diff --git a/crates/benchmarks/benches/modules/noise_models.rs b/crates/benchmarks/benches/modules/noise_models.rs index fa009072c..fb0f9b71a 100644 --- a/crates/benchmarks/benches/modules/noise_models.rs +++ b/crates/benchmarks/benches/modules/noise_models.rs @@ -97,10 +97,10 @@ fn bench_depolarizing_noise(c: &mut Criterion) { // Benchmark mixed gate set (more realistic) group.bench_with_input(BenchmarkId::new("mixed", num_gates), &num_gates, |b, &n| { let mut noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) - .with_meas_probability(0.001) - .with_single_qubit_probability(0.0005) - .with_two_qubit_probability(0.002) + .with_p_prep(0.001) + .with_p_meas(0.001) + .with_p1(0.0005) + .with_p2(0.002) .with_seed(42) .build(); @@ -130,10 +130,10 @@ fn bench_depolarizing_noise(c: &mut Criterion) { b.iter(|| { // Recreate with seed for reproducibility noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) - .with_meas_probability(0.001) - .with_single_qubit_probability(0.0005) - .with_two_qubit_probability(0.002) + .with_p_prep(0.001) + .with_p_meas(0.001) + .with_p1(0.0005) + .with_p2(0.002) .with_seed(42) .build(); let result = noise.start(input.clone()).unwrap(); diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index fe0655e72..9be5baf34 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -569,11 +569,11 @@ fn run_program(args: &RunArgs) -> Result<(), PecosError> { parse_general_noise_probabilities(args.noise_probability.as_ref()); builder = builder.noise( GeneralNoiseModelBuilder::new() - .with_prep_probability(prep) - .with_meas_0_probability(meas_0) - .with_meas_1_probability(meas_1) - .with_p1_probability(single_qubit) - .with_p2_probability(two_qubit), + .with_p_prep(prep) + .with_p_meas_0(meas_0) + .with_p_meas_1(meas_1) + .with_p1(single_qubit) + .with_p2(two_qubit), ); } } diff --git a/crates/pecos-engines/examples/biased_depolarizing_example.rs b/crates/pecos-engines/examples/biased_depolarizing_example.rs index c344619ee..e2bd9a910 100644 --- a/crates/pecos-engines/examples/biased_depolarizing_example.rs +++ b/crates/pecos-engines/examples/biased_depolarizing_example.rs @@ -51,11 +51,11 @@ fn example1_different_bias_levels(circ: &ByteMessage, quantum: &StateVecEngine) for (p_flip_0, p_flip_1, desc) in configs { // Create the biased depolarizing noise model let noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) // Probability of flipping 0 to 1 - .with_meas_1_probability(p_flip_1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) // Probability of flipping 0 to 1 + .with_p_meas_1(p_flip_1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .build(); let mut system = QuantumSystem::new(Box::new(noise), Box::new(quantum.clone())); @@ -110,11 +110,11 @@ fn example2_with_seed(circ: &ByteMessage) { println!("Example 2: Using direct constructor with seed"); let noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.4) // Probability of flipping 0 to 1 - .with_meas_1_probability(0.1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.4) // Probability of flipping 0 to 1 + .with_p_meas_1(0.1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .with_seed(123) .build(); let quantum = Box::new(StateVecEngine::new(1)); @@ -170,11 +170,11 @@ fn example3_bell_state() { // Create a new quantum system with 2 qubits let quantum2 = Box::new(StateVecEngine::new(2)); let noise2 = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.2) // Probability of flipping 0 to 1 - .with_meas_1_probability(0.3) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.2) // Probability of flipping 0 to 1 + .with_p_meas_1(0.3) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .build(); let mut system2 = QuantumSystem::new(Box::new(noise2), quantum2); diff --git a/crates/pecos-engines/examples/compare_noise_models.rs b/crates/pecos-engines/examples/compare_noise_models.rs index b3a7c71e1..36483b785 100644 --- a/crates/pecos-engines/examples/compare_noise_models.rs +++ b/crates/pecos-engines/examples/compare_noise_models.rs @@ -41,11 +41,11 @@ fn compare_depolarizing_with_general(circ: &ByteMessage) { // Create equivalent general noise model let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(p_noise) - .with_meas_0_probability(p_noise) - .with_meas_1_probability(p_noise) - .with_p1_probability(p_noise) - .with_p2_probability(p_noise) + .with_p_prep(p_noise) + .with_p_meas_0(p_noise) + .with_p_meas_1(p_noise) + .with_p1(p_noise) + .with_p2(p_noise) .with_seed(seed) .build(); let mut general_system = QuantumSystem::new(Box::new(general_noise), Box::new(quantum.clone())); @@ -183,11 +183,11 @@ fn test_asymmetric_measurements() { let p1 = 0.05; let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(p_meas_1) - .with_p1_probability(p1) - .with_p2_probability(0.0) // Not used in this circuit + .with_p_prep(p_prep) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(p_meas_1) + .with_p1(p1) + .with_p2(0.0) // Not used in this circuit .with_seed(seed) .build(); let mut general_system = QuantumSystem::new(Box::new(general_noise), Box::new(quantum.clone())); @@ -195,10 +195,10 @@ fn test_asymmetric_measurements() { // For comparison, a depolarizing model with symmetric errors let p_depolarizing = f64::midpoint(p_meas_0, p_meas_1); // Average of the asymmetric errors let depolarizing_noise = DepolarizingNoiseModel::builder() - .with_prep_probability(p_prep) - .with_meas_probability(p_depolarizing) - .with_single_qubit_probability(p1) - .with_two_qubit_probability(0.0) + .with_p_prep(p_prep) + .with_p_meas(p_depolarizing) + .with_p1(p1) + .with_p2(0.0) .with_seed(seed) .build(); let mut depolarizing_system = diff --git a/crates/pecos-engines/examples/general_noise_test.rs b/crates/pecos-engines/examples/general_noise_test.rs index 84cb1a709..c2315b99c 100644 --- a/crates/pecos-engines/examples/general_noise_test.rs +++ b/crates/pecos-engines/examples/general_noise_test.rs @@ -49,11 +49,11 @@ fn compare_biased_and_general(circ: &ByteMessage, quantum: &StateVecEngine) { for (p_flip_0, p_flip_1, desc) in configs { // Create biased depolarizing noise model with custom settings let biased_noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) // Probability of flipping 0 to 1 - .with_meas_1_probability(p_flip_1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) // Probability of flipping 0 to 1 + .with_p_meas_1(p_flip_1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut biased_system = @@ -61,11 +61,11 @@ fn compare_biased_and_general(circ: &ByteMessage, quantum: &StateVecEngine) { // Create equivalent general noise model (with gate noise set to 0) let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) - .with_meas_1_probability(p_flip_1) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) + .with_p_meas_1(p_flip_1) + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut general_system = @@ -155,22 +155,22 @@ fn bell_state_comparison() { // Create biased depolarizing noise model with custom settings let biased_noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) // Probability of flipping 0 to 1 - .with_meas_1_probability(p_flip_1) // Probability of flipping 1 to 0 - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) // Probability of flipping 0 to 1 + .with_p_meas_1(p_flip_1) // Probability of flipping 1 to 0 + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut biased_system = QuantumSystem::new(Box::new(biased_noise), Box::new(quantum.clone())); // Create equivalent general noise model let general_noise = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_flip_0) - .with_meas_1_probability(p_flip_1) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_flip_0) + .with_p_meas_1(p_flip_1) + .with_p1(0.0) + .with_p2(0.0) .with_seed(seed) .build(); let mut general_system = QuantumSystem::new(Box::new(general_noise), Box::new(quantum.clone())); diff --git a/crates/pecos-engines/examples/run_noisy_circ_with_general.rs b/crates/pecos-engines/examples/run_noisy_circ_with_general.rs index 2a6da30f6..37eb7fbe7 100644 --- a/crates/pecos-engines/examples/run_noisy_circ_with_general.rs +++ b/crates/pecos-engines/examples/run_noisy_circ_with_general.rs @@ -30,11 +30,11 @@ fn main() { // Create GeneralNoise with uniform probability for all error types let mut noise_builder = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1); + .with_p_prep(0.1) + .with_p_meas_0(0.1) + .with_p_meas_1(0.1) + .with_p1(0.1) + .with_p2(0.1); // Set seed if provided if let Some(seed) = seed_option { diff --git a/crates/pecos-engines/src/noise/biased_depolarizing.rs b/crates/pecos-engines/src/noise/biased_depolarizing.rs index bf9e9fd4f..82ecfc211 100644 --- a/crates/pecos-engines/src/noise/biased_depolarizing.rs +++ b/crates/pecos-engines/src/noise/biased_depolarizing.rs @@ -41,11 +41,11 @@ use std::any::Any; /// /// // Or use the builder pattern /// let noise_model = BiasedDepolarizingNoiseModel::builder() -/// .with_prep_probability(0.01) -/// .with_meas_0_probability(0.02) -/// .with_meas_1_probability(0.03) -/// .with_single_qubit_probability(0.04) -/// .with_two_qubit_probability(0.05) +/// .with_p_prep(0.01) +/// .with_p_meas_0(0.02) +/// .with_p_meas_1(0.03) +/// .with_p1(0.04) +/// .with_p2(0.05) /// .with_seed(42) /// .build(); /// @@ -512,7 +512,19 @@ impl RngManageable for BiasedDepolarizingNoiseModel { } } -/// Builder for creating biased depolarizing noise models +/// Builder for creating biased depolarizing noise models. +/// +/// The retired descriptive probability setters are intentionally unavailable: +/// +/// ```compile_fail +/// use pecos_engines::noise::BiasedDepolarizingNoiseModel; +/// let _ = BiasedDepolarizingNoiseModel::builder().with_single_qubit_probability(0.01); +/// ``` +/// +/// ```compile_fail +/// use pecos_engines::noise::BiasedDepolarizingNoiseModel; +/// let _ = BiasedDepolarizingNoiseModel::builder().with_two_qubit_probability(0.01); +/// ``` #[derive(Debug, Clone)] pub struct BiasedDepolarizingNoiseModelBuilder { p_prep: Option, @@ -561,55 +573,39 @@ impl BiasedDepolarizingNoiseModelBuilder { /// Set the probability of error during preparation #[must_use] - pub fn with_prep_probability(mut self, probability: f64) -> Self { + pub fn with_p_prep(mut self, probability: f64) -> Self { self.p_prep = Some(probability); self } /// Set the probability of flipping 0 to 1 during measurement #[must_use] - pub fn with_meas_0_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_0(mut self, probability: f64) -> Self { self.p_meas_0 = Some(probability); self } /// Set the probability of flipping 1 to 0 during measurement #[must_use] - pub fn with_meas_1_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_1(mut self, probability: f64) -> Self { self.p_meas_1 = Some(probability); self } /// Set the probability of error after single-qubit gates #[must_use] - pub fn with_p1_probability(mut self, probability: f64) -> Self { + pub fn with_p1(mut self, probability: f64) -> Self { self.p1 = Some(probability); self } - /// Set the probability of error after single-qubit gates - /// - /// This is an alias for `with_p1_probability` for API consistency. - #[must_use] - pub fn with_single_qubit_probability(self, probability: f64) -> Self { - self.with_p1_probability(probability) - } - /// Set the probability of error after two-qubit gates #[must_use] - pub fn with_p2_probability(mut self, probability: f64) -> Self { + pub fn with_p2(mut self, probability: f64) -> Self { self.p2 = Some(probability); self } - /// Set the probability of error after two-qubit gates - /// - /// This is an alias for `with_p2_probability` for API consistency. - #[must_use] - pub fn with_two_qubit_probability(self, probability: f64) -> Self { - self.with_p2_probability(probability) - } - /// Set the seed for the random number generator #[must_use] pub fn with_seed(mut self, seed: u64) -> Self { @@ -715,11 +711,11 @@ mod tests { fn test_builder() { // Create a noise model with the builder let noise = BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.2) - .with_meas_1_probability(0.3) - .with_p1_probability(0.4) - .with_p2_probability(0.5) + .with_p_prep(0.1) + .with_p_meas_0(0.2) + .with_p_meas_1(0.3) + .with_p1(0.4) + .with_p2(0.5) .build(); // Get the boxed noise model's probabilities using any_ref downcast diff --git a/crates/pecos-engines/src/noise/depolarizing.rs b/crates/pecos-engines/src/noise/depolarizing.rs index cfa7d9ac6..01cacf463 100644 --- a/crates/pecos-engines/src/noise/depolarizing.rs +++ b/crates/pecos-engines/src/noise/depolarizing.rs @@ -40,10 +40,10 @@ use std::any::Any; /// /// // Or use the builder pattern /// let noise_model = DepolarizingNoiseModel::builder() -/// .with_prep_probability(0.01) -/// .with_meas_probability(0.02) -/// .with_single_qubit_probability(0.03) -/// .with_two_qubit_probability(0.04) +/// .with_p_prep(0.01) +/// .with_p_meas(0.02) +/// .with_p1(0.03) +/// .with_p2(0.04) /// .with_seed(42) /// .build(); /// @@ -439,7 +439,19 @@ impl RngManageable for DepolarizingNoiseModel { } } -/// Builder for creating depolarizing noise models +/// Builder for creating depolarizing noise models. +/// +/// The retired descriptive probability setters are intentionally unavailable: +/// +/// ```compile_fail +/// use pecos_engines::noise::DepolarizingNoiseModel; +/// let _ = DepolarizingNoiseModel::builder().with_single_qubit_probability(0.01); +/// ``` +/// +/// ```compile_fail +/// use pecos_engines::noise::DepolarizingNoiseModel; +/// let _ = DepolarizingNoiseModel::builder().with_two_qubit_probability(0.01); +/// ``` #[derive(Debug, Clone)] pub struct DepolarizingNoiseModelBuilder { p_prep: Option, @@ -485,48 +497,32 @@ impl DepolarizingNoiseModelBuilder { /// Set the probability of error during preparation #[must_use] - pub fn with_prep_probability(mut self, probability: f64) -> Self { + pub fn with_p_prep(mut self, probability: f64) -> Self { self.p_prep = Some(probability); self } /// Set the probability of error during measurement #[must_use] - pub fn with_meas_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas(mut self, probability: f64) -> Self { self.p_meas = Some(probability); self } /// Set the probability of error after single-qubit gates #[must_use] - pub fn with_p1_probability(mut self, probability: f64) -> Self { + pub fn with_p1(mut self, probability: f64) -> Self { self.p1 = Some(probability); self } - /// Set the probability of error after single-qubit gates - /// - /// This is an alias for `with_p1_probability` for API consistency. - #[must_use] - pub fn with_single_qubit_probability(self, probability: f64) -> Self { - self.with_p1_probability(probability) - } - /// Set the probability of error after two-qubit gates #[must_use] - pub fn with_p2_probability(mut self, probability: f64) -> Self { + pub fn with_p2(mut self, probability: f64) -> Self { self.p2 = Some(probability); self } - /// Set the probability of error after two-qubit gates - /// - /// This is an alias for `with_p2_probability` for API consistency. - #[must_use] - pub fn with_two_qubit_probability(self, probability: f64) -> Self { - self.with_p2_probability(probability) - } - /// Set the seed for the random number generator #[must_use] pub fn with_seed(mut self, seed: u64) -> Self { @@ -697,10 +693,10 @@ mod tests { fn test_builder() { // Create a noise model with the builder let mut noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_probability(0.2) - .with_p1_probability(0.3) - .with_p2_probability(0.4) + .with_p_prep(0.1) + .with_p_meas(0.2) + .with_p1(0.3) + .with_p2(0.4) .build(); // Create a direct instance with the same probabilities @@ -780,14 +776,42 @@ mod tests { assert!((p2 - 0.5).abs() < f64::EPSILON); } + #[test] + fn field_name_setters_match_pre_removal_alias_bytes() { + let mut noise = DepolarizingNoiseModel::builder() + .with_p_prep(0.0) + .with_p_meas(0.0) + .with_p1(1.0) + .with_p2(1.0) + .with_seed(0x5eed) + .build(); + + let mut builder = ByteMessage::quantum_operations_builder(); + builder.x(&[0]); + builder.cx(&[(0, 1)]); + + let EngineStage::NeedsProcessing(output) = noise.start(builder.build()).unwrap() else { + panic!("noise model unexpectedly completed"); + }; + assert_eq!( + output.as_bytes(), + [ + 83, 67, 69, 80, 1, 0, 0, 0, 4, 0, 0, 0, 84, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 12, + 0, 0, 0, 50, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, + 1, 0, 0, 0, + ] + ); + } + #[test] fn test_builder_with_probability() { // Create a noise model with the builder let mut noise = DepolarizingNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_probability(0.02) - .with_p1_probability(0.03) - .with_p2_probability(0.04) + .with_p_prep(0.01) + .with_p_meas(0.02) + .with_p1(0.03) + .with_p2(0.04) .build(); // Create a direct instance with the same probabilities diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index 8b423d700..39751a137 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -63,11 +63,11 @@ //! //! // Using the builder with explicit error rates //! let noise_model = GeneralNoiseModel::builder() -//! .with_prep_probability(0.01) -//! .with_meas_0_probability(0.02) -//! .with_meas_1_probability(0.03) -//! .with_p1_probability(0.04) -//! .with_p2_probability(0.05) +//! .with_p_prep(0.01) +//! .with_p_meas_0(0.02) +//! .with_p_meas_1(0.03) +//! .with_p1(0.04) +//! .with_p2(0.05) //! .with_seed(42) //! .build(); //! ``` @@ -94,7 +94,7 @@ use pecos_core::errors::PecosError; use pecos_core::{Angle64, QubitId}; use pecos_random::PecosRng; use std::any::Any; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; /// General noise model with parameterized error channels. /// @@ -120,15 +120,6 @@ pub struct GeneralNoiseModel { /// 1.0 means all leakage events remain leakage events. leakage_scale: f64, - /// Whether to use coherent dephasing vs incoherent (stochastic) dephasing - /// - /// If true, dephasing is modeled as coherent phase rotations using RZ gates. - /// If false, dephasing is modeled as stochastic Z errors with quadratic scaling. - /// - /// In physical systems, coherent dephasing represents systematic phase evolution - /// such as frequency offsets. - p_idle_coherent: bool, - /// The idle noise rate for linear dependency on time (seconds). /// /// This always applies stochastic noise @@ -142,23 +133,17 @@ pub struct GeneralNoiseModel { /// the input. p_idle_linear_model: SingleQubitWeightedSampler, - /// The idle noise rate for quadratic dependency on time (seconds). - /// - /// This will be a coherent noise channel unless `p_idle_coherent` is set to false. If it is - /// false it will apply Z to each qubit quadratic dependency on time - p_idle_quadratic_rate: f64, + /// DEM-style stochastic sine-squared idle rate in radians per time unit. + p_idle_sin_squared_rate: f64, - /// Scaling factor to convert coherent dephasing rates to incoherent rates - /// - /// When using incoherent (stochastic) dephasing, this factor adjusts the dephasing rate. This - /// is a fudge factor used to artificially increase the dephasing rate when modeling the - /// quadratic dephasing stochastically since such modeling does not account for coherent - /// effects. - /// - /// # Panics - /// - /// Panics if the factor is not positive (less than or equal to 0.0). - p_idle_coherent_to_incoherent_factor: f64, + /// Unnormalized per-axis relative multipliers for the sine-squared idle family. + p_idle_sin_squared_model: BTreeMap, + + /// DEM-style coherent idle rate in radians per time unit. + p_idle_coherent_rate: f64, + + /// Unnormalized RX/RY/RZ relative multipliers for the coherent idle family. + p_idle_coherent_model: BTreeMap, /// Probability of applying a fault during preparation (initialization) /// @@ -282,11 +267,13 @@ pub struct GeneralNoiseModel { /// The distribution is stored as pre-computed, cached sampler instead of the `HashMap` that is the input. p2_pauli_model: TwoQubitWeightedSampler, - /// Idle noise after each two-qubit gate where noise will be applied stochastically based on - /// `p2_idle`. + /// Duration of the idle-noise site applied to each qubit after a two-qubit gate. /// - /// This may be useful for memory sweeping. - p2_idle: f64, + /// A value of `0.0` disables these sites. For a nonzero duration, the sites receive the same + /// configured idle families as a real [`GateType::Idle`] operation: linear stochastic noise, + /// independent per-axis sine-squared noise, and coherent rotations. The duration is not itself + /// an error probability. + idle_after_2q: f64, /// Probability of flipping a 0 measurement to 1 /// @@ -454,9 +441,9 @@ impl GeneralNoiseModel { /// Create a new noise model with the specified error parameters /// - /// Creates a `GeneralNoiseModel` with the specified error probabilities while using default values - /// for all other parameters. This is a convenience method for cases where you only need to customize - /// the basic error rates. + /// Creates a `GeneralNoiseModel` with the specified error probabilities while using no-effect + /// defaults for all other parameters. This is a convenience method for cases where you only need + /// to customize the basic error rates. /// /// * `p_prep` - Preparation (initialization) error probability /// * `p_meas_0` - Probability of measuring 1 when the state is |0⟩ @@ -465,7 +452,7 @@ impl GeneralNoiseModel { /// * `p2` - Two-qubit gate error probability (average error rate) /// /// For more extensive customization, use the builder pattern with `GeneralNoiseModel::builder()`. - /// For default parameters, use `GeneralNoiseModel::default()`. + /// For a noiseless model, use `GeneralNoiseModel::default()`. /// /// # Example /// ``` @@ -586,12 +573,7 @@ impl GeneralNoiseModel { // decide whether to add the original gate based on error models match gate.gate_type { GateType::Idle => { - self.apply_idle_faults( - &gate, - self.p_idle_linear_rate, - self.p_idle_quadratic_rate, - &mut builder, - ); + self.apply_idle_faults(&gate, self.p_idle_linear_rate, &mut builder); } GateType::PZ => { for &q in &gate.qubits { @@ -817,28 +799,29 @@ impl GeneralNoiseModel { &mut self, gate: &Gate, linear_rate: f64, - quadratic_rate: f64, + builder: &mut ByteMessageBuilder, + ) { + let qubits: Vec = gate.qubits.iter().map(|q| usize::from(*q)).collect(); + self.apply_idle_faults_for_duration(linear_rate, gate.idle_duration(), &qubits, builder); + } + + fn apply_idle_faults_for_duration( + &mut self, + linear_rate: f64, + duration: f64, + qubits: &[usize], builder: &mut ByteMessageBuilder, ) { if linear_rate > f64::EPSILON { - let qubits_usize: Vec = gate.qubits.iter().map(|q| usize::from(*q)).collect(); - self.apply_idle_linear_stochastic_noise( - linear_rate, - gate.idle_duration(), - &qubits_usize, - builder, - ); + self.apply_idle_linear_stochastic_noise(linear_rate, duration, qubits, builder); } - if quadratic_rate.abs() > f64::EPSILON { - // TODO: add test - let qubits_usize: Vec = gate.qubits.iter().map(|q| usize::from(*q)).collect(); - self.apply_idle_quadratic_dephasing( - quadratic_rate, - gate.idle_duration(), - &qubits_usize, - builder, - ); + if self.p_idle_sin_squared_rate > f64::EPSILON && duration.abs() > f64::EPSILON { + self.apply_idle_sin_squared(duration, qubits, builder); + } + + if self.p_idle_coherent_rate > f64::EPSILON && duration.abs() > f64::EPSILON { + self.apply_idle_coherent(duration, qubits, builder); } } @@ -867,54 +850,103 @@ impl GeneralNoiseModel { } } - /// Apply coherent dephasing noise to a gate - /// - /// This method implements coherent phase rotation (systematic Z-rotation) noise - /// that occurs during idle periods or during gates with a specified duration. - /// - /// In physical systems, coherent dephasing represents: - /// - Systematic phase errors due to energy level shifts - /// - Frequency offsets in control fields - /// - AC Stark shifts - /// - Other systematic Z-rotation errors - /// - /// # Parameters - /// * `builder` - The `ByteMessageBuilder` to add gate operations to - /// * `angle` - The time duration over which idling occurs times the rate per time - /// * `qubits` - The qubits that are potentially affected by the idling noise - fn apply_idle_quadratic_dephasing( + /// Apply the DEM-style stochastic sine-squared family independently per axis. + fn apply_idle_sin_squared( &mut self, - rate: f64, duration: f64, qubits: &[usize], builder: &mut ByteMessageBuilder, ) { - let mut angle = rate * duration; - - angle = if self.p_idle_coherent { - angle - } else { - angle.sin().powi(2) - }; + for axis in ["X", "Y", "Z", "L"] { + let Some(multiplier) = self.p_idle_sin_squared_model.get(axis).copied() else { + continue; + }; + let probability = + Self::sin_squared_probability(self.p_idle_sin_squared_rate, multiplier, duration); + if probability <= f64::EPSILON { + continue; + } - if angle.abs() > f64::EPSILON { - let mut noisy_qubits = vec![]; + let affected = qubits + .iter() + .copied() + .filter(|qubit| !self.is_leaked(*qubit) && self.rng.occurs(probability)) + .collect::>(); + if affected.is_empty() { + continue; + } - for qubit in qubits { - if !self.is_leaked(*qubit) && (self.p_idle_coherent || self.rng.occurs(angle)) { - noisy_qubits.push(*qubit); + match axis { + "X" => { + builder.x(&affected); } + "Y" => { + builder.y(&affected); + } + "Z" => { + builder.z(&affected); + } + "L" => { + for qubit in affected { + if let Some(gate) = self.leak(qubit) { + builder.add_gate_command(&gate); + } + } + } + _ => unreachable!("sine-family model was validated by the builder"), } - if !noisy_qubits.is_empty() { - if self.p_idle_coherent { - builder.rz(Angle64::from_radians(angle), &noisy_qubits); - } else { - builder.z(&noisy_qubits); + } + } + + fn sin_squared_probability(rate: f64, multiplier: f64, duration: f64) -> f64 { + (rate * multiplier * duration).sin().powi(2) + } + + /// Apply deterministic coherent idle rotations in RX/RY/RZ order. + fn apply_idle_coherent( + &self, + duration: f64, + qubits: &[usize], + builder: &mut ByteMessageBuilder, + ) { + let affected = qubits + .iter() + .copied() + .filter(|qubit| !self.is_leaked(*qubit)) + .collect::>(); + if affected.is_empty() { + return; + } + + for axis in ["RX", "RY", "RZ"] { + let Some(multiplier) = self.p_idle_coherent_model.get(axis).copied() else { + continue; + }; + let angle = + Self::coherent_rotation_angle(self.p_idle_coherent_rate, multiplier, duration); + if angle <= f64::EPSILON { + continue; + } + + match axis { + "RX" => { + builder.rx(Angle64::from_radians(angle), &affected); } + "RY" => { + builder.ry(Angle64::from_radians(angle), &affected); + } + "RZ" => { + builder.rz(Angle64::from_radians(angle), &affected); + } + _ => unreachable!("coherent-family model was validated by the builder"), } } } + fn coherent_rotation_angle(rate: f64, multiplier: f64, duration: f64) -> f64 { + rate * multiplier * duration + } + /// Apply preparation (initialization) noise /// /// State prep noise model: @@ -1243,11 +1275,16 @@ impl GeneralNoiseModel { builder.add_gate_commands(&noise); - if self.p2_idle > f64::EPSILON { - self.apply_idle_linear_stochastic_noise( - self.p2_idle, - 1.0, - &original_gate_qubits, + if self.idle_after_2q > f64::EPSILON { + let gate_qubits = gate + .qubits + .iter() + .map(|q| usize::from(*q)) + .collect::>(); + self.apply_idle_faults_for_duration( + self.p_idle_linear_rate, + self.idle_after_2q, + &gate_qubits, builder, ); } @@ -1480,63 +1517,94 @@ mod tests { use crate::byte_message::GateType; use pecos_core::Angle64; + fn assert_float_eq(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected {expected}, got {actual}" + ); + } + #[test] fn test_default() { - // Create a noise model with the default settings let model = GeneralNoiseModel::default(); - // Check the default values - assert!( - (model.p_prep - 0.01).abs() < f64::EPSILON, - "Default p_prep should be 0.01" - ); - assert!( - (model.p_meas_0 - 0.01).abs() < f64::EPSILON, - "Default p_meas_0 should be 0.01" - ); - assert!( - (model.p_meas_1 - 0.01).abs() < f64::EPSILON, - "Default p_meas_1 should be 0.01" - ); - assert!( - (model.p1 - 0.001).abs() < f64::EPSILON, - "Default p1 should be 0.001" - ); - assert!( - (model.p2 - 0.01).abs() < f64::EPSILON, - "Default p2 should be 0.01" - ); - assert!( - (model.p1_emission_ratio - 0.5).abs() < f64::EPSILON, - "Default p1_emission_ratio should be 0.5" - ); - assert!( - (model.p_prep_leak_ratio - 0.5).abs() < f64::EPSILON, - "Default p_prep_leak_ratio should be 0.5" - ); - assert!( - (model.p2_emission_ratio - 0.5).abs() < f64::EPSILON, - "Default p2_emission_ratio should be 0.5" - ); - assert!( - (model.p1_seepage_prob - 0.5).abs() < f64::EPSILON, - "Default seepage_prob should be 0.5" + assert_float_eq(model.p_prep, 0.0); + assert_float_eq(model.p_meas_0, 0.0); + assert_float_eq(model.p_meas_1, 0.0); + assert_float_eq(model.p1, 0.0); + assert_float_eq(model.p2, 0.0); + assert_float_eq(model.p_idle_linear_rate, 0.0); + assert_float_eq(model.p_idle_sin_squared_rate, 0.0); + assert_float_eq(model.p_idle_coherent_rate, 0.0); + assert_eq!( + model.p_idle_coherent_model, + BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]) + ); + assert!(model.p_idle_sin_squared_model.is_empty()); + assert_float_eq(model.p1_emission_ratio, 0.0); + assert_float_eq(model.p_prep_leak_ratio, 0.0); + assert_float_eq(model.p2_emission_ratio, 0.0); + assert_float_eq(model.p1_seepage_prob, 0.0); + assert_float_eq(model.p2_seepage_prob, 0.0); + assert_float_eq(model.idle_after_2q, 0.0); + assert_float_eq(model.p_meas_crosstalk_global, 0.0); + assert_float_eq(model.p_meas_crosstalk_local, 0.0); + assert_float_eq(model.p_prep_crosstalk, 0.0); + assert_float_eq(model.p2_angle_a, 0.0); + assert_float_eq(model.p2_angle_b, 1.0); + assert_float_eq(model.p2_angle_c, 0.0); + assert_float_eq(model.p2_angle_d, 1.0); + assert_float_eq(model.p2_angle_power, 1.0); + assert_float_eq(model.leakage_scale, 1.0); + assert_eq!( + model.p_meas_crosstalk_model.get_weighted_map(0), + &BTreeMap::from([("0->0".to_string(), 1.0)]) ); - assert!( - (model.p2_seepage_prob - 0.5).abs() < f64::EPSILON, - "Default seepage_prob should be 0.5" + assert_eq!( + model.p_meas_crosstalk_model.get_weighted_map(1), + &BTreeMap::from([("1->1".to_string(), 1.0)]) ); } + #[test] + fn default_model_emits_no_noise_gates_for_full_circuit() { + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.pz(&[0, 1]); + input_builder.h(&[0]); + input_builder.cx(&[(0, 1)]); + input_builder.idle(2.0, &[0, 1]); + input_builder.mz(&[0, 1]); + let input = input_builder.build(); + let expected = input + .quantum_ops() + .unwrap() + .into_iter() + .filter(|gate| gate.gate_type != GateType::Idle) + .collect::>(); + + let mut model = GeneralNoiseModel::default(); + let emitted = model + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap(); + + assert_eq!(emitted, expected); + } + #[test] fn test_builder() { // Create a noise model with the builder let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.2) - .with_meas_1_probability(0.3) - .with_average_p1_probability(0.4) - .with_average_p2_probability(0.5) + .with_p_prep(0.1) + .with_p_meas_0(0.2) + .with_p_meas_1(0.3) + .with_average_p1(0.4) + .with_average_p2(0.5) .with_prep_leak_ratio(0.6) .build(); @@ -1586,22 +1654,15 @@ mod tests { assert!((p_prep_leak_ratio - 0.6).abs() < f64::EPSILON); - // Test the builder with no parameters (should use defaults) + // Test the builder with no parameters (should use no-effect defaults) let default_noise = GeneralNoiseModel::builder().build(); let default_ref = default_noise .as_any() .downcast_ref::() .unwrap(); - // Verify a few key default values - assert!( - (default_ref.p1 - 0.001).abs() < 1e-6, - "Default p1 should be 0.001" - ); - assert!( - (default_ref.p2 - 0.01).abs() < 1e-6, - "Default p2 should be 0.01" - ); + assert_float_eq(default_ref.p1, 0.0); + assert_float_eq(default_ref.p2, 0.0); } /// Helper function to invoke a measurement request from the user to the noise @@ -1691,7 +1752,7 @@ mod tests { // Create a noise model with 100% prep error probability and 100% leakage ratio // using the builder pattern let mut model = GeneralNoiseModel::builder() - .with_prep_probability(1.0) + .with_p_prep(1.0) .with_prep_leak_ratio(1.0) .build(); let noise = model @@ -1721,7 +1782,7 @@ mod tests { // Now, create a noise model with 100% prep error probability but 0% leakage ratio let mut model = GeneralNoiseModel::builder() - .with_prep_probability(1.0) + .with_p_prep(1.0) .with_prep_leak_ratio(0.0) .build(); let noise = model @@ -1744,11 +1805,11 @@ mod tests { // Test builder configuration let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1) + .with_p_prep(0.1) + .with_p_meas_0(0.1) + .with_p_meas_1(0.1) + .with_p1(0.1) + .with_p2(0.1) .with_prep_leak_ratio(0.7) .build(); @@ -1765,11 +1826,11 @@ mod tests { fn test_leaked_qubit_measurement_behavior() { // Create a noise model with no spontaneous errors let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); // Manually mark qubit 0 as leaked @@ -1800,11 +1861,11 @@ mod tests { fn test_repeated_measurement_of_leaked_qubit() { // Create a noise model with no spontaneous errors let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); // Manually mark qubit 0 as leaked @@ -1847,11 +1908,11 @@ mod tests { // Create a noise model with no spontaneous errors let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model .as_any_mut() @@ -1914,8 +1975,8 @@ mod tests { // Create a noise model with biased measurement probabilities let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.3) // 30% chance of flipping 0 to 1 - .with_meas_1_probability(0.2) // 20% chance of flipping 1 to 0 + .with_p_meas_0(0.3) // 30% chance of flipping 0 to 1 + .with_p_meas_1(0.2) // 20% chance of flipping 1 to 0 .with_seed(42) // Use fixed seed for deterministic test .build(); let noise = model @@ -1982,8 +2043,8 @@ mod tests { // Create a noise model with no measurement errors let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) .build(); let noise = model .as_any_mut() @@ -2041,8 +2102,8 @@ mod tests { // Create a noise model with strong asymmetric bias // 80% chance of flipping 0->1, only 10% chance of flipping 1->0 let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.8) // Strong bias: 0 -> 1 - .with_meas_1_probability(0.1) // Weak bias: 1 -> 0 + .with_p_meas_0(0.8) // Strong bias: 0 -> 1 + .with_p_meas_1(0.1) // Weak bias: 1 -> 0 .with_seed(12345) // Fixed seed for reproducibility .build(); let noise = model @@ -2135,8 +2196,8 @@ mod tests { // Test with extreme biases to make the effect very clear // Case 1: Always flip 0->1, never flip 1->0 let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(1.0) // Always flip 0->1 - .with_meas_1_probability(0.0) // Never flip 1->0 + .with_p_meas_0(1.0) // Always flip 0->1 + .with_p_meas_1(0.0) // Never flip 1->0 .build(); let noise = model .as_any_mut() @@ -2172,8 +2233,8 @@ mod tests { // Case 2: Never flip 0->1, always flip 1->0 let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.0) // Never flip 0->1 - .with_meas_1_probability(1.0) // Always flip 1->0 + .with_p_meas_0(0.0) // Never flip 0->1 + .with_p_meas_1(1.0) // Always flip 1->0 .build(); let noise = model .as_any_mut() @@ -2216,11 +2277,11 @@ mod tests { // Create a noise model with no errors (deterministic) let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model @@ -2283,11 +2344,11 @@ mod tests { // Create a noise model with no measurement errors (deterministic) let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model @@ -2336,11 +2397,11 @@ mod tests { // Create a noise model with no measurement errors (deterministic) let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .build(); let noise = model @@ -2396,8 +2457,8 @@ mod tests { // Test that leaked qubits are forced to 1, then bias is applied let mut model = GeneralNoiseModel::builder() - .with_meas_0_probability(0.0) // No 0->1 flips - .with_meas_1_probability(0.5) // 50% chance to flip 1->0 + .with_p_meas_0(0.0) // No 0->1 flips + .with_p_meas_1(0.5) // 50% chance to flip 1->0 .with_seed(42) .build(); let noise = model @@ -2612,16 +2673,17 @@ mod tests { fn test_parameter_scaling() { // Test that scaling factors are applied correctly - use builder pattern let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.01) - .with_average_p2_probability(0.01) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.01) + .with_average_p2(0.01) .with_scale(2.0) .with_p1_scale(3.0) .with_p2_scale(4.0) .with_prep_scale(5.0) .with_meas_scale(6.0) + .with_prep_leak_ratio(0.5) .with_leakage_scale(0.25) .build(); let noise = model @@ -2638,8 +2700,7 @@ mod tests { let expected_p1 = 0.01 * 3.0 * 2.0 * (3.0 / 2.0); // Base * p1_scale * overall scale * avg->total let expected_p2 = 0.01 * 4.0 * 2.0 * (5.0 / 4.0); // Base * p2_scale * overall scale * avg->total - // Initial value in constructor is 0.5 - // and we scale it by overall scale (2.0) + // The configured ratio is scaled by the overall scale (2.0). let expected_leak_ratio = 0.5 * 2.0; // Base * overall scale, capped at 1.0 println!( @@ -2686,11 +2747,11 @@ mod tests { fn test_builder_with_scaling() { // Test that builder applies scaling factors correctly let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.01) - .with_average_p2_probability(0.01) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.01) + .with_average_p2(0.01) .with_prep_leak_ratio(0.01) .with_scale(2.0) .with_p1_scale(3.0) @@ -2759,8 +2820,9 @@ mod tests { #[test] fn test_emission_ratio_scaling() { // Test that emission ratios are properly scaled and capped at a maximum of 1.0 - // Default emission ratios are 0.5 let mut model = GeneralNoiseModel::builder() + .with_p1_emission_ratio(0.5) + .with_p2_emission_ratio(0.5) .with_scale(3.0) .with_emission_scale(4.0) .build(); @@ -2769,7 +2831,7 @@ mod tests { .downcast_mut::() .unwrap(); - // Verify both ratios are 0.5 after scaling + // Verify both configured ratios are capped after scaling. // When scaled: 0.5 * 3.0 (scale) * 4.0 (emission_scale) = 6.0 // But capped at 1.0 assert!( @@ -2798,15 +2860,714 @@ mod tests { assert!((noise.p2_emission_ratio - 0.6).abs() < 1e-6); } + fn after_2q_outputs( + duration: f64, + linear_rate: f64, + coherent_rate: f64, + seed: u64, + shots: usize, + ) -> Vec> { + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.cx(&[(0, 1)]); + let input = input_builder.build(); + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); + + let mut model = GeneralNoiseModel::builder() + .with_p2(0.0) + .with_p_idle_linear(linear_rate, &linear_model) + .with_p_idle_coherent(coherent_rate, &coherent_model) + .with_idle_after_2q(duration) + .with_seed(seed) + .build(); + + (0..shots) + .map(|_| { + model + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap() + }) + .collect() + } + + fn emitted_after_2q_noise_count(outputs: &[Vec]) -> usize { + outputs + .iter() + .flatten() + .filter(|gate| gate.gate_type != GateType::CX) + .count() + } + + #[test] + fn idle_after_2q_duration_scales_linear_noise() { + let smaller = after_2q_outputs(0.1, 1.0, 0.0, 42, 1_000); + let larger = after_2q_outputs(1.0, 1.0, 0.0, 42, 1_000); + + assert!( + emitted_after_2q_noise_count(&larger) > emitted_after_2q_noise_count(&smaller), + "a larger after-2q idle duration should emit strictly more idle-noise gates" + ); + } + + #[test] + fn idle_after_2q_applies_coherent_family() { + let outputs = after_2q_outputs(0.5, 0.0, 0.25, 42, 1); + let rz_gate = outputs + .iter() + .flatten() + .find(|gate| gate.gate_type == GateType::RZ) + .expect("the coherent idle family should emit an RZ gate"); + + assert_eq!(rz_gate.qubits.len(), 2); + assert!(rz_gate.qubits.contains(&QubitId(0))); + assert!(rz_gate.qubits.contains(&QubitId(1))); + } + + #[test] + fn zero_idle_after_2q_duration_emits_no_idle_noise() { + let outputs = after_2q_outputs(0.0, 1.0, 0.0, 42, 100); + + assert_eq!(emitted_after_2q_noise_count(&outputs), 0); + } + + #[test] + fn zero_idle_rates_emit_no_after_2q_idle_noise() { + let outputs = after_2q_outputs(1.0, 0.0, 0.0, 42, 100); + + assert_eq!(emitted_after_2q_noise_count(&outputs), 0); + } + #[test] - fn test_p_idle_coherent() { + fn idle_after_2q_is_deterministic_for_same_seed() { + let first = after_2q_outputs(0.5, 0.4, 0.0, 42, 100); + let second = after_2q_outputs(0.5, 0.4, 0.0, 42, 100); + + assert_eq!(first, second); + } + + /// The documented `r * PI` migration is exact in the probability, not just in + /// sampled bytes. + /// + /// The byte-comparison sibling test only resolves conversion errors of a few + /// percent, because a seeded stochastic run can leave every draw on the same side + /// of its threshold. This compares the analytic probability instead, so a wrong + /// constant fails at machine precision rather than at 5%. + #[test] + fn quadratic_migration_r_times_pi_is_exact_in_probability() { + let legacy_rate = 0.2_f64; + let duration = 0.75_f64; + + // What the legacy path produced: the builder scaled the cycles-per-time rate by + // factor/2 * 2*PI, with the factor at its final default of 1.0. + let legacy_effective_rate = legacy_rate * std::f64::consts::PI; + let legacy_probability = (legacy_effective_rate * duration).sin().powi(2); + + // What the documented migration produces through the family entry point. + let migrated_probability = GeneralNoiseModel::sin_squared_probability( + legacy_rate * std::f64::consts::PI, + 1.0, + duration, + ); + + assert!( + (migrated_probability - legacy_probability).abs() < 1e-15, + "migration must be exact: got {migrated_probability}, expected {legacy_probability}", + ); + + // And it is genuinely sensitive: a 0.1% error in the constant is caught here. + let perturbed = GeneralNoiseModel::sin_squared_probability( + legacy_rate * std::f64::consts::PI * 1.001, + 1.0, + duration, + ); + assert!( + (perturbed - legacy_probability).abs() > 1e-9, + "a perturbed constant must be distinguishable", + ); + } + + #[test] + fn quadratic_migration_r_times_pi_keeps_captured_legacy_bytes() { + let legacy_rate = 0.2; + let z_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_sin_squared(legacy_rate * std::f64::consts::PI, &z_model) + .build(); + + let mut input_builder = ByteMessage::quantum_operations_builder(); + for _ in 0..8 { + input_builder.idle(0.75, &[0, 1, 2, 3]); + } + let output = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .into_bytes(); + let captured_legacy_bytes = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 5, 0, 0, 0, 100, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, + 0, 1, 0, 0, 0, 10, 0, 0, 0, 12, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, + 0, 8, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 2, 0, 0, 0, + 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, + ]; + + assert_eq!(output, captured_legacy_bytes); + } + + #[test] + fn default_angle_scaling_is_identity_for_p2_only_model() { + let model = GeneralNoiseModel::builder().with_p2(0.37).build(); + + assert_float_eq( + model.p2_angle_error_rate(-std::f64::consts::FRAC_PI_3), + 0.37, + ); + assert_float_eq(model.p2_angle_error_rate(0.0), 0.37); + assert_float_eq(model.p2_angle_error_rate(std::f64::consts::FRAC_PI_3), 0.37); + } + + #[test] + fn same_seed_and_configuration_emit_identical_noise() { + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let make_model = || { + GeneralNoiseModel::builder() + .with_seed(4_242) + .with_p_prep(0.4) + .with_p1(0.4) + .with_p2(0.4) + .with_p_idle_linear(0.4, &linear_model) + .with_p1_emission_ratio(0.5) + .with_p2_emission_ratio(0.5) + .with_prep_leak_ratio(0.5) + .build() + }; + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.pz(&[0, 1]); + input_builder.h(&[0]); + input_builder.cx(&[(0, 1)]); + input_builder.idle(0.5, &[0, 1]); + let input = input_builder.build(); + let collect = |model: &mut GeneralNoiseModel| { + (0..64) + .map(|_| model.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>() + }; + + let first = collect(&mut make_model()); + let second = collect(&mut make_model()); + + assert_eq!(first, second); + assert!(first.iter().any(|output| output.len() > 16)); + } + + #[test] + fn x_weighted_sine_model_emits_x_not_z() { + let x_model = BTreeMap::from([("X".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &x_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::X); + } + + fn coherent_idle_gates( + rate: f64, + coherent_model: &BTreeMap, + duration: f64, + seed: u64, + ) -> Vec { + let mut model = GeneralNoiseModel::builder() + .with_seed(seed) + .with_p_idle_coherent(rate, coherent_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(duration, &[0]); + model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap() + } + + #[test] + fn coherent_idle_angle_is_rate_times_multiplier_times_duration() { + let rate = 0.25; + let multiplier = 1.4; + let duration = 0.6; + let expected_angle = 0.21; + let model = BTreeMap::from([("RY".to_string(), multiplier)]); + + assert!( + (GeneralNoiseModel::coherent_rotation_angle(rate, multiplier, duration) + - expected_angle) + .abs() + < f64::EPSILON + ); + let gates = coherent_idle_gates(rate, &model, duration, 424); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::RY); + assert_eq!( + gates[0].angles, + [Angle64::from_radians(expected_angle)].into() + ); + } + + #[test] + fn coherent_idle_emits_selected_generators_in_deterministic_order() { + let rx_only = BTreeMap::from([("RX".to_string(), 1.0)]); + let gates = coherent_idle_gates(0.2, &rx_only, 0.5, 424); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::RX); + assert!(!gates.iter().any(|gate| gate.gate_type == GateType::RZ)); + + let all_axes = BTreeMap::from([ + ("RZ".to_string(), 3.0), + ("RX".to_string(), 1.0), + ("RY".to_string(), 2.0), + ]); + let gates = coherent_idle_gates(0.2, &all_axes, 0.5, 424); + assert_eq!( + gates.iter().map(|gate| gate.gate_type).collect::>(), + [GateType::RX, GateType::RY, GateType::RZ] + ); + assert_eq!(gates[0].angles, [Angle64::from_radians(0.1)].into()); + assert_eq!(gates[1].angles, [Angle64::from_radians(0.2)].into()); + assert_eq!( + gates[2].angles, + [Angle64::from_radians( + GeneralNoiseModel::coherent_rotation_angle(0.2, 3.0, 0.5), + )] + .into() + ); + } + + #[test] + fn coherent_idle_multipliers_are_not_normalized() { + let model = BTreeMap::from([("RX".to_string(), 1.0), ("RZ".to_string(), 1.0)]); + let gates = coherent_idle_gates(0.2, &model, 0.5, 424); + + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].angles, [Angle64::from_radians(0.1)].into()); + assert_eq!(gates[1].angles, [Angle64::from_radians(0.1)].into()); + } + + #[test] + fn coherent_idle_is_seed_independent_and_consumes_no_rng_draws() { + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); + let first = coherent_idle_gates(0.3, &coherent_model, 0.7, 1); + let second = coherent_idle_gates(0.3, &coherent_model, 0.7, 999); + assert_eq!(first, second); + + let linear_model = BTreeMap::from([("X".to_string(), 1.0)]); + let make_model = |with_coherent| { + let builder = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear(0.35, &linear_model); + if with_coherent { + builder.with_p_idle_coherent(0.3, &coherent_model) + } else { + builder + } + .build() + }; + let mut without_coherent = make_model(false); + let mut with_coherent = make_model(true); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(0.7, &[0, 1, 2, 3]); + let input = input_builder.build(); + for _ in 0..128 { + let baseline = without_coherent + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap(); + let composed = with_coherent + .apply_noise_on_start(&input) + .unwrap() + .quantum_ops() + .unwrap() + .into_iter() + .filter(|gate| gate.gate_type != GateType::RZ) + .collect::>(); + assert_eq!(composed, baseline); + } + } + + #[test] + fn coherent_idle_reaches_after_2q_sites() { + let coherent_model = BTreeMap::from([("RZ".to_string(), 2.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p2(0.0) + .with_p_idle_coherent(0.25, &coherent_model) + .with_idle_after_2q(0.6) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.cx(&[(0, 1)]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].gate_type, GateType::CX); + assert_eq!(gates[1].gate_type, GateType::RZ); + assert_eq!(gates[1].qubits, [QubitId(0), QubitId(1)].into()); + assert_eq!(gates[1].angles, [Angle64::from_radians(0.3)].into()); + } + + #[test] + fn coherent_sine_squared_and_linear_idle_families_compose() { + let linear_model = BTreeMap::from([("X".to_string(), 1.0)]); + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let coherent_model = BTreeMap::from([("RY".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_linear(1.0, &linear_model) + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!( + gates.iter().map(|gate| gate.gate_type).collect::>(), + [GateType::X, GateType::Z, GateType::RY] + ); + assert_eq!(gates[2].angles, [Angle64::from_radians(0.25)].into()); + } + + #[test] + fn family_only_configuration_keeps_captured_pre_removal_bytes() { + let linear_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let sine_model = BTreeMap::from([("X".to_string(), 1.0)]); + let coherent_model = BTreeMap::from([("RZ".to_string(), 2.0)]); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear(1.0, &linear_model) + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0, 1]); + + let output = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .into_bytes(); + let captured_pre_removal_bytes = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 4, 0, 0, 0, 96, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, + 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 12, 0, 0, + 0, 1, 2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0, 32, 2, 1, 0, 0, 0, 0, + 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 63, + ]; + + assert_eq!(output, captured_pre_removal_bytes); + } + + #[test] + fn coherent_idle_skips_leaked_qubits() { + let coherent_model = BTreeMap::from([("RX".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + model.leaked_qubits.insert(0); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0, 1]); + + let gates = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap(); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].qubits, [QubitId(1)].into()); + } + + #[test] + fn sine_model_axes_are_independent_unnormalized_multipliers() { + let model_map = BTreeMap::from([ + ("X".to_string(), 1.0), + ("Y".to_string(), 1.0), + ("Z".to_string(), 1.0), + ("L".to_string(), 1.0), + ]); + let mut model = GeneralNoiseModel::builder() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &model_map) + .build(); + + assert_eq!(model.p_idle_sin_squared_model, model_map); + for multiplier in model.p_idle_sin_squared_model.values() { + assert!((*multiplier - 1.0).abs() < f64::EPSILON); + } + + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(1.0, &[0]); + let gate_types = model + .apply_noise_on_start(&input_builder.build()) + .unwrap() + .quantum_ops() + .unwrap() + .into_iter() + .map(|gate| gate.gate_type) + .collect::>(); + assert_eq!( + gate_types, + vec![GateType::X, GateType::Y, GateType::Z, GateType::PZ] + ); + } + + #[test] + fn linear_family_rejects_unnormalized_model() { + let model = BTreeMap::from([("X".to_string(), 1.0), ("Z".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModel::builder().with_p_idle_linear(0.1, &model); + }); + assert!( + panic.is_err(), + "an unnormalized linear model must be rejected" + ); + } + + #[test] + fn sine_family_rejects_invalid_rates_axes_and_multipliers() { + let cases = [ + (f64::INFINITY, BTreeMap::from([("X".to_string(), 1.0)])), + (0.1, BTreeMap::from([("A".to_string(), 1.0)])), + (0.1, BTreeMap::from([("X".to_string(), -1.0)])), + ]; + for (rate, model) in cases { + assert!( + std::panic::catch_unwind(|| { + let _ = GeneralNoiseModel::builder().with_p_idle_sin_squared(rate, &model); + }) + .is_err(), + "invalid sine rate/model must be rejected: rate={rate}, model={model:?}" + ); + } + } + + #[test] + fn coherent_family_rejects_invalid_rates_axes_and_multipliers() { + let cases = [ + (f64::INFINITY, BTreeMap::from([("RX".to_string(), 1.0)])), + (0.1, BTreeMap::from([("L".to_string(), 1.0)])), + (0.1, BTreeMap::from([("A".to_string(), 1.0)])), + (0.1, BTreeMap::from([("RX".to_string(), -1.0)])), + ]; + for (rate, model) in cases { + assert!( + std::panic::catch_unwind(|| { + let _ = GeneralNoiseModel::builder().with_p_idle_coherent(rate, &model); + }) + .is_err(), + "invalid coherent rate/model must be rejected: rate={rate}, model={model:?}" + ); + } + } + + #[test] + fn zero_sine_rate_and_zero_idle_duration_emit_nothing() { + assert!(GeneralNoiseModel::sin_squared_probability(0.0, 1.0, 1.0) < f64::EPSILON); + assert!( + GeneralNoiseModel::sin_squared_probability(std::f64::consts::FRAC_PI_2, 1.0, 0.0) + < f64::EPSILON + ); + let x_model = BTreeMap::from([("X".to_string(), 1.0)]); + let mut zero_rate = GeneralNoiseModel::builder() + .with_p_idle_sin_squared(0.0, &x_model) + .build(); + let mut nonzero_rate = GeneralNoiseModel::builder() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &x_model) + .build(); + let mut duration_one = ByteMessage::quantum_operations_builder(); + duration_one.idle(1.0, &[0]); + let mut duration_zero = ByteMessage::quantum_operations_builder(); + duration_zero.idle(0.0, &[0]); + + assert!( + zero_rate + .apply_noise_on_start(&duration_one.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + assert!( + nonzero_rate + .apply_noise_on_start(&duration_zero.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + } + + #[test] + fn zero_coherent_rate_and_zero_idle_duration_emit_nothing() { + let coherent_model = BTreeMap::from([("RX".to_string(), 1.0)]); + let mut zero_rate = GeneralNoiseModel::builder() + .with_p_idle_coherent(0.0, &coherent_model) + .build(); + let mut nonzero_rate = GeneralNoiseModel::builder() + .with_p_idle_coherent(0.25, &coherent_model) + .build(); + let mut duration_one = ByteMessage::quantum_operations_builder(); + duration_one.idle(1.0, &[0]); + let mut duration_zero = ByteMessage::quantum_operations_builder(); + duration_zero.idle(0.0, &[0]); + + assert!( + zero_rate + .apply_noise_on_start(&duration_one.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + assert!( + nonzero_rate + .apply_noise_on_start(&duration_zero.build()) + .unwrap() + .quantum_ops() + .unwrap() + .is_empty() + ); + } + + #[test] + fn sine_family_is_deterministic_for_same_seed() { + let sine_model = BTreeMap::from([ + ("X".to_string(), 0.5), + ("Y".to_string(), 0.75), + ("Z".to_string(), 1.0), + ]); + let make_model = || { + GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_sin_squared(0.6, &sine_model) + .build() + }; + let mut first = make_model(); + let mut second = make_model(); + let mut input_builder = ByteMessage::quantum_operations_builder(); + input_builder.idle(0.7, &[0, 1, 2, 3]); + let input = input_builder.build(); + let first_outputs = (0..128) + .map(|_| first.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>(); + let second_outputs = (0..128) + .map(|_| second.apply_noise_on_start(&input).unwrap().into_bytes()) + .collect::>(); + + assert_eq!(first_outputs, second_outputs); + assert!(first_outputs.iter().any(|output| output.len() > 16)); + } + + #[test] + fn linear_and_sine_families_keep_their_pre_removal_bytes() { + let mut input_builder = ByteMessage::quantum_operations_builder(); + for _ in 0..8 { + input_builder.idle(0.75, &[0, 1, 2, 3]); + } + let input = input_builder.build(); + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear(0.35, &linear_model) + .with_p_idle_sin_squared(0.07 * 1.5 * std::f64::consts::PI, &sine_model) + .build(); + + let output = model.apply_noise_on_start(&input).unwrap().into_bytes(); + let expected = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 10, 0, 0, 0, 176, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 3, 1, + 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 10, 0, 0, 0, 8, 0, + 0, 0, 2, 1, 0, 0, 2, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 3, 0, 0, 0, 10, 0, + 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, + 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 3, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 2, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, + 0, 2, 1, 0, 0, 1, 0, 0, 0, + ]; + assert_eq!(output, expected); + } + + #[test] + fn linear_and_coherent_families_keep_their_pre_removal_bytes() { + let mut input_builder = ByteMessage::quantum_operations_builder(); + for _ in 0..8 { + input_builder.idle(0.75, &[0, 1, 2, 3]); + } + let input = input_builder.build(); + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); + let mut model = GeneralNoiseModel::builder() + .with_seed(424) + .with_p_idle_linear(0.35, &linear_model) + .with_p_idle_coherent(0.07 * 2.0 * std::f64::consts::PI, &coherent_model) + .build(); + + let output = model.apply_noise_on_start(&input).unwrap().into_bytes(); + let expected = vec![ + 83, 67, 69, 80, 1, 0, 0, 0, 16, 0, 0, 0, 176, 1, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, + 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, + 63, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, + 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, 0, 8, 0, 0, 0, 3, 1, 0, 0, 1, 0, 0, 0, + 10, 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, + 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, + 0, 0, 0, 8, 0, 0, 0, 1, 1, 0, 0, 2, 0, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, + 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, + 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, + 0, 8, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 3, 0, 0, 0, + 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, + 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, 0, 8, 0, 0, 0, 3, 1, 0, 0, 3, 0, 0, 0, + 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, + 82, 99, 190, 111, 139, 28, 213, 63, 10, 0, 0, 0, 8, 0, 0, 0, 2, 1, 0, 0, 2, 0, 0, 0, + 10, 0, 0, 0, 28, 0, 0, 0, 32, 4, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, + 82, 99, 190, 111, 139, 28, 213, 63, + ]; + assert_eq!(output, expected); + } + + #[test] + fn test_coherent_and_sine_squared_idle_families() { // Create a circuit builder let mut builder = ByteMessage::quantum_operations_builder(); - // Create a noise model with coherent dephasing + // Create a noise model with coherent dephasing. + let coherent_model = BTreeMap::from([("RZ".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() - .with_p_idle_coherent(true) - .with_p_idle_quadratic_rate(0.2) + .with_p_idle_coherent(0.2, &coherent_model) .build(); // Create an idle gate @@ -2820,7 +3581,7 @@ mod tests { }; // Apply idle faults - should use coherent dephasing (RZ gates) - model.apply_idle_faults(&gate, 0.0, model.p_idle_quadratic_rate, &mut builder); + model.apply_idle_faults(&gate, 0.0, &mut builder); // Get the message and verify it contains RZ gates let message = builder.build(); @@ -2844,12 +3605,7 @@ mod tests { channel: None, }; - model.apply_idle_faults( - &multi_qubit_gate, - 0.0, - model.p_idle_quadratic_rate, - &mut builder, - ); + model.apply_idle_faults(&multi_qubit_gate, 0.0, &mut builder); let message = builder.build(); let gates = message.quantum_ops().unwrap(); @@ -2888,16 +3644,17 @@ mod tests { "RZ gates should affect qubits 0, 1, 2" ); - // Now test with incoherent dephasing + // Now test with stochastic sine-squared dephasing. let mut builder = ByteMessage::quantum_operations_builder(); + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); let mut model = GeneralNoiseModel::builder() - .with_p_idle_coherent(false) + .with_p_idle_sin_squared(0.2, &sine_model) .with_seed(42) .build(); - // Apply idle faults with incoherent dephasing - model.apply_idle_faults(&gate, 0.0, model.p_idle_quadratic_rate, &mut builder); + // Apply idle faults with incoherent dephasing. + model.apply_idle_faults(&gate, 0.0, &mut builder); // The message may contain Z gates or be empty depending on random outcomes let message = builder.build(); @@ -2910,7 +3667,7 @@ mod tests { #[allow(clippy::unreadable_literal)] fn test_rzz_error_rate() { let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .with_p2_angle_power(1.0) .build(); @@ -2939,7 +3696,7 @@ mod tests { // Test quadratic scaling let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .with_p2_angle_power(2.0) .build(); @@ -2960,7 +3717,7 @@ mod tests { fn test_noiseless_gates() { // Create a noise model and mark RZ as a noiseless gate let mut model = GeneralNoiseModel::builder() - .with_p1_probability(0.5) // Use a moderate valid probability + .with_p1(0.5) // Use a moderate valid probability .with_noiseless_gate(GateType::RZ) .build(); let noise = model @@ -3065,7 +3822,7 @@ mod tests { #[test] fn test_rzz_error_rate_debug() { let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .build(); let noise = model @@ -3090,7 +3847,7 @@ mod tests { // Check scaled przz error rate let mut model = GeneralNoiseModel::builder() - .with_average_p2_probability(0.1) + .with_average_p2(0.1) .with_p2_angle_params(0.1, 0.0, 0.25, 0.0) .with_scale(2.0) .build(); @@ -3140,11 +3897,11 @@ mod tests { // Create a noise model with custom Pauli and emission models using the builder let model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_p1_probability(0.1) - .with_p2_probability(0.2) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_p1(0.1) + .with_p2(0.2) .with_p1_pauli_model(&custom_p1_pauli) .with_p1_emission_model(&custom_p1_emission) .with_p2_pauli_model(&custom_p2_pauli) diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index b6662620a..40088516a 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -12,7 +12,7 @@ use std::collections::{BTreeMap, BTreeSet}; /// Layout: /// `(p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission_ratio, p2_emission_ratio)` /// where `angle` is `Some((a, b, c, d, power))` when angle scaling is -/// configured. The emission ratios use the model default (0.5) when unset, and +/// configured. The emission ratios use the model default (zero) when unset, and /// the emission DISTRIBUTION is required to be the default (uniform Pauli) -- /// custom emission models keep the config out of this subset. pub type PauliWithAngleScaling = ( @@ -36,11 +36,10 @@ pub struct GeneralNoiseModelBuilder { leakage_scale: Option, emission_scale: Option, // idle noise - p_idle_coherent: Option, p_idle_linear_rate: Option, p_idle_linear_model: Option, - p_idle_quadratic_rate: Option, - p_idle_coherent_to_incoherent_factor: Option, + p_idle_sin_squared: Option<(f64, BTreeMap)>, + p_idle_coherent: Option<(f64, BTreeMap)>, idle_scale: Option, // prep noise p_prep: Option, @@ -63,7 +62,11 @@ pub struct GeneralNoiseModelBuilder { p2_emission_model: Option, p2_seepage_prob: Option, p2_pauli_model: Option, - p2_idle: Option, + /// Duration of the idle-noise sites applied after two-qubit gates. + /// + /// These sites use the configured linear and quadratic idle mechanisms; this value is not a + /// standalone error probability. + idle_after_2q: Option, p2_scale: Option, // measurement noise p_meas_0: Option, @@ -95,9 +98,8 @@ impl GeneralNoiseModelBuilder { // idle noise p_idle_linear_rate: None, p_idle_linear_model: None, - p_idle_quadratic_rate: None, + p_idle_sin_squared: None, p_idle_coherent: None, - p_idle_coherent_to_incoherent_factor: None, idle_scale: None, // prep noise p_prep: None, @@ -120,7 +122,7 @@ impl GeneralNoiseModelBuilder { p2_emission_model: None, p2_seepage_prob: None, p2_pauli_model: None, - p2_idle: None, + idle_after_2q: None, p2_scale: None, // measurement noise p_meas_0: None, @@ -133,9 +135,40 @@ impl GeneralNoiseModelBuilder { } } - /// Build the general noise model + /// Fill unset parameters with the legacy demonstration preset. /// - /// TODO: Consider another build with noiseless default + /// This preset reproduces the general noise model's historical defaults. It is intended for + /// demonstrations and is not a calibrated device model. Parameters already set by the caller + /// are preserved, so explicit setters win whether they appear before or after `auto()`. + /// + /// The preset uses preparation, measurement, one-qubit, two-qubit, and linear-idle error rates + /// of 0.01, 0.01/0.01, 0.001, 0.01, and 0.001 respectively. Its other effects are: + /// + /// - `p_prep_leak_ratio = 0.5`: half of preparation faults leak the qubit out of the + /// computational subspace. + /// - `p1_emission_ratio = p2_emission_ratio = 0.5`: half of gate errors take the spontaneous- + /// emission branch, which removes the original gate and substitutes a sample from the + /// emission model. The preset's uniform emission models contain Pauli keys only, so these + /// branches do not cause leakage. + /// - `p1_seepage_prob = p2_seepage_prob = 0.5`: seepage is attempted only for qubits that are + /// already leaked. + #[must_use] + pub fn auto(mut self) -> Self { + self.p_prep.get_or_insert(0.01); + self.p_meas_0.get_or_insert(0.01); + self.p_meas_1.get_or_insert(0.01); + self.p1.get_or_insert(0.001); + self.p2.get_or_insert(0.01); + self.p_idle_linear_rate.get_or_insert(0.001); + self.p1_emission_ratio.get_or_insert(0.5); + self.p2_emission_ratio.get_or_insert(0.5); + self.p_prep_leak_ratio.get_or_insert(0.5); + self.p1_seepage_prob.get_or_insert(0.5); + self.p2_seepage_prob.get_or_insert(0.5); + self + } + + /// Build the general noise model /// /// # Returns /// A `GeneralNoiseModel` @@ -144,6 +177,9 @@ impl GeneralNoiseModelBuilder { /// Panics if any probabilities are not set or are not between 0 and 1. #[must_use] pub fn build(mut self) -> GeneralNoiseModel { + self.validate_configuration() + .unwrap_or_else(|message| panic!("{message}")); + // Start with the default noise model as a base let mut model = GeneralNoiseModel::default(); @@ -166,10 +202,6 @@ impl GeneralNoiseModelBuilder { // idle noise // ----------------------------------------------------------------------------------------- - if let Some(coherent) = self.p_idle_coherent { - model.p_idle_coherent = coherent; - } - if let Some(p_idle_linear_rate) = self.p_idle_linear_rate { model.p_idle_linear_rate = p_idle_linear_rate; } @@ -178,12 +210,14 @@ impl GeneralNoiseModelBuilder { model.p_idle_linear_model = model_map; } - if let Some(p_idle_quadratic_rate) = self.p_idle_quadratic_rate { - model.p_idle_quadratic_rate = p_idle_quadratic_rate; + if let Some((rate, sine_model)) = self.p_idle_sin_squared.clone() { + model.p_idle_sin_squared_rate = rate; + model.p_idle_sin_squared_model = sine_model; } - if let Some(factor) = self.p_idle_coherent_to_incoherent_factor { - model.p_idle_coherent_to_incoherent_factor = factor; + if let Some((rate, coherent_model)) = self.p_idle_coherent.clone() { + model.p_idle_coherent_rate = rate; + model.p_idle_coherent_model = coherent_model; } // prep noise @@ -252,8 +286,8 @@ impl GeneralNoiseModelBuilder { model.p2_pauli_model = model_map; } - if let Some(p2_idle) = self.p2_idle { - model.p2_idle = p2_idle; + if let Some(idle_after_2q) = self.idle_after_2q { + model.idle_after_2q = idle_after_2q; } // measurement noise @@ -353,61 +387,93 @@ impl GeneralNoiseModelBuilder { // --- idle noise --- // - /// Set whether to use coherent dephasing - #[must_use] - pub fn with_p_idle_coherent(mut self, use_coherent: bool) -> Self { - self.p_idle_coherent = Some(use_coherent); - self - } - - /// Set the idling noise error rate for the linear term + /// Set the DEM-style linear idle-noise family. + /// + /// `rate` is the total event rate per time unit. For an idle of duration `d`, one event is + /// sampled with probability `rate * d`, then its X, Y, Z, or leakage axis is drawn from + /// `model`. The model must therefore be a normalized distribution: this linear family splits + /// one total rate across its axes. + /// + /// In contrast, [`Self::with_p_idle_sin_squared`] takes radians per time unit and an + /// unnormalized model because sine laws do not add linearly: each axis carries its own + /// independent rate. That setter applies no unit conversion. + /// + /// All engines idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. + /// + /// The linear sampling structure deliberately remains different from the DEM: engines emits + /// at most one linear event followed by a categorical axis choice, while the DEM emits + /// independent per-axis mechanisms. The difference is second order in the rates; this setter + /// aligns the units and axis alphabet, not that sampling structure. #[must_use] - pub fn with_p_idle_linear_rate(mut self, rate: f64) -> Self { + pub fn with_p_idle_linear(mut self, rate: f64, model: &BTreeMap) -> Self { self.p_idle_linear_rate = Some(Self::validate_non_negative(rate, "linear idling rate")); - self - } - - // TODO: See if we should put a average scaling... - /// Set the average idling noise error rate per channel for the linear term - #[must_use] - pub fn with_average_p_idle_linear_rate(mut self, rate: f64) -> Self { - let rate: f64 = rate * 3.0 / 2.0; - self.p_idle_linear_rate = Some(rate); - self - } - - /// Set the stochastic model for idling that is linearly dependent on time - #[must_use] - pub fn with_p_idle_linear_model(mut self, model: &BTreeMap) -> Self { self.p_idle_linear_model = Some(SingleQubitWeightedSampler::new(model)); self } - /// Set the idling noise error rate for the quadratic term - #[must_use] - pub fn with_p_idle_quadratic_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_rate = Some(rate); - self - } - - /// Set the average idling noise error rate per channel for the quadratic term + /// Set the DEM-style stochastic sine-squared idle-noise family. + /// + /// `rate` is in radians per time unit. No unit conversion is applied. For each axis P with + /// multiplier `n_P` and an idle of duration `d`, engines independently samples + /// `P(P) = sin^2(rate * n_P * d)`. + /// + /// The model accepts X, Y, Z, and L and is intentionally unnormalized: sine laws do not add + /// linearly, so every axis carries its own independent rate. By comparison, + /// [`Self::with_p_idle_linear`] requires a normalized distribution because its one total + /// linear event rate is split across axes. + /// + /// The removed cycles-per-time spelling migrates exactly as follows at its former default + /// factor of one: + /// + /// `with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0})` + /// + /// All engines idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. + /// + /// The linear sampling structure deliberately remains different from the DEM: engines emits + /// at most one linear event followed by a categorical axis choice, while the DEM emits + /// independent per-axis mechanisms. The difference is second order in the rates; this setter + /// aligns the units and axis alphabet, not that sampling structure. + /// + /// # Panics + /// + /// Panics if `rate` or a multiplier is not finite and non-negative, or if `model` contains a + /// key other than X, Y, Z, or L. #[must_use] - pub fn with_average_p_idle_quadratic_rate(mut self, rate: f64) -> Self { - let rate: f64 = rate * (3.0 / 2.0_f64).sqrt(); - self.p_idle_quadratic_rate = Some(rate); + pub fn with_p_idle_sin_squared(mut self, rate: f64, model: &BTreeMap) -> Self { + let rate = Self::validate_finite_non_negative(rate, "sine-squared idling rate"); + Self::validate_sine_model(model); + self.p_idle_sin_squared = Some((rate, model.clone())); self } - /// Set the coherent-to-incoherent conversion factor + /// Set the DEM-style coherent idle-noise family. /// - /// # Parameters - /// * `factor` - The conversion factor between coherent and incoherent noise + /// `rate` is in radians per time unit. No unit conversion is applied, just as for + /// [`Self::with_p_idle_sin_squared`]. For each RX/RY/RZ generator with multiplier `n_P` and an + /// idle of duration `d`, engines applies a deterministic rotation with angle `rate * n_P * d`; + /// coherent evolution is not sampled and consumes no random draw. + /// + /// The model is intentionally unnormalized because its values are relative rate multipliers, + /// not probabilities to be split from one total event rate. The symmetric default model is + /// `{"RX": 1.0, "RY": 1.0, "RZ": 1.0}`. Leakage and all keys other than RX, RY, and RZ + /// are rejected because leakage is not a rotation. + /// + /// Whether these rotations can be consumed depends on the downstream consumer. The standard + /// DEM builder rejects coherent idle noise; the EEG route in `exp/pecos-eeg` represents it + /// with an RZ generator; and a simulator applies it only when its rotation executor is + /// installed. PECOS #437 documents how a missing executor could otherwise silently drop it. + /// + /// # Panics + /// + /// Panics if `rate` or a multiplier is not finite and non-negative, or if `model` contains a + /// key other than RX, RY, or RZ. #[must_use] - pub fn with_p_idle_coherent_to_incoherent_factor(mut self, factor: f64) -> Self { - self.p_idle_coherent_to_incoherent_factor = Some(Self::validate_positive( - factor, - "Coherent-to-incoherent factor", - )); + pub fn with_p_idle_coherent(mut self, rate: f64, model: &BTreeMap) -> Self { + let rate = Self::validate_finite_non_negative(rate, "coherent idling rate"); + Self::validate_coherent_model(model); + self.p_idle_coherent = Some((rate, model.clone())); self } @@ -427,7 +493,7 @@ impl GeneralNoiseModelBuilder { /// Set the probability of error during preparation #[must_use] - pub fn with_prep_probability(mut self, probability: f64) -> Self { + pub fn with_p_prep(mut self, probability: f64) -> Self { self.p_prep = Some(Self::validate_probability(probability)); self } @@ -483,7 +549,7 @@ impl GeneralNoiseModelBuilder { /// Set the probability of error after single-qubit gates #[must_use] - pub fn with_p1_probability(mut self, probability: f64) -> Self { + pub fn with_p1(mut self, probability: f64) -> Self { self.p1 = Some(Self::validate_probability(probability)); self } @@ -498,7 +564,7 @@ impl GeneralNoiseModelBuilder { /// For a single-qubit gate with uniform error distribution across 3 Pauli errors, /// the ratio of total error rate to average error rate is 3/2. #[must_use] - pub fn with_average_p1_probability(mut self, probability: f64) -> Self { + pub fn with_average_p1(mut self, probability: f64) -> Self { self.p1 = Some(Self::validate_probability(probability * 3.0 / 2.0)); self } @@ -547,7 +613,7 @@ impl GeneralNoiseModelBuilder { /// Set the probability of error after two-qubit gates #[must_use] - pub fn with_p2_probability(mut self, probability: f64) -> Self { + pub fn with_p2(mut self, probability: f64) -> Self { self.p2 = Some(Self::validate_probability(probability)); self } @@ -562,7 +628,7 @@ impl GeneralNoiseModelBuilder { /// For a two-qubit gate with uniform error distribution across 15 Pauli errors, /// the ratio of total error rate to average error rate is 5/4. #[must_use] - pub fn with_average_p2_probability(mut self, probability: f64) -> Self { + pub fn with_average_p2(mut self, probability: f64) -> Self { self.p2 = Some(Self::validate_probability(probability * 5.0 / 4.0)); self } @@ -635,9 +701,17 @@ impl GeneralNoiseModelBuilder { self } + /// Set the duration of the idle-noise site applied to each qubit after a two-qubit gate. + /// + /// A duration of `0.0` disables these sites. Nonzero sites receive every configured linear, + /// sine-squared, and coherent idle family over the given duration. + /// + /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q + /// idle noise; the equivalent is + /// `with_p_idle_linear(0.01, model).with_idle_after_2q(1.0)`. #[must_use] - pub fn with_p2_idle(mut self, probability: f64) -> Self { - self.p2_idle = Some(Self::validate_probability(probability)); + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = Some(Self::validate_duration(duration)); self } @@ -658,21 +732,21 @@ impl GeneralNoiseModelBuilder { /// Set the probability of flipping 0 to 1 during measurement #[must_use] - pub fn with_meas_0_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_0(mut self, probability: f64) -> Self { self.p_meas_0 = Some(Self::validate_probability(probability)); self } /// Set the probability of flipping 1 to 0 during measurement #[must_use] - pub fn with_meas_1_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas_1(mut self, probability: f64) -> Self { self.p_meas_1 = Some(Self::validate_probability(probability)); self } /// Set the probability of bit flipping the measurement result #[must_use] - pub fn with_meas_probability(mut self, probability: f64) -> Self { + pub fn with_p_meas(mut self, probability: f64) -> Self { self.p_meas_0 = Some(Self::validate_probability(probability)); self.p_meas_1 = Some(Self::validate_probability(probability)); self @@ -755,6 +829,61 @@ impl GeneralNoiseModelBuilder { value } + /// Validate that a value is finite and non-negative. + fn validate_finite_non_negative(value: f64, name: &str) -> f64 { + assert!( + value.is_finite() && value >= 0.0, + "{name} must be finite and non-negative, got {value}" + ); + value + } + + /// Validate an unnormalized sine-family multiplier model. + fn validate_sine_model(model: &BTreeMap) { + for (axis, multiplier) in model { + assert!( + matches!(axis.as_str(), "X" | "Y" | "Z" | "L"), + "p_idle_sin_squared model has invalid key '{axis}'; expected X, Y, Z, or L" + ); + Self::validate_finite_non_negative( + *multiplier, + &format!("p_idle_sin_squared multiplier for '{axis}'"), + ); + } + } + + /// Validate an unnormalized coherent-family multiplier model. + fn validate_coherent_model(model: &BTreeMap) { + for (axis, multiplier) in model { + assert!( + matches!(axis.as_str(), "RX" | "RY" | "RZ"), + "p_idle_coherent model has invalid key '{axis}'; expected RX, RY, or RZ" + ); + Self::validate_finite_non_negative( + *multiplier, + &format!("p_idle_coherent multiplier for '{axis}'"), + ); + } + } + + /// Validate cross-field configuration invariants. + /// + /// # Errors + /// + /// Returns a description of the first invalid combination. + pub fn validate_configuration(&self) -> Result<(), &'static str> { + Ok(()) + } + + /// Validate that a duration is finite and non-negative + fn validate_duration(duration: f64) -> f64 { + assert!( + duration.is_finite() && duration >= 0.0, + "Duration must be finite and non-negative, got {duration}" + ); + duration + } + // ========================================================================================== // /// The simple Pauli-probability subset of this configuration, if the /// physics reduces to it. @@ -762,17 +891,13 @@ impl GeneralNoiseModelBuilder { /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2)`. `p1`/`p2` are in the /// standard depolarizing convention the builder stores internally (the /// `with_average_*` setters convert on the way in). Unset probabilities - /// take their `GeneralNoiseModel::default()` values — this model's - /// philosophy is realistic defaults, NOT unset-means-off. + /// take their no-effect `GeneralNoiseModel::default()` values. /// /// Returns `Some` only when the noise shape is plain Pauli noise: /// - /// - Knobs whose model defaults are non-neutral must be EXPLICITLY - /// zeroed: emission ratios (default 0.5 — half the errors replace the - /// gate instead of following it), prep leak ratio (default 0.5), and - /// the linear idle rate (default 0.001). - /// - Knobs with neutral defaults (crosstalk, quadratic idle, scales, - /// noiseless gates) may be unset or set to their neutral value. + /// - Emission ratios, preparation leakage, idle noise, crosstalk, and other optional + /// mechanisms may be unset or set to their neutral value. + /// - Scales may be unset or set to one, and the noiseless-gate set must be empty. /// - Custom Pauli/emission/crosstalk models and angle-dependent /// two-qubit noise must be unset. /// @@ -781,8 +906,9 @@ impl GeneralNoiseModelBuilder { /// common configuration without re-deriving probability conventions. #[must_use] pub fn simple_probabilities(&self) -> Option<(f64, f64, f64, f64, f64)> { + let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); let emission_off = - self.p1_emission_ratio == Some(0.0) && self.p2_emission_ratio == Some(0.0); + zero_or_unset(self.p1_emission_ratio) && zero_or_unset(self.p2_emission_ratio); if self.is_plain_pauli_except_angle_and_emission() && self.resolved_angle_scaling().is_none() && emission_off @@ -839,27 +965,22 @@ impl GeneralNoiseModelBuilder { /// True when every non-Pauli feature is off EXCEPT possibly the /// angle-dependent two-qubit scaling and the spontaneous-emission ratios. /// Shared by `simple_probabilities` (which additionally requires both the - /// angle scaling unset and emission explicitly off) and + /// angle scaling unset and emission off) and /// `pauli_with_angle_scaling` (which extracts them). The emission DISTRIBUTION /// must still be the default uniform model (`p1/p2_emission_model` unset) -- /// custom emission samplers are NOT in this subset. fn is_plain_pauli_except_angle_and_emission(&self) -> bool { - let explicitly_zero = |v: Option| v == Some(0.0); let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); let one_or_unset = |v: Option| v.is_none() || v == Some(1.0); - // Non-neutral model defaults: unset means the default applies, so - // these must be explicitly zeroed for the physics to be plain Pauli. - // (Emission ratios are intentionally NOT required off here -- they are - // handled separately, since neo now matches engines' gate-removing - // emission with the default uniform distribution.) - let defaulted_features_off = - explicitly_zero(self.p_prep_leak_ratio) && explicitly_zero(self.p_idle_linear_rate); - - // Neutral model defaults: unset is fine. - let optional_features_off = zero_or_unset(self.p_idle_quadratic_rate) + // Emission ratios are intentionally NOT required off here: they are handled separately, + // since neo matches engines' gate-removing emission with the default uniform distribution. + let optional_features_off = zero_or_unset(self.p_prep_leak_ratio) + && zero_or_unset(self.p_idle_linear_rate) + && self.p_idle_sin_squared.is_none() + && self.p_idle_coherent.is_none() && zero_or_unset(self.p_prep_crosstalk) - && zero_or_unset(self.p2_idle) + && zero_or_unset(self.idle_after_2q) && zero_or_unset(self.p_meas_crosstalk_global) && zero_or_unset(self.p_meas_crosstalk_local); @@ -890,11 +1011,7 @@ impl GeneralNoiseModelBuilder { let gates_default = self.noiseless_gates.as_ref().is_none_or(BTreeSet::is_empty); - defaulted_features_off - && optional_features_off - && custom_models_off - && scales_neutral - && gates_default + optional_features_off && custom_models_off && scales_neutral && gates_default } /// Resolve the base Pauli probabilities `(p_prep, p_meas_0, p_meas_1, p1, @@ -1003,19 +1120,7 @@ impl GeneralNoiseModelBuilder { model.p2_emission_ratio *= emission_scale * scale; model.p2_emission_ratio = model.p2_emission_ratio.min(1.0); - model.p_idle_quadratic_rate *= (idle_scale * scale).sqrt(); - - // If we need to do incoherent noise instead of coherent - if !model.p_idle_coherent { - // 0.5 to deal with the 0.5 in sin(rate x duration x 0.5)^2 - let factor = model.p_idle_coherent_to_incoherent_factor * 0.5; - model.p_idle_quadratic_rate *= factor; - } - // frequency is in units of 2pi so convert to radians - model.p_idle_quadratic_rate *= 2.0 * std::f64::consts::PI; - model.p_idle_linear_rate = model.p_idle_linear_rate * scale * idle_scale; - model.p2_idle = Self::validate_probability(model.p2_idle * scale * idle_scale); } } @@ -1029,19 +1134,123 @@ impl crate::noise::IntoNoiseModel for GeneralNoiseModelBuilder { mod tests { use super::*; + fn assert_float_eq(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected {expected}, got {actual}" + ); + } + #[test] - fn simple_probabilities_requires_explicit_zeros_for_defaulted_features() { - // Bare builder: model defaults include emission 0.5, prep leak 0.5, - // idle 0.001 — physics beyond the simple Pauli subset. + fn auto_reproduces_legacy_demonstration_preset() { + let model = GeneralNoiseModelBuilder::new().auto().build(); + + assert_float_eq(model.p_prep, 0.01); + assert_float_eq(model.p_meas_0, 0.01); + assert_float_eq(model.p_meas_1, 0.01); + assert_float_eq(model.p1, 0.001); + assert_float_eq(model.p2, 0.01); + assert_float_eq(model.p_idle_linear_rate, 0.001); + assert_float_eq(model.p1_emission_ratio, 0.5); + assert_float_eq(model.p2_emission_ratio, 0.5); + assert_float_eq(model.p_prep_leak_ratio, 0.5); + assert_float_eq(model.p1_seepage_prob, 0.5); + assert_float_eq(model.p2_seepage_prob, 0.5); + } + + #[test] + fn explicit_setter_beats_auto_in_both_orders() { + let linear_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + let set_explicit = |builder: GeneralNoiseModelBuilder| { + builder + .with_p_prep(0.11) + .with_p_meas_0(0.12) + .with_p_meas_1(0.13) + .with_p1(0.14) + .with_p2(0.15) + .with_p_idle_linear(0.16, &linear_model) + .with_p1_emission_ratio(0.17) + .with_p2_emission_ratio(0.18) + .with_prep_leak_ratio(0.19) + .with_p1_seepage_prob(0.20) + .with_p2_seepage_prob(0.21) + }; + let models = [ + set_explicit(GeneralNoiseModelBuilder::new().auto()).build(), + set_explicit(GeneralNoiseModelBuilder::new()).auto().build(), + ]; + + for model in models { + assert_float_eq(model.p_prep, 0.11); + assert_float_eq(model.p_meas_0, 0.12); + assert_float_eq(model.p_meas_1, 0.13); + assert_float_eq(model.p1, 0.14); + assert_float_eq(model.p2, 0.15); + assert_float_eq(model.p_idle_linear_rate, 0.16); + assert_float_eq(model.p1_emission_ratio, 0.17); + assert_float_eq(model.p2_emission_ratio, 0.18); + assert_float_eq(model.p_prep_leak_ratio, 0.19); + assert_float_eq(model.p1_seepage_prob, 0.20); + assert_float_eq(model.p2_seepage_prob, 0.21); + } + } + + #[test] + fn retired_idle_builder_state_and_setters_are_absent_from_rust_source() { + let source = include_str!("builder.rs"); + let removed_setters = [ + "with_p_idle_linear_rate", + "with_p_idle_linear_model", + "with_p_idle_quadratic_rate", + "with_p_idle_quadratic_coherent", + "with_p_idle_coherent_to_incoherent_factor", + "with_average_p_idle_linear_rate", + "with_average_p_idle_quadratic_rate", + ]; + + for setter in removed_setters { + assert!( + !source.contains(&format!("pub fn {setter}")), + "{setter} must not remain on GeneralNoiseModelBuilder" + ); + } + + let removed_factor = ["p_idle_coherent_to_", "incoherent_factor"].concat(); + assert!( + !source.contains(&format!("{removed_factor}: Option")) + && !source.contains(&format!("self.{removed_factor}")) + && !source.contains(&format!("model.{removed_factor}")), + "the orphaned coherent-to-incoherent factor must not remain in builder state or auto()" + ); + } + + #[test] + fn auto_does_not_overwrite_explicit_zero() { + let model = GeneralNoiseModelBuilder::new().with_p2(0.0).auto().build(); + + assert_float_eq(model.p2, 0.0); + assert_float_eq(model.p1, 0.001); + } + + #[test] + fn simple_probabilities_accepts_neutral_defaults_and_rejects_auto_features() { + assert_eq!( + GeneralNoiseModelBuilder::new().simple_probabilities(), + Some((0.0, 0.0, 0.0, 0.0, 0.0)) + ); assert!( GeneralNoiseModelBuilder::new() + .with_average_p1(0.2) .simple_probabilities() - .is_none() + .is_some() ); - // Setting only a probability does not neutralize the defaults. assert!( GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) + .auto() .simple_probabilities() .is_none() ); @@ -1050,17 +1259,16 @@ mod tests { #[test] fn simple_probabilities_returns_stored_convention_values() { let simple = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.4) - .with_prep_probability(0.01) - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.03) + .with_average_p1(0.2) + .with_average_p2(0.4) + .with_p_prep(0.01) + .with_p_meas_0(0.02) + .with_p_meas_1(0.03) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .simple_probabilities() - .expect("fully zeroed config is simple"); + .expect("plain Pauli config is simple"); let (p_prep, p_meas_0, p_meas_1, p1, p2) = simple; assert!((p_prep - 0.01).abs() < 1e-12); @@ -1077,9 +1285,8 @@ mod tests { .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) .simple_probabilities() - .expect("zeroed features with default probabilities is simple"); + .expect("neutral defaults are simple"); let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = GeneralNoiseModel::default().probabilities(); @@ -1091,12 +1298,11 @@ mod tests { #[test] fn pauli_with_angle_scaling_matches_simple_when_no_angle() { let builder = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.4) + .with_average_p1(0.2) + .with_average_p2(0.4) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_prep_leak_ratio(0.0); let simple = builder.simple_probabilities().expect("simple config"); let (p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, p2_emission) = builder @@ -1104,7 +1310,7 @@ mod tests { .expect("simple config is also pauli-with-angle"); assert_eq!((p_prep, p_meas_0, p_meas_1, p1, p2), simple); assert!(angle.is_none()); - // Emission was explicitly zeroed to land in the strict simple subset. + // Emission is zero in the strict simple subset. assert_eq!((p1_emission, p2_emission), (0.0, 0.0)); } @@ -1114,17 +1320,16 @@ mod tests { #[test] fn pauli_with_angle_scaling_extracts_configured_angle() { let builder = GeneralNoiseModelBuilder::new() - .with_p2_probability(0.3) + .with_p2(0.3) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) .with_p2_angle_power(2.0) - .with_average_p1_probability(0.0) + .with_average_p1(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0); + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0); // Angle scaling is outside the STRICT simple subset. assert!(builder.simple_probabilities().is_none()); @@ -1140,16 +1345,15 @@ mod tests { #[test] fn pauli_with_angle_scaling_fills_unset_power_from_default() { let builder = GeneralNoiseModelBuilder::new() - .with_p2_probability(0.3) + .with_p2(0.3) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) - .with_average_p1_probability(0.0) + .with_average_p1(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0); + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0); let (_, _, _, _, _, angle, _, _) = builder .pauli_with_angle_scaling() @@ -1162,11 +1366,11 @@ mod tests { /// angle configured. #[test] fn pauli_with_angle_scaling_rejects_non_angle_features() { - // Prep-leakage and linear idling keep their (non-zero) model defaults - // because they are never explicitly zeroed -> beyond the subset. - // (Emission ratios are NOT a blocker -- they are part of the subset.) + // Explicit preparation leakage is beyond the subset. (Emission ratios are NOT a + // blocker -- they are part of the subset.) let builder = GeneralNoiseModelBuilder::new() - .with_p2_probability(0.3) + .with_p2(0.3) + .with_prep_leak_ratio(0.5) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0); assert!(builder.pauli_with_angle_scaling().is_none()); } @@ -1176,12 +1380,11 @@ mod tests { #[test] fn pauli_with_angle_scaling_extracts_emission_ratios() { let builder = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.4) + .with_average_p1(0.2) + .with_average_p2(0.4) .with_p1_emission_ratio(0.25) .with_p2_emission_ratio(0.75) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_prep_leak_ratio(0.0); // Non-zero emission is OUTSIDE the strict simple subset... assert!(builder.simple_probabilities().is_none()); @@ -1200,11 +1403,10 @@ mod tests { #[test] fn pauli_with_angle_scaling_rejects_emission_scale() { let builder = GeneralNoiseModelBuilder::new() - .with_average_p1_probability(0.2) + .with_average_p1(0.2) .with_p1_emission_ratio(0.25) .with_emission_scale(2.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_prep_leak_ratio(0.0); // The built model applies the scale (0.25 * 2.0 = 0.5)... let built = builder.clone().build(); diff --git a/crates/pecos-engines/src/noise/general/default.rs b/crates/pecos-engines/src/noise/general/default.rs index 66aba12fd..104ace8e6 100644 --- a/crates/pecos-engines/src/noise/general/default.rs +++ b/crates/pecos-engines/src/noise/general/default.rs @@ -6,23 +6,17 @@ use crate::noise::{ use std::collections::{BTreeMap, BTreeSet}; impl Default for GeneralNoiseModel { - /// Create a new noise model with default error parameters + /// Create a noiseless general noise model. /// - /// Creates a `GeneralNoiseModel` with sensible default error probabilities: - /// * `p_prep` - Preparation (initialization) error probability: 0.01 - /// * `p_meas_0` - Probability of measuring 1 when the state is |0⟩: 0.01 - /// * `p_meas_1` - Probability of measuring 0 when the state is |1⟩: 0.01 - /// * `p1` - Single-qubit gate error probability (average error rate): 0.001 - /// * `p2` - Two-qubit gate error probability (average error rate): 0.01 - /// - /// Other parameters are initialized with sensible defaults, including uniform - /// distributions for Pauli errors and emission errors. + /// All rates and probabilities use their no-effect value. Multipliers and angle-scaling + /// parameters use their identity values, and the sampler distributions remain uniform so + /// that explicitly enabling a rate without replacing its sampler remains well-defined. /// /// # Example /// ``` /// use pecos_engines::noise::GeneralNoiseModel; /// - /// // Create model with default error probabilities + /// // The default model adds no noise. /// let mut model = GeneralNoiseModel::default(); /// ``` fn default() -> Self { @@ -71,39 +65,47 @@ impl Default for GeneralNoiseModel { p2_emission_model.insert("YI".to_string(), 1.0 / 15.0); p2_emission_model.insert("ZI".to_string(), 1.0 / 15.0); - let p_meas_0: f64 = 0.01; // 1% probability of measuring 1 when state is |0⟩ - let p_meas_1: f64 = 0.01; // 1% probability of measuring 0 when state is |1⟩ + let p_meas_0: f64 = 0.0; + let p_meas_1: f64 = 0.0; let mut p_meas_crosstalk_model = BTreeMap::new(); p_meas_crosstalk_model.insert("0->0".to_string(), 1.0); p_meas_crosstalk_model.insert("1->1".to_string(), 1.0); - // Default error probabilities + let p_idle_coherent_model = BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]); + + // No-effect defaults Self { - p_prep: 0.01, - p_idle_coherent: false, - p_idle_linear_rate: 0.001, + p_prep: 0.0, + p_idle_linear_rate: 0.0, p_idle_linear_model: SingleQubitWeightedSampler::new(&p1_pauli_model), - p_idle_quadratic_rate: 0.0, + p_idle_sin_squared_rate: 0.0, + p_idle_sin_squared_model: BTreeMap::new(), + p_idle_coherent_rate: 0.0, + p_idle_coherent_model, p_meas_0, p_meas_1, - p1: 0.001, - p2: 0.01, - p1_emission_ratio: 0.5, - p_prep_leak_ratio: 0.5, - p2_emission_ratio: 0.5, + p1: 0.0, + p2: 0.0, + p1_emission_ratio: 0.0, + p_prep_leak_ratio: 0.0, + p2_emission_ratio: 0.0, p1_pauli_model: SingleQubitWeightedSampler::new(&p1_pauli_model), p1_emission_model: SingleQubitWeightedSampler::new(&p1_emission_model), p2_pauli_model: TwoQubitWeightedSampler::new(&p2_pauli_model), p2_emission_model: TwoQubitWeightedSampler::new(&p2_emission_model), - p1_seepage_prob: 0.5, - p2_seepage_prob: 0.5, + p1_seepage_prob: 0.0, + p2_seepage_prob: 0.0, p2_angle_a: 0.0, p2_angle_b: 1.0, p2_angle_c: 0.0, p2_angle_d: 1.0, p2_angle_power: 1.0, - p2_idle: 0.0, + idle_after_2q: 0.0, leaked_qubits: BTreeSet::new(), rng: NoiseRng::default(), prepared_qubits: BTreeSet::new(), @@ -112,8 +114,6 @@ impl Default for GeneralNoiseModel { p_meas_crosstalk_local: 0.0, p_meas_crosstalk_model: CrosstalkWeightedSampler::new(&p_meas_crosstalk_model), p_prep_crosstalk: 0.0, - - p_idle_coherent_to_incoherent_factor: 1.5, noiseless_gates: BTreeSet::new(), p_meas_max: p_meas_0.max(p_meas_1), leakage_scale: 1.0, diff --git a/crates/pecos-engines/tests/measure_leaked_test.rs b/crates/pecos-engines/tests/measure_leaked_test.rs index d79acc256..8426b6a10 100644 --- a/crates/pecos-engines/tests/measure_leaked_test.rs +++ b/crates/pecos-engines/tests/measure_leaked_test.rs @@ -31,11 +31,11 @@ fn test_measure_leaked_basic_functionality() { fn test_measure_leaked_with_general_noise_model() { // Create a noise model let mut noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(42) .build(); @@ -120,11 +120,11 @@ fn test_measure_leaked_preserves_quantum_state() { fn test_measure_leaked_sequential_measurements() { // Test that leaked state persists across multiple measurements let mut noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(42) .build(); @@ -181,11 +181,11 @@ fn test_measure_leaked_sequential_measurements() { fn test_measure_leaked_with_prep_unleaks() { // Test that Prep operation unleaks qubits let mut noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(42) .build(); diff --git a/crates/pecos-engines/tests/mpz_test.rs b/crates/pecos-engines/tests/mpz_test.rs index db1d2366f..da87ba834 100644 --- a/crates/pecos-engines/tests/mpz_test.rs +++ b/crates/pecos-engines/tests/mpz_test.rs @@ -44,11 +44,11 @@ fn mpz_runs_through_the_general_noise_model() { use pecos_engines::noise::general::GeneralNoiseModel; let noise = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_p1_probability(0.0) - .with_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_p1(0.0) + .with_p2(0.0) .with_seed(7) .build(); let engine = Box::new(StateVecEngine::new(1)); diff --git a/crates/pecos-engines/tests/noise_determinism.rs b/crates/pecos-engines/tests/noise_determinism.rs index 42082427c..f78ddbf1f 100644 --- a/crates/pecos-engines/tests/noise_determinism.rs +++ b/crates/pecos-engines/tests/noise_determinism.rs @@ -49,11 +49,11 @@ fn create_noise_model() -> GeneralNoiseModel { // Use builder to construct the model with all parameters set let mut model = GeneralNoiseModel::builder() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1) + .with_p_prep(0.1) + .with_p_meas_0(0.1) + .with_p_meas_1(0.1) + .with_p1(0.1) + .with_p2(0.1) .with_p1_pauli_model(&single_qubit_weights) .with_p2_pauli_model(&two_qubit_weights) .with_p1_emission_ratio(0.5) @@ -472,11 +472,11 @@ fn test_deterministic_measurement() { // Create a noise model with significant measurement error let model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.2) - .with_meas_1_probability(0.2) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.2) + .with_p_meas_1(0.2) + .with_average_p1(0.1) + .with_average_p2(0.1) .build(); // Box the model for use with the NoiseModel trait @@ -600,14 +600,14 @@ fn test_comprehensive_noise_determinism() { // Create a noise model with all types of noise let model = GeneralNoiseModel::builder() // Preparation errors - .with_prep_probability(0.05) + .with_p_prep(0.05) .with_prep_leak_ratio(0.2) // Measurement errors - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.15) + .with_p_meas_0(0.1) + .with_p_meas_1(0.15) // Gate errors - .with_average_p1_probability(0.2) - .with_average_p2_probability(0.1) + .with_average_p1(0.2) + .with_average_p2(0.1) // Leakage and emission errors .with_p1_emission_ratio(0.3) .with_p2_emission_ratio(0.3) @@ -736,11 +736,11 @@ fn test_long_running_determinism() { // Create a noise model with moderate error rates let model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.02) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.05) + .with_p_prep(0.01) + .with_p_meas_0(0.02) + .with_p_meas_1(0.02) + .with_average_p1(0.1) + .with_average_p2(0.05) .build(); // Box the model diff --git a/crates/pecos-engines/tests/noise_test.rs b/crates/pecos-engines/tests/noise_test.rs index df11a3c6b..7136f485c 100644 --- a/crates/pecos-engines/tests/noise_test.rs +++ b/crates/pecos-engines/tests/noise_test.rs @@ -76,11 +76,11 @@ fn test_single_qubit_gate_noise_distributions() { // Create noise model with high error rates using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Disable emission errors .with_seed(42) .build(); @@ -159,11 +159,11 @@ fn test_rotation_gate_with_different_angles() { // Create noise model with high error rates for clearer results using the builder pattern // Explicitly avoid marking RZ as a noiseless gate for this test let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.05) - .with_meas_0_probability(0.05) - .with_meas_1_probability(0.05) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.2) + .with_p_prep(0.05) + .with_p_meas_0(0.05) + .with_p_meas_1(0.05) + .with_average_p1(0.1) + .with_average_p2(0.2) .build(); // Test rotation gates with different angles @@ -299,11 +299,11 @@ fn test_two_qubit_gate_noise_distributions() { // Create noise model with high error rates for clearer results using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.05) - .with_meas_0_probability(0.05) - .with_meas_1_probability(0.05) - .with_average_p1_probability(0.1) - .with_average_p2_probability(0.2) + .with_p_prep(0.05) + .with_p_meas_0(0.05) + .with_p_meas_1(0.05) + .with_average_p1(0.1) + .with_average_p2(0.2) .build(); // Test CNOT gate with different input states @@ -435,11 +435,11 @@ fn test_rzz_angle_dependent_error_model() { // Create noise model with RZZ angle-dependent error parameters using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_p2_angle_params(0.05, 0.0, 0.1, 0.0) // a=0.05, b=0, c=0.1, d=0 .with_p2_angle_power(1.0) // Linear scaling with angle .with_seed(42) @@ -519,11 +519,11 @@ fn test_leakage_model() { // Create noise model with significant leakage using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_p2_emission_ratio(0.8) // High emission ratio for obvious effect .with_prep_leak_ratio(0.5) // 50% of prep errors lead to leakage .with_seed(42) @@ -562,11 +562,11 @@ fn test_software_gates_not_affected_by_noise() { // Create noise model with high error rates using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.3) - .with_average_p2_probability(0.3) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.3) + .with_average_p2(0.3) .with_seed(42) .with_noiseless_gate(GateType::RZ) .build(); @@ -615,15 +615,17 @@ fn test_software_gates_not_affected_by_noise() { #[test] fn test_coherent_vs_incoherent_dephasing() { const NUM_SHOTS: usize = 2000; + let coherent_idle_model = BTreeMap::from([("RZ".to_string(), 1.0)]); + let sine_idle_model = BTreeMap::from([("Z".to_string(), 1.0)]); // Create two noise models with different dephasing types using the builder pattern let coherent_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) - .with_p_idle_coherent(true) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) + .with_p_idle_coherent(0.2, &coherent_idle_model) .with_seed(42) .build(); @@ -631,13 +633,12 @@ fn test_coherent_vs_incoherent_dephasing() { // The build() method now returns GeneralNoiseModel directly let incoherent_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) - .with_p_idle_coherent(false) - .with_p_idle_coherent_to_incoherent_factor(2.0) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) + .with_p_idle_sin_squared(0.1, &sine_idle_model) .with_seed(42) .build(); @@ -646,7 +647,7 @@ fn test_coherent_vs_incoherent_dephasing() { // Create a dephasing test circuit: // 1. Prepare |+⟩ state with H - // 2. Wait a bit (we'll use a Z gate for simplicity instead of a true idle) + // 2. Wait for one time unit // 3. Apply H to convert phase to population // 4. Measure @@ -656,8 +657,7 @@ fn test_coherent_vs_incoherent_dephasing() { // Prepare |+⟩ state builder.h(&[0]); - // Add Z gate (as a simplified way to introduce phase) - builder.z(&[0]); + builder.idle(1.0, &[0]); // Convert phase to population builder.h(&[0]); @@ -701,11 +701,11 @@ fn test_parameter_scaling_impact() { for scale in scale_factors { // Create a noise model with the given scale factor using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.05) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.05) + .with_average_p2(0.1) .with_scale(scale) // Apply overall scaling .with_seed(42) .build(); @@ -756,11 +756,11 @@ fn test_debug_x_gate_noise() { // Create a simple noise model with high error rate but no emission errors using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .build(); @@ -810,11 +810,11 @@ fn test_seed_effect() { // Create a simple noise model with high error rate but no emission errors using the builder pattern let noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .build(); @@ -890,11 +890,11 @@ fn test_seed_effect() { .collect(); let complex_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_p1_pauli_model(&pauli_model) .with_p1_emission_model(&emission_model) @@ -923,11 +923,11 @@ fn test_combined_comparison() { println!("=== TESTING SIMPLER MODEL ==="); // Create a simple noise model with high error rate but no emission errors using the builder pattern let simple_noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_seed(42) .build(); @@ -977,11 +977,11 @@ fn test_combined_comparison() { // Create the model with the builder let complex_noise_model = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.8) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.8) .with_p1_emission_ratio(0.0) // No leakage errors .with_p1_pauli_model(&pauli_model) .with_p1_emission_model(&emission_model) @@ -1041,11 +1041,11 @@ fn test_pauli_model_effect() { println!("=== Test with default Pauli model ==="); // Create a noise model with default Pauli model using the builder pattern let noise_model1 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_seed(42) .build(); @@ -1085,11 +1085,11 @@ fn test_pauli_model_effect() { .collect(); let noise_model2 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_p1_pauli_model(&x_biased_model) .with_p1_emission_model(&emission_model) @@ -1120,11 +1120,11 @@ fn test_pauli_model_effect() { .collect(); let noise_model3 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) .with_p1_pauli_model(&z_biased_model) .with_p1_emission_model(&emission_model) @@ -1160,11 +1160,11 @@ fn test_pauli_model_behavior() { // ====== Model 1: Default model (equal distribution of X, Y, Z errors) ====== let model1 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Turn off emission errors .with_seed(42) .build(); @@ -1192,11 +1192,11 @@ fn test_pauli_model_behavior() { .collect(); let model2 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Turn off emission errors .with_p1_pauli_model(&x_biased_model) .with_seed(42) @@ -1225,11 +1225,11 @@ fn test_pauli_model_behavior() { .collect(); let model3 = GeneralNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - .with_average_p1_probability(0.5) - .with_average_p2_probability(0.1) + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_average_p1(0.5) + .with_average_p2(0.1) .with_p1_emission_ratio(0.0) // Turn off emission errors .with_p1_pauli_model(&z_biased_model) .with_seed(42) diff --git a/crates/pecos-fusion-blossom/src/decoder.rs b/crates/pecos-fusion-blossom/src/decoder.rs index 547e4d8b3..8d7c73f6c 100644 --- a/crates/pecos-fusion-blossom/src/decoder.rs +++ b/crates/pecos-fusion-blossom/src/decoder.rs @@ -282,6 +282,20 @@ impl FusionBlossomDecoder { /// /// Returns error if the graph is empty or construction fails. pub fn from_matching_graph(graph: &pecos_decoder_core::dem::DemMatchingGraph) -> Result { + Self::from_matching_graph_with_solver_type(graph, SolverType::Serial) + } + + /// Create decoder from a `DemMatchingGraph` with an explicit supported solver. + /// + /// # Errors + /// + /// Returns an error if the graph is invalid, construction fails, or the + /// parallel solver is requested without its required partition configuration. + pub fn from_matching_graph_with_solver_type( + graph: &pecos_decoder_core::dem::DemMatchingGraph, + solver_type: SolverType, + ) -> Result { + Self::validate_dem_solver_type(solver_type)?; // Matching decoders pack observable flips into a u64; reject >64-observable // DEMs as an error rather than overflow-panicking in the `1 << o` loop below. graph @@ -290,7 +304,8 @@ impl FusionBlossomDecoder { let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, - ..Default::default() + solver_type, + max_tree_size: None, }; let mut decoder = Self::new(config)?; for edge in &graph.edges { @@ -314,9 +329,19 @@ impl FusionBlossomDecoder { /// /// Returns error if the DEM is malformed. pub fn from_dem(dem: &str) -> Result { + Self::from_dem_with_solver_type(dem, SolverType::Serial) + } + + /// Create decoder from a DEM string with an explicit supported solver. + /// + /// # Errors + /// + /// Returns an error if the DEM is malformed or the parallel solver is + /// requested without its required partition configuration. + pub fn from_dem_with_solver_type(dem: &str, solver_type: SolverType) -> Result { let graph = pecos_decoder_core::dem::DemMatchingGraph::from_dem_str(dem) .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; - Self::from_matching_graph(&graph) + Self::from_matching_graph_with_solver_type(&graph, solver_type) } /// Parse a DEM string into a reusable structure for correlated FB construction. @@ -526,8 +551,22 @@ impl FusionBlossomDecoder { /// /// Returns error if the DEM is malformed. pub fn from_dem_correlated(dem: &str) -> Result { + Self::from_dem_correlated_with_solver_type(dem, SolverType::Serial) + } + + /// Create a correlated decoder from a DEM string with an explicit supported solver. + /// + /// # Errors + /// + /// Returns an error if the DEM is malformed or the parallel solver is + /// requested without its required partition configuration. + pub fn from_dem_correlated_with_solver_type( + dem: &str, + solver_type: SolverType, + ) -> Result { use pecos_decoder_core::dem::DemCheckMatrix; + Self::validate_dem_solver_type(solver_type)?; let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; // Matching decoders pack observable flips into a u64; reject >64-observable @@ -538,7 +577,8 @@ impl FusionBlossomDecoder { let config = FusionBlossomConfig { num_nodes: Some(dcm.num_detectors), num_observables: dcm.num_observables, - ..Default::default() + solver_type, + max_tree_size: None, }; let mut decoder = Self::new(config)?; @@ -575,6 +615,16 @@ impl FusionBlossomDecoder { Ok(decoder) } + fn validate_dem_solver_type(solver_type: SolverType) -> Result<()> { + if solver_type == SolverType::Parallel { + return Err(FusionBlossomError::Configuration( + "solver_type 'parallel' requires a partition configuration, which the DEM constructor does not accept" + .to_string(), + )); + } + Ok(()) + } + /// Create decoder from a standard QEC code /// /// # Errors @@ -1093,6 +1143,12 @@ impl FusionBlossomDecoder { self.num_nodes } + /// Get the configured solver type. + #[must_use] + pub fn solver_type(&self) -> SolverType { + self.config.solver_type + } + /// Get number of edges #[must_use] pub fn num_edges(&self) -> usize { diff --git a/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs b/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs index 333c7ab8e..75fe51343 100644 --- a/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs +++ b/crates/pecos-fusion-blossom/tests/fusion_blossom_tests.rs @@ -16,6 +16,26 @@ fn test_create_decoder() { assert!(decoder.is_ok()); } +#[test] +fn test_dem_solver_type_is_applied_and_parallel_fails_at_construction() { + let dem = "error(0.1) D0 D1 L0\ndetector D0\ndetector D1\nlogical_observable L0"; + + let legacy = FusionBlossomDecoder::from_dem_with_solver_type(dem, SolverType::Legacy).unwrap(); + assert_eq!(legacy.solver_type(), SolverType::Legacy); + + let correlated = + FusionBlossomDecoder::from_dem_correlated_with_solver_type(dem, SolverType::Legacy) + .unwrap(); + assert_eq!(correlated.solver_type(), SolverType::Legacy); + + let error = FusionBlossomDecoder::from_dem_with_solver_type(dem, SolverType::Parallel) + .err() + .unwrap() + .to_string(); + assert!(error.contains("solver_type")); + assert!(error.contains("partition configuration")); +} + #[test] fn test_add_edges() { let config = FusionBlossomConfig { diff --git a/crates/pecos-ldpc-decoders/src/decoders.rs b/crates/pecos-ldpc-decoders/src/decoders.rs index 76bd9f197..e5b4a74ff 100644 --- a/crates/pecos-ldpc-decoders/src/decoders.rs +++ b/crates/pecos-ldpc-decoders/src/decoders.rs @@ -39,6 +39,31 @@ fn prepare_channel_probs( } } +fn validate_bp_tuning( + max_iter: usize, + adaptive_max_iter: usize, + ms_scaling_factor: f64, + order: usize, + order_parameter: &str, +) -> Result<(i32, i32), LdpcError> { + if !ms_scaling_factor.is_finite() || ms_scaling_factor < 0.0 { + return Err(LdpcError::InvalidInput( + "ms_scaling_factor must be finite and non-negative".to_string(), + )); + } + let actual_max_iter = if max_iter == 0 { + adaptive_max_iter + } else { + max_iter + }; + let actual_max_iter = i32::try_from(actual_max_iter) + .map_err(|_| LdpcError::InvalidInput("max_iter must not exceed 2147483647".to_string()))?; + let order = i32::try_from(order).map_err(|_| { + LdpcError::InvalidInput(format!("{order_parameter} must not exceed 2147483647")) + })?; + Ok((actual_max_iter, order)) +} + /// BP+OSD Decoder pub struct BpOsdDecoder { inner: UniquePtr, @@ -96,15 +121,19 @@ impl BpOsdDecoder { "OSD decoding requires syndrome input. Please use InputVectorType::Syndrome when OSD is enabled.".to_string() )); } - // Prepare channel probabilities let channel_probs = prepare_channel_probs(pcm.cols, error_rate, error_channel)?; // Create sparse matrix representation for FFI let sparse_repr = pcm.to_ffi_repr(); - // Handle adaptive iterations (0 means use n as max_iter) - let actual_max_iter = if max_iter == 0 { pcm.cols } else { max_iter }; + let (actual_max_iter, osd_order) = validate_bp_tuning( + max_iter, + pcm.cols, + ms_scaling_factor, + osd_order, + "osd_order", + )?; // Default thread count to 1 if not specified let threads = omp_thread_count.unwrap_or(1); @@ -118,12 +147,12 @@ impl BpOsdDecoder { let inner = ffi::create_bp_osd_decoder( &sparse_repr, &channel_probs, - i32::try_from(actual_max_iter).unwrap_or(i32::MAX), + actual_max_iter, bp_method.to_ffi(), bp_schedule.to_ffi(), ms_scaling_factor, osd_method.to_ffi(), - i32::try_from(osd_order).unwrap_or(0), + osd_order, input_vector_type.to_ffi(), i32::try_from(threads).unwrap_or(1), schedule_order, @@ -336,15 +365,19 @@ impl BpLsdDecoder { .to_string(), )); } - // Prepare channel probabilities let channel_probs = prepare_channel_probs(pcm.cols, error_rate, error_channel)?; // Create sparse matrix representation for FFI let sparse_repr = pcm.to_ffi_repr(); - // Handle adaptive iterations (0 means use n as max_iter) - let actual_max_iter = if max_iter == 0 { pcm.cols } else { max_iter }; + let (actual_max_iter, lsd_order) = validate_bp_tuning( + max_iter, + pcm.cols, + ms_scaling_factor, + lsd_order, + "lsd_order", + )?; // Default thread count to 1 if not specified let threads = omp_thread_count.unwrap_or(1); @@ -358,12 +391,12 @@ impl BpLsdDecoder { let inner = ffi::create_bp_lsd_decoder( &sparse_repr, &channel_probs, - i32::try_from(actual_max_iter).unwrap_or(i32::MAX), + actual_max_iter, bp_method.to_ffi(), bp_schedule.to_ffi(), ms_scaling_factor, lsd_method.to_ffi(), - i32::try_from(lsd_order).unwrap_or(0), + lsd_order, i32::try_from(bits_per_step).unwrap_or(0), input_vector_type.to_ffi(), i32::try_from(threads).unwrap_or(1), @@ -853,6 +886,7 @@ impl FlipDecoder { /// Union Find Decoder pub struct UnionFindDecoder { inner: UniquePtr, + uf_method: UfMethod, } /// Union Find method @@ -890,7 +924,10 @@ impl UnionFindDecoder { let decoder = ffi::create_union_find_decoder(&pcm_repr, uf_method.to_ffi()) .map_err(|e| LdpcError::Ldpc(e.what().to_string()))?; - Ok(Self { inner: decoder }) + Ok(Self { + inner: decoder, + uf_method, + }) } /// Decode a syndrome using Union Find @@ -951,6 +988,12 @@ impl UnionFindDecoder { pub fn bit_count(&self) -> usize { usize::try_from(ffi::get_bit_count_uf(&self.inner)).unwrap_or(0) } + + /// Get the configured Union-Find method. + #[must_use] + pub fn method(&self) -> UfMethod { + self.uf_method + } } /// `BeliefFind` Decoder - Combines BP with Union Find diff --git a/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs b/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs index 86b0f6aff..7c0f030ee 100644 --- a/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs +++ b/crates/pecos-ldpc-decoders/tests/ldpc/integration_test.rs @@ -36,6 +36,79 @@ fn test_sparse_matrix_creation() { assert_eq!(reconstructed, dense); } +#[test] +fn test_bp_tuning_validation_names_invalid_parameter() { + let pcm = repetition_code(3); + let too_large = usize::try_from(i32::MAX).unwrap() + 1; + let make_osd = |max_iter, scaling, osd_order| { + BpOsdDecoder::new( + &pcm, + Some(0.1), + None, + max_iter, + BpMethod::ProductSum, + BpSchedule::Parallel, + scaling, + OsdMethod::OsdCs, + osd_order, + InputVectorType::Syndrome, + None, + None, + None, + ) + }; + + assert!( + make_osd(too_large, 1.0, 0) + .err() + .unwrap() + .to_string() + .contains("max_iter") + ); + assert!( + make_osd(10, f64::NAN, 0) + .err() + .unwrap() + .to_string() + .contains("ms_scaling_factor") + ); + assert!( + make_osd(10, -0.1, 0) + .err() + .unwrap() + .to_string() + .contains("ms_scaling_factor") + ); + assert!( + make_osd(10, 1.0, too_large) + .err() + .unwrap() + .to_string() + .contains("osd_order") + ); + + let lsd_error = BpLsdDecoder::new( + &pcm, + Some(0.1), + None, + 10, + BpMethod::ProductSum, + BpSchedule::Parallel, + 1.0, + OsdMethod::OsdCs, + too_large, + 0, + InputVectorType::Syndrome, + None, + None, + None, + ) + .err() + .unwrap() + .to_string(); + assert!(lsd_error.contains("lsd_order")); +} + #[test] fn test_repetition_code_decoder() { let pcm = repetition_code(5); diff --git a/crates/pecos-pymatching/include/pymatching_bridge.h b/crates/pecos-pymatching/include/pymatching_bridge.h index a76c20163..c3adb0abe 100644 --- a/crates/pecos-pymatching/include/pymatching_bridge.h +++ b/crates/pecos-pymatching/include/pymatching_bridge.h @@ -39,6 +39,7 @@ class PyMatchingGraph { double weight, double error_probability, MergeStrategy merge_strategy); + void set_all_error_probabilities(double error_probability); // Graph queries size_t get_num_nodes() const; @@ -124,6 +125,8 @@ void add_boundary_edge( double weight, double error_probability, MergeStrategy merge_strategy); +void pymatching_set_all_error_probabilities( + PyMatchingGraph& graph, double error_probability); size_t pymatching_get_num_nodes(const PyMatchingGraph& graph); size_t pymatching_get_num_detectors(const PyMatchingGraph& graph); diff --git a/crates/pecos-pymatching/src/bridge.cpp b/crates/pecos-pymatching/src/bridge.cpp index 7bbef999d..40b66882b 100644 --- a/crates/pecos-pymatching/src/bridge.cpp +++ b/crates/pecos-pymatching/src/bridge.cpp @@ -185,6 +185,15 @@ void PyMatchingGraph::add_boundary_edge( } } +void PyMatchingGraph::set_all_error_probabilities(double error_probability) { + double weight = std::log((1 - error_probability) / error_probability); + for (auto& edge : pimpl_->user_graph_->edges) { + edge.weight = weight; + edge.error_probability = error_probability; + } + pimpl_->mwpm_.reset(); +} + // ===== Graph Queries ===== size_t PyMatchingGraph::get_num_nodes() const { @@ -714,6 +723,11 @@ void add_boundary_edge( graph.add_boundary_edge(node, observables, weight, error_probability, merge_strategy); } +void pymatching_set_all_error_probabilities( + PyMatchingGraph& graph, double error_probability) { + graph.set_all_error_probabilities(error_probability); +} + size_t pymatching_get_num_nodes(const PyMatchingGraph& graph) { return graph.get_num_nodes(); } diff --git a/crates/pecos-pymatching/src/bridge.rs b/crates/pecos-pymatching/src/bridge.rs index b2becdc86..61303d8f6 100644 --- a/crates/pecos-pymatching/src/bridge.rs +++ b/crates/pecos-pymatching/src/bridge.rs @@ -119,6 +119,11 @@ pub(crate) mod ffi { merge_strategy: MergeStrategy, ) -> Result<()>; + fn pymatching_set_all_error_probabilities( + graph: Pin<&mut PyMatchingGraph>, + error_probability: f64, + ); + // ===== Graph Queries ===== /// Get the number of nodes in the graph. diff --git a/crates/pecos-pymatching/src/decoder.rs b/crates/pecos-pymatching/src/decoder.rs index f63a67d6d..08c7b2af1 100644 --- a/crates/pecos-pymatching/src/decoder.rs +++ b/crates/pecos-pymatching/src/decoder.rs @@ -359,6 +359,15 @@ impl fmt::Display for PyMatchingDecoder { } impl PyMatchingDecoder { + fn validate_error_probability(error_probability: f64) -> Result<()> { + if !(0.0..1.0).contains(&error_probability) || error_probability == 0.0 { + return Err(PyMatchingError::Configuration( + "error_probability must be finite and strictly between 0 and 1".to_string(), + )); + } + Ok(()) + } + /// Normalize edge parameters to their default values fn normalize_edge_params( weight: Option, @@ -535,6 +544,36 @@ impl PyMatchingDecoder { Ok(Self { graph, config }) } + /// Create a decoder from a DEM and override every graph edge's error probability. + /// + /// The graph structure is preserved. Each matching weight is recomputed + /// from the supplied probability, matching the builder's probability semantics. + /// + /// # Errors + /// + /// Returns an error if the DEM is invalid, the probability is outside + /// `(0, 1)`, or an edge cannot be updated. + pub fn from_dem_with_error_probability( + dem_string: &str, + error_probability: f64, + ) -> Result { + let mut decoder = Self::from_dem(dem_string)?; + decoder.set_all_error_probabilities(error_probability)?; + Ok(decoder) + } + + /// Replace the error probability and derived matching weight on every edge. + /// + /// # Errors + /// + /// Returns an error if `error_probability` is outside `(0, 1)` or an edge + /// cannot be updated. + pub fn set_all_error_probabilities(&mut self, error_probability: f64) -> Result<()> { + Self::validate_error_probability(error_probability)?; + ffi::pymatching_set_all_error_probabilities(self.graph.pin_mut(), error_probability); + Ok(()) + } + /// Create a decoder from a DEM string with correlation support /// /// When `enable_correlations` is true, the decoder tracks edge correlations @@ -1772,6 +1811,37 @@ impl DecodingResultTrait for DecodingResult { mod config_tests { use super::*; + #[test] + fn test_dem_error_probability_override_reaches_every_edge() { + let dem = + "error(0.1) D0\nerror(0.2) D1 L0\ndetector D0\ndetector D1\nlogical_observable L0"; + let baseline = PyMatchingDecoder::from_dem(dem).unwrap().get_all_edges(); + let overridden = PyMatchingDecoder::from_dem_with_error_probability(dem, 0.35) + .unwrap() + .get_all_edges(); + + assert_eq!(baseline.len(), overridden.len()); + let expected_weight = ((1.0_f64 - 0.35) / 0.35).ln(); + for after in &overridden { + assert!((after.weight - expected_weight).abs() < f64::EPSILON); + assert!((after.error_probability - 0.35).abs() < f64::EPSILON); + } + + let error = PyMatchingDecoder::from_dem_with_error_probability(dem, f64::NAN) + .err() + .unwrap() + .to_string(); + assert!(error.contains("error_probability")); + + for endpoint in [0.0, 1.0] { + let error = PyMatchingDecoder::from_dem_with_error_probability(dem, endpoint) + .err() + .unwrap() + .to_string(); + assert!(error.contains("error_probability")); + } + } + #[test] fn test_check_matrix_config_api() { // Test the new config-based API diff --git a/crates/pecos-qasm/examples/general_noise_builder.rs b/crates/pecos-qasm/examples/general_noise_builder.rs index da6ac4acd..d0f34b385 100644 --- a/crates/pecos-qasm/examples/general_noise_builder.rs +++ b/crates/pecos-qasm/examples/general_noise_builder.rs @@ -10,10 +10,10 @@ fn run_basic_noise_example(qasm: &str) -> Result<(), Box> println!("Example 1: Basic noise configuration"); let basic_noise = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002); + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -68,11 +68,11 @@ fn main() -> Result<(), Box> { let complex_noise = GeneralNoiseModel::builder() .with_seed(123) .with_scale(1.5) // Scale all error rates by 1.5x - .with_average_p1_probability(0.001) + .with_average_p1(0.001) .with_p1_pauli_model(&p1_pauli) - .with_average_p2_probability(0.01) + .with_average_p2(0.01) .with_p2_pauli_model(&p2_pauli) - .with_prep_probability(0.001) + .with_p_prep(0.001) .with_leakage_scale(0.1) .with_emission_scale(0.8); @@ -89,8 +89,8 @@ fn main() -> Result<(), Box> { let selective_noise = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High single-qubit error - .with_p2_probability(0.1) // High two-qubit error + .with_p1(0.1) // High single-qubit error + .with_p2(0.1) // High two-qubit error .with_noiseless_gate(pecos_core::prelude::GateType::H) // H gates have no noise .with_noiseless_gate(pecos_core::prelude::GateType::MZ); // Measurements have no noise @@ -105,20 +105,24 @@ fn main() -> Result<(), Box> { // Example 4: Full configuration with all parameters println!("Example 4: Full noise configuration"); + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let full_noise = GeneralNoiseModel::builder() .with_seed(456) .with_scale(1.2) .with_leakage_scale(0.2) .with_emission_scale(0.7) - .with_prep_probability(0.0005) - .with_p1_probability(0.001) - .with_average_p1_probability(0.0008) - .with_p2_probability(0.01) - .with_average_p2_probability(0.008) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.003) - .with_p_idle_coherent(false) - .with_p_idle_linear_rate(0.0001) + .with_p_prep(0.0005) + .with_p1(0.001) + .with_average_p1(0.0008) + .with_p2(0.01) + .with_average_p2(0.008) + .with_p_meas_0(0.001) + .with_p_meas_1(0.003) + .with_p_idle_linear(0.0001, &idle_model) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); diff --git a/crates/pecos-qasm/examples/general_noise_config.rs b/crates/pecos-qasm/examples/general_noise_config.rs index 109e3822e..7e14b0d79 100644 --- a/crates/pecos-qasm/examples/general_noise_config.rs +++ b/crates/pecos-qasm/examples/general_noise_config.rs @@ -11,6 +11,7 @@ use pecos_engines::noise::{ use pecos_engines::sim_builder; use pecos_programs::Qasm; use pecos_qasm::qasm_engine; +use std::collections::BTreeMap; fn main() -> Result<(), Box> { let qasm = r#" @@ -25,12 +26,19 @@ fn main() -> Result<(), Box> { // Example 1: General noise model with detailed configuration println!("Example 1: GeneralNoiseModelBuilder with unified API"); + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let general_noise = GeneralNoiseModel::builder() - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_prep_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001) + .with_p1(0.001) + .with_p2(0.01) + .with_p_prep(0.001) + .with_p_meas_0(0.001) + .with_p_meas_1(0.001) + .with_p_idle_linear(0.0001, &idle_model) + .with_idle_after_2q(1.0) .with_seed(42); let results = sim_builder() @@ -56,10 +64,10 @@ fn main() -> Result<(), Box> { // Example 3: Custom depolarizing noise with different rates println!("\nExample 3: Custom depolarizing noise"); let custom_depolarizing = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) - .with_meas_probability(0.002) - .with_p1_probability(0.001) - .with_p2_probability(0.01); + .with_p_prep(0.001) + .with_p_meas(0.002) + .with_p1(0.001) + .with_p2(0.01); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) diff --git a/crates/pecos-qasm/src/config.rs b/crates/pecos-qasm/src/config.rs index ad792ce1e..5bf9ee4a5 100644 --- a/crates/pecos-qasm/src/config.rs +++ b/crates/pecos-qasm/src/config.rs @@ -61,15 +61,17 @@ pub struct GeneralNoiseFields { // Idle noise parameters #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_coherent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_linear_rate: Option, + pub p_idle_linear: Option, #[serde(skip_serializing_if = "Option::is_none")] pub p_idle_linear_model: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_quadratic_rate: Option, + pub p_idle_sin_squared: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub p_idle_sin_squared_model: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub p_idle_coherent_to_incoherent_factor: Option, + pub p_idle_coherent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub p_idle_coherent_model: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub idle_scale: Option, @@ -114,8 +116,11 @@ pub struct GeneralNoiseFields { pub p2_seepage_prob: Option, #[serde(skip_serializing_if = "Option::is_none")] pub p2_pauli_model: Option>, + /// Duration of the idle-noise sites applied to both qubits after a two-qubit gate. + /// + /// The configured idle families determine the noise at these sites. #[serde(skip_serializing_if = "Option::is_none")] - pub p2_idle: Option, + pub idle_after_2q: Option, #[serde(skip_serializing_if = "Option::is_none")] pub p2_scale: Option, @@ -205,20 +210,42 @@ impl GeneralNoiseFields { /// Apply idle noise parameters to the builder fn apply_idle_params(&self, mut builder: GeneralNoiseModelBuilder) -> GeneralNoiseModelBuilder { - if let Some(v) = self.p_idle_coherent { - builder = builder.with_p_idle_coherent(v); - } - if let Some(v) = self.p_idle_linear_rate { - builder = builder.with_p_idle_linear_rate(v); - } - if let Some(model) = self.p_idle_linear_model.as_ref() { - builder = builder.with_p_idle_linear_model(model); - } - if let Some(v) = self.p_idle_quadratic_rate { - builder = builder.with_p_idle_quadratic_rate(v); - } - if let Some(v) = self.p_idle_coherent_to_incoherent_factor { - builder = builder.with_p_idle_coherent_to_incoherent_factor(v); + if let Some(rate) = self.p_idle_linear { + let default_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); + builder = builder.with_p_idle_linear( + rate, + self.p_idle_linear_model.as_ref().unwrap_or(&default_model), + ); + } + if let Some(rate) = self.p_idle_sin_squared { + let default_model = BTreeMap::from([ + ("X".to_string(), 1.0), + ("Y".to_string(), 1.0), + ("Z".to_string(), 1.0), + ]); + builder = builder.with_p_idle_sin_squared( + rate, + self.p_idle_sin_squared_model + .as_ref() + .unwrap_or(&default_model), + ); + } + if let Some(rate) = self.p_idle_coherent { + let default_model = BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]); + builder = builder.with_p_idle_coherent( + rate, + self.p_idle_coherent_model + .as_ref() + .unwrap_or(&default_model), + ); } if let Some(v) = self.idle_scale { builder = builder.with_idle_scale(v); @@ -229,7 +256,7 @@ impl GeneralNoiseFields { /// Apply prep noise parameters to the builder fn apply_prep_params(&self, mut builder: GeneralNoiseModelBuilder) -> GeneralNoiseModelBuilder { if let Some(v) = self.p_prep { - builder = builder.with_prep_probability(v); + builder = builder.with_p_prep(v); } if let Some(v) = self.p_prep_leak_ratio { builder = builder.with_prep_leak_ratio(v); @@ -252,7 +279,7 @@ impl GeneralNoiseFields { mut builder: GeneralNoiseModelBuilder, ) -> GeneralNoiseModelBuilder { if let Some(v) = self.p1 { - builder = builder.with_p1_probability(v); + builder = builder.with_p1(v); } if let Some(v) = self.p1_emission_ratio { builder = builder.with_p1_emission_ratio(v); @@ -278,7 +305,7 @@ impl GeneralNoiseFields { mut builder: GeneralNoiseModelBuilder, ) -> GeneralNoiseModelBuilder { if let Some(v) = self.p2 { - builder = builder.with_p2_probability(v); + builder = builder.with_p2(v); } if let Some((a, b, c, d)) = self.p2_angle_params { builder = builder.with_p2_angle_params(a, b, c, d); @@ -298,8 +325,8 @@ impl GeneralNoiseFields { if let Some(model) = self.p2_pauli_model.as_ref() { builder = builder.with_p2_pauli_model(model); } - if let Some(v) = self.p2_idle { - builder = builder.with_p2_idle(v); + if let Some(v) = self.idle_after_2q { + builder = builder.with_idle_after_2q(v); } if let Some(v) = self.p2_scale { builder = builder.with_p2_scale(v); @@ -310,10 +337,10 @@ impl GeneralNoiseFields { /// Apply measurement noise parameters to the builder fn apply_meas_params(&self, mut builder: GeneralNoiseModelBuilder) -> GeneralNoiseModelBuilder { if let Some(v) = self.p_meas_0 { - builder = builder.with_meas_0_probability(v); + builder = builder.with_p_meas_0(v); } if let Some(v) = self.p_meas_1 { - builder = builder.with_meas_1_probability(v); + builder = builder.with_p_meas_1(v); } if let Some(v) = self.p_meas_crosstalk { builder = builder.with_p_meas_crosstalk(v); diff --git a/crates/pecos-qasm/src/simulation.rs b/crates/pecos-qasm/src/simulation.rs index 4e9813524..e26c84e27 100644 --- a/crates/pecos-qasm/src/simulation.rs +++ b/crates/pecos-qasm/src/simulation.rs @@ -38,10 +38,10 @@ use pecos_programs::Qasm; /// /// // Run with noise /// let noise_builder = DepolarizingNoiseModel::builder() -/// .with_p1_probability(0.001) -/// .with_p2_probability(0.01) -/// .with_prep_probability(0.001) -/// .with_meas_probability(0.001); +/// .with_p1(0.001) +/// .with_p2(0.01) +/// .with_p_prep(0.001) +/// .with_p_meas(0.001); /// /// let results = qasm_engine() /// .program(Qasm::from_string(qasm)) diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs b/crates/pecos-qasm/tests/general_noise_builder_test.rs index cc5094628..ccc2990ad 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs @@ -23,10 +23,10 @@ fn test_general_noise_builder_basic() { // Create builder with fluent API let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002); + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -70,7 +70,7 @@ fn test_general_noise_builder_with_pauli_models() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High error rate for testing + .with_p1(0.1) // High error rate for testing .with_p1_pauli_model(&p1_model); let results = sim_builder() @@ -122,13 +122,13 @@ fn test_general_noise_builder_complex_configuration() { .with_scale(1.5) .with_leakage_scale(0.1) .with_emission_scale(0.8) - .with_prep_probability(0.001) - .with_average_p1_probability(0.0008) + .with_p_prep(0.001) + .with_average_p1(0.0008) .with_p1_pauli_model(&p1_model) - .with_average_p2_probability(0.008) + .with_average_p2(0.008) .with_p2_pauli_model(&p2_model) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) .with_noiseless_gate(GateType::H); let results = sim_builder() @@ -157,8 +157,8 @@ fn test_general_noise_builder_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.5) // Very high error rate - .with_p2_probability(0.5) // Very high error rate + .with_p1(0.5) // Very high error rate + .with_p2(0.5) // Very high error rate .with_noiseless_gate(GateType::H) // H gate is noiseless .with_noiseless_gate(GateType::MZ); // Measurement is noiseless @@ -188,13 +188,12 @@ fn test_general_noise_builder_with_prep_errors() { include "qelib1.inc"; qreg q[2]; creg c[2]; - // No gates, just measure initialized qubits + // Explicit preparation followed by measurement + reset q; measure q -> c; "#; - let noise_builder = GeneralNoiseModel::builder() - .with_seed(42) - .with_prep_probability(0.1); // 10% prep error + let noise_builder = GeneralNoiseModel::builder().with_seed(42).with_p_prep(0.1); // 10% prep error let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -237,8 +236,8 @@ fn test_general_noise_builder_measurement_errors() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_meas_0_probability(0.05) // 5% chance |0> measured as |1> - .with_meas_1_probability(0.10); // 10% chance |1> measured as |0> + .with_p_meas_0(0.05) // 5% chance |0> measured as |1> + .with_p_meas_1(0.10); // 10% chance |1> measured as |0> let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) @@ -282,20 +281,24 @@ fn test_general_noise_builder_chaining_all_methods() { "#; // Test that all builder methods can be chained + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let noise_builder = GeneralNoiseModel::builder() .with_seed(42) .with_scale(1.2) .with_leakage_scale(0.1) .with_emission_scale(0.9) - .with_prep_probability(0.001) - .with_p1_probability(0.001) - .with_average_p1_probability(0.0008) - .with_p2_probability(0.01) - .with_average_p2_probability(0.008) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) - .with_p_idle_coherent(false) - .with_p_idle_linear_rate(0.0001) + .with_p_prep(0.001) + .with_p1(0.001) + .with_average_p1(0.0008) + .with_p2(0.01) + .with_average_p2(0.008) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) + .with_p_idle_linear(0.0001, &idle_model) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); @@ -326,8 +329,8 @@ fn test_general_noise_builder_with_multiple_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High noise - .with_p2_probability(0.1) // High noise + .with_p1(0.1) // High noise + .with_p2(0.1) // High noise .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::SZ) // S gate .with_noiseless_gate(GateType::T) @@ -381,8 +384,8 @@ fn test_general_noise_builder_comparison_with_sim_builder() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01); + .with_p1(0.001) + .with_p2(0.01); // Test full method chaining with simulation builder let results = sim_builder() diff --git a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled index a130c6fe1..54a2385b5 100644 --- a/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled +++ b/crates/pecos-qasm/tests/general_noise_builder_test.rs.disabled @@ -23,10 +23,10 @@ fn test_general_noise_builder_basic() { // Create builder with fluent API let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002); + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002); let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -71,7 +71,7 @@ fn test_general_noise_builder_with_pauli_models() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High error rate for testing + .with_p1(0.1) // High error rate for testing .with_p1_pauli_model(&p1_model); let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -116,13 +116,13 @@ fn test_general_noise_builder_complex_configuration() { .with_scale(1.5) .with_leakage_scale(0.1) .with_emission_scale(0.8) - .with_prep_probability(0.001) - .with_average_p1_probability(0.0008) + .with_p_prep(0.001) + .with_average_p1(0.0008) .with_p1_pauli_model(&p1_model) - .with_average_p2_probability(0.008) + .with_average_p2(0.008) .with_p2_pauli_model(&p2_model) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) .with_noiseless_gate(GateType::H); let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -152,8 +152,8 @@ fn test_general_noise_builder_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.5) // Very high error rate - .with_p2_probability(0.5) // Very high error rate + .with_p1(0.5) // Very high error rate + .with_p2(0.5) // Very high error rate .with_noiseless_gate(GateType::H) // H gate is noiseless .with_noiseless_gate(GateType::Measure); // Measurement is noiseless @@ -186,7 +186,7 @@ fn test_general_noise_builder_with_prep_errors() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_prep_probability(0.1); // 10% prep error + .with_p_prep(0.1); // 10% prep error let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -230,8 +230,8 @@ fn test_general_noise_builder_measurement_errors() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_meas_0_probability(0.05) // 5% chance |0> measured as |1> - .with_meas_1_probability(0.10); // 10% chance |1> measured as |0> + .with_p_meas_0(0.05) // 5% chance |0> measured as |1> + .with_p_meas_1(0.10); // 10% chance |1> measured as |0> let noise_model = NoiseModelType::General(Box::new(noise_builder)); @@ -272,20 +272,24 @@ fn test_general_noise_builder_chaining_all_methods() { "#; // Test that all builder methods can be chained + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let noise_builder = GeneralNoiseModel::builder() .with_seed(42) .with_scale(1.2) .with_leakage_scale(0.1) .with_emission_scale(0.9) - .with_prep_probability(0.001) - .with_p1_probability(0.001) - .with_average_p1_probability(0.0008) - .with_p2_probability(0.01) - .with_average_p2_probability(0.008) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) - .with_p_idle_coherent(false) - .with_p_idle_linear_rate(0.0001) + .with_p_prep(0.001) + .with_p1(0.001) + .with_average_p1(0.0008) + .with_p2(0.01) + .with_average_p2(0.008) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) + .with_p_idle_linear(0.0001, &idle_model) .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::CX); @@ -313,8 +317,8 @@ fn test_general_noise_builder_with_multiple_noiseless_gates() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.1) // High noise - .with_p2_probability(0.1) // High noise + .with_p1(0.1) // High noise + .with_p2(0.1) // High noise .with_noiseless_gate(GateType::H) .with_noiseless_gate(GateType::SZ) // S gate .with_noiseless_gate(GateType::T) @@ -368,8 +372,8 @@ fn test_general_noise_builder_comparison_with_sim_builder() { let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01); + .with_p1(0.001) + .with_p2(0.01); let noise_model = NoiseModelType::General(Box::new(noise_builder)); diff --git a/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled b/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled index 897907966..62abe09ce 100644 --- a/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled +++ b/crates/pecos-qasm/tests/general_noise_config_test.rs.disabled @@ -68,8 +68,7 @@ fn test_general_noise_json_complex() { "ZY": 0.06, "ZZ": 0.04 }, - "p_idle_coherent": false, - "p_idle_linear_rate": 0.0001, + "p_idle_linear": 0.0001, "leakage_scale": 0.5, "emission_scale": 0.8, "p2_angle_params": [1.0, 0.5, 1.2, 0.3], @@ -89,8 +88,7 @@ fn test_general_noise_json_complex() { ); assert!(fields.p1_pauli_model.is_some()); assert!(fields.p2_pauli_model.is_some()); - assert_eq!(fields.p_idle_coherent, Some(false)); - assert_eq!(fields.p_idle_linear_rate, Some(0.0001)); + assert_eq!(fields.p_idle_linear, Some(0.0001)); assert_eq!(fields.leakage_scale, Some(0.5)); assert_eq!(fields.emission_scale, Some(0.8)); assert_eq!(fields.p2_angle_params, Some((1.0, 0.5, 1.2, 0.3))); diff --git a/crates/pecos-qasm/tests/qasm_sim_api_test.rs b/crates/pecos-qasm/tests/qasm_sim_api_test.rs index ffc6ab2a3..447f62f24 100644 --- a/crates/pecos-qasm/tests/qasm_sim_api_test.rs +++ b/crates/pecos-qasm/tests/qasm_sim_api_test.rs @@ -109,10 +109,10 @@ fn test_custom_depolarizing_noise() { // Use builder for custom depolarizing noise let noise_builder = DepolarizingNoiseModel::builder() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.001) - .with_p2_probability(0.1); // High two-qubit error + .with_p_prep(0.01) + .with_p_meas(0.01) + .with_p1(0.001) + .with_p2(0.1); // High two-qubit error let results = qasm_engine() .program(Qasm::from_string(qasm)) @@ -327,9 +327,9 @@ fn test_general_noise() { // Use GeneralNoiseModelBuilder instead of old GeneralNoise let noise_builder = GeneralNoiseModel::builder() .with_seed(42) - .with_p1_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001); + .with_p1(0.001) + .with_p_meas_0(0.001) + .with_p_meas_1(0.001); let results = qasm_engine() .program(Qasm::from_string(qasm)) diff --git a/crates/pecos-qasm/tests/run_qasm_test.rs b/crates/pecos-qasm/tests/run_qasm_test.rs index 28f3854d0..8fda645cf 100644 --- a/crates/pecos-qasm/tests/run_qasm_test.rs +++ b/crates/pecos-qasm/tests/run_qasm_test.rs @@ -113,10 +113,10 @@ fn test_run_qasm_with_config_structs() { // Test with config struct converted to enum let noise_config = DepolarizingNoiseModelBuilder::new() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.001) - .with_p2_probability(0.1); + .with_p_prep(0.01) + .with_p_meas(0.01) + .with_p1(0.001) + .with_p2(0.1); let results = sim_builder() .classical(qasm_engine().program(Qasm::from_string(qasm))) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 01a6649dd..8d58da4f4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,9 +100,10 @@ pub use sampler::{ pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, + DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, IdleNoiseFamily, MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, MeasurementMechanism, - MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, + MeasurementNoiseChannelResidual, MeasurementNoiseModel, NoiseChannelError, NoiseChannelKind, + NoiseChannelResidual, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, ReplacementBranchImpact, TwoDetectorDirectRenderPolicy, combine_probabilities, omitted_two_qubit_gate_pauli_twirl, record_offset_to_absolute_index, diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 08133f20e..a1432edfd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -17,8 +17,10 @@ use super::types::{ DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, - FaultMechanism, MeasurementCrosstalkDemMode, NoiseConfig, PerGateTypeNoise, - ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, + FaultMechanism, IdleChannelFamilies, MeasurementCrosstalkDemMode, NoiseChannelKind, + NoiseChannelResidual, NoiseConfig, PauliProbs, PerGateTypeNoise, + ReplacementBranchApproximation, SourceMetadata, fit_exclusive_signatures, + record_offset_to_absolute_index, validate_exclusive_probabilities, validate_idle_probabilities, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Direction, Pauli, apply_gate}; @@ -428,12 +430,15 @@ impl<'a> DemBuilder<'a> { p1_total * weights.weight_for(&Z(0)), ]; } - let per = per_channel_probability(p1_total, 3); + let per = p1_total / 3.0; [per, per, per] } - /// Resolve `[rate_X, rate_Y, rate_Z]` for an explicit idle location. - fn idle_rates_for_loc(&self, loc: &DagSpacetimeLocation) -> [f64; 3] { + /// Resolve the categorical Pauli channel for an explicit idle location. + fn idle_probabilities_for_loc( + &self, + loc: &DagSpacetimeLocation, + ) -> Result { if let Some(pg) = &self.per_gate { let explicit_rates = loc .qubits @@ -441,22 +446,34 @@ impl<'a> DemBuilder<'a> { .and_then(|q| pg.explicit_1q_rates_on(GateType::Idle, *q)) .or_else(|| pg.explicit_1q_rates(GateType::Idle)); if let Some(rates) = explicit_rates { - return rates; + let probabilities = PauliProbs { + px: rates[0], + py: rates[1], + pz: rates[2], + }; + validate_idle_probabilities(probabilities, "per-gate") + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + return Ok(IdleChannelFamilies { + exclusive: smallvec::smallvec![probabilities], + independent: SmallVec::new(), + }); } if pg.base.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = pg.base.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return pg + .base + .try_idle_channel_families(loc.idle_duration) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string())); } - return [0.0; 3]; + return Ok(IdleChannelFamilies::default()); } if self.noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = self.noise.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return self + .noise + .try_idle_channel_families(loc.idle_duration) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string())); } - [0.0; 3] + Ok(IdleChannelFamilies::default()) } /// Resolve the 15-entry 2Q per-Pauli-pair rate array for a gate @@ -501,7 +518,7 @@ impl<'a> DemBuilder<'a> { p2_total * weight }); } - [per_channel_probability(self.noise.p2_rate_for_gate(loc1.gate_type), 15); 15] + [self.noise.p2_rate_for_gate(loc1.gate_type) / 15.0; 15] } /// Sets the number of measurements (used for record offset calculation). @@ -795,13 +812,16 @@ impl<'a> DemBuilder<'a> { /// with a non-empty influence map, a used record offset is out of range, /// a used `meas_id` is not present in the circuit (resolved against the /// stable stamped ids when available, else positionally), or a - /// both-present entry's `records` and `meas_ids` are not redundant. + /// both-present entry's `records` and `meas_ids` are not redundant. Returns + /// [`DemBuilderError::ConfigurationError`] for an invalid noise input or a + /// non-positive signature-channel character. pub fn try_build(&self) -> Result { self.validate_measurement_count()?; self.validate_metadata_refs()?; self.validate_replacement_branch_approximation()?; self.validate_measurement_crosstalk_dem_mode()?; - Ok(self.build()) + self.validate_idle_noise()?; + self.build_inner() } /// Builds the Detector Error Model with source tracking. @@ -815,14 +835,22 @@ impl<'a> DemBuilder<'a> { /// circuit-derived metadata must use [`Self::try_build`] instead. /// # Panics /// - /// Panics if the configured replacement-branch approximation is invalid; - /// validity is established by construction-time validation. + /// Panics if the configured replacement-branch approximation is invalid, + /// or if a noise input or signature channel is invalid. Use + /// [`Self::try_build`] to receive those failures as errors. #[must_use] pub fn build(&self) -> DetectorErrorModel { self.validate_replacement_branch_approximation() .expect("invalid DEM replacement branch approximation"); self.validate_measurement_crosstalk_dem_mode() .expect("invalid DEM measurement crosstalk configuration"); + self.validate_idle_noise() + .expect("invalid DEM idle-noise configuration"); + self.build_inner() + .expect("invalid DEM signature conversion") + } + + fn build_inner(&self) -> Result { let num_influence_dem_outputs = self .num_influence_dem_outputs() .max(self.influence_map.dem_output_metadata.len()); @@ -887,9 +915,9 @@ impl<'a> DemBuilder<'a> { &mut dem, &meas_to_detectors, &meas_to_observables, - ); + )?; - dem + Ok(dem) } fn validate_replacement_branch_approximation(&self) -> Result<(), DemBuilderError> { @@ -1026,6 +1054,15 @@ impl<'a> DemBuilder<'a> { Ok(()) } + fn validate_idle_noise(&self) -> Result<(), DemBuilderError> { + for loc in &self.influence_map.locations { + if loc.gate_type == GateType::Idle && !loc.before { + let _ = self.idle_probabilities_for_loc(loc)?; + } + } + Ok(()) + } + fn hidden_mz_result_before_crosstalk_payload( context: ExactBranchReplayContext<'_>, loc: &DagSpacetimeLocation, @@ -1413,7 +1450,7 @@ impl<'a> DemBuilder<'a> { dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, - ) { + ) -> Result<(), DemBuilderError> { let locations = &self.influence_map.locations; for (loc_idx, loc) in locations.iter().enumerate() { @@ -1500,26 +1537,26 @@ impl<'a> DemBuilder<'a> { if !loc.before => { let rates = self.rates_1q_for_loc(loc); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_single_qubit_fault_source_tracked( loc_idx, rates, dem, meas_to_detectors, meas_to_observables, - ); + )?; } } GateType::Idle if !loc.before => { - let rates = self.idle_rates_for_loc(loc); - if rates.iter().any(|r| *r > 0.0) { - self.process_single_qubit_fault_source_tracked( + let families = self.idle_probabilities_for_loc(loc)?; + if !families.exclusive.is_empty() || !families.independent.is_empty() { + self.process_idle_fault_source_tracked( loc_idx, - rates, + families, dem, meas_to_detectors, meas_to_observables, - ); + )?; } } _ => {} @@ -1531,7 +1568,7 @@ impl<'a> DemBuilder<'a> { let loc1 = &locations[loc1_idx]; let loc2 = &locations[loc2_idx]; let rates = self.rates_2q_for_locs(loc1, loc2); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_two_qubit_fault_source_tracked( loc1_idx, loc2_idx, @@ -1539,7 +1576,7 @@ impl<'a> DemBuilder<'a> { dem, meas_to_detectors, meas_to_observables, - ); + )?; } if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::BranchImpact @@ -1560,6 +1597,7 @@ impl<'a> DemBuilder<'a> { ); } } + Ok(()) } /// Processes a prep fault with source tracking. @@ -1848,85 +1886,152 @@ impl<'a> DemBuilder<'a> { } } - /// Processes a single-qubit gate fault with source tracking. - /// `rates` is `[rate_X, rate_Y, rate_Z]` -- zero entries are skipped. - fn process_single_qubit_fault_source_tracked( + /// Converts one categorical idle Pauli channel after propagation has + /// produced its concrete detector/observable flip signatures. + fn process_idle_fault_source_tracked( &self, loc_idx: usize, - rates: [f64; 3], + families: IdleChannelFamilies, dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, - ) { - let [rate_x, rate_y, rate_z] = rates; - + ) -> Result<(), DemBuilderError> { let x_effect = self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + let y_effect = + self.compute_mechanism(loc_idx, Pauli::Y, meas_to_detectors, meas_to_observables); let z_effect = self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + debug_assert_eq!(y_effect, x_effect.xor(&z_effect)); - // X error: direct source - if rate_x > 0.0 && !x_effect.is_empty() { - dem.add_direct_contribution_with_source( - x_effect.clone(), - rate_x, - SourceMetadata::new( - &[loc_idx], - &[Pauli::X], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), - ); + let loc = &self.influence_map.locations[loc_idx]; + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let channel_weight = probabilities.total(); + let mut exclusive = BTreeMap::new(); + for (effect, probability) in [ + (x_effect.clone(), probabilities.px), + (y_effect.clone(), probabilities.py), + (z_effect.clone(), probabilities.pz), + ] { + if effect.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(effect).or_insert(0.0) += probability; + } + + let context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_signatures(exclusive, FaultMechanism::xor, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + for (effect, probability) in fit.mechanisms { + Self::add_single_location_signature_contribution( + loc_idx, + loc, + effect, + probability, + dem, + ); + } + if let Some((effect, magnitude)) = fit.residual { + dem.add_idle_noise_residual(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, + effect, + magnitude, + channel_weight, + }); + } + } + for probabilities in families.independent { + for (effect, probability) in [ + (x_effect.clone(), probabilities.px), + (y_effect.clone(), probabilities.py), + (z_effect.clone(), probabilities.pz), + ] { + Self::add_single_location_signature_contribution( + loc_idx, + loc, + effect, + probability, + dem, + ); + } } + Ok(()) + } - // Z error: direct source - if rate_z > 0.0 && !z_effect.is_empty() { - dem.add_direct_contribution_with_source( - z_effect.clone(), - rate_z, - SourceMetadata::new( - &[loc_idx], - &[Pauli::Z], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), - ); + fn add_single_location_signature_contribution( + loc_idx: usize, + loc: &DagSpacetimeLocation, + effect: FaultMechanism, + probability: f64, + dem: &mut DetectorErrorModel, + ) { + if effect.is_empty() || probability == 0.0 { + return; } + dem.add_direct_contribution_with_source( + effect, + probability, + SourceMetadata::new(&[loc_idx], &[], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::ExclusiveSignature), + ); + } - // Y error: Y = XZ, so effect is XOR of X and Z effects - let y_effect = x_effect.xor(&z_effect); - if rate_y > 0.0 && !y_effect.is_empty() { - if !x_effect.is_empty() && !z_effect.is_empty() { - dem.add_y_decomposed_contribution_with_source( - &x_effect, - &z_effect, - rate_y, - SourceMetadata::new( - &[loc_idx], - &[Pauli::Y], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), - ); - } else { - // One is empty, so Y has same effect as the non-empty one (direct source) - dem.add_direct_contribution_with_source( - y_effect, - rate_y, - SourceMetadata::new( - &[loc_idx], - &[Pauli::Y], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), - ); + /// Converts a categorical single-qubit gate channel at the propagated-signature layer. + fn process_single_qubit_fault_source_tracked( + &self, + loc_idx: usize, + rates: [f64; 3], + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> Result<(), DemBuilderError> { + let loc = &self.influence_map.locations[loc_idx]; + let context = format!("one-qubit {} gate at location {loc_idx}", loc.gate_type); + validate_exclusive_probabilities(&rates, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + let channel_weight = rates.iter().sum(); + let x_effect = + self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + let y_effect = + self.compute_mechanism(loc_idx, Pauli::Y, meas_to_detectors, meas_to_observables); + let z_effect = + self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + debug_assert_eq!(y_effect, x_effect.xor(&z_effect)); + + let mut exclusive = BTreeMap::new(); + for (effect, probability) in [x_effect, y_effect, z_effect].into_iter().zip(rates) { + if effect.is_empty() || probability == 0.0 { + continue; } + *exclusive.entry(effect).or_insert(0.0) += probability; } + let fit = fit_exclusive_signatures(exclusive, FaultMechanism::xor, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + for (effect, probability) in fit.mechanisms { + Self::add_single_location_signature_contribution( + loc_idx, + loc, + effect, + probability, + dem, + ); + } + if let Some((effect, magnitude)) = fit.residual { + dem.add_idle_noise_residual(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::SingleQubitGate, + effect, + magnitude, + channel_weight, + }); + } + Ok(()) } - /// Processes a two-qubit gate fault with source tracking and intra-channel decomposition. - /// `rates` is the 15-entry array in `PAULI_2Q_ORDER` order -- zero entries - /// are skipped. + /// Converts a categorical two-qubit gate channel at the propagated-signature layer. fn process_two_qubit_fault_source_tracked( &self, loc1: usize, @@ -1935,31 +2040,61 @@ impl<'a> DemBuilder<'a> { dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, - ) { + ) -> Result<(), DemBuilderError> { let loc1_meta = &self.influence_map.locations[loc1]; let loc2_meta = &self.influence_map.locations[loc2]; + let context = format!( + "two-qubit {} gate at locations {loc1} and {loc2}", + loc1_meta.gate_type + ); + validate_exclusive_probabilities(&rates, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + let channel_weight = rates.iter().sum(); let effects = self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); - // Process all 15 non-trivial Pauli combinations + let mut exclusive = BTreeMap::new(); for p1 in 0u8..4 { for p2 in 0u8..4 { if p1 == 0 && p2 == 0 { - continue; // Skip II + continue; } - - // Per-pair rate: index = 4*p1 + p2 - 1 (skipping II at idx 0). let flat = 4 * (p1 as usize) + (p2 as usize); - let prob = rates[flat - 1]; - if prob == 0.0 { + let probability = rates[flat - 1]; + let effect = effects[p1 as usize][p2 as usize].clone(); + if effect.is_empty() || probability == 0.0 { continue; } - Self::add_two_qubit_pauli_contribution( - loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, None, - ); + *exclusive.entry(effect).or_insert(0.0) += probability; } } + let fit = fit_exclusive_signatures(exclusive, FaultMechanism::xor, &context) + .map_err(|error| DemBuilderError::ConfigurationError(error.to_string()))?; + for (effect, probability) in fit.mechanisms { + dem.add_direct_contribution_with_source( + effect, + probability, + SourceMetadata::new( + &[loc1, loc2], + &[], + &[loc1_meta.gate_type, loc2_meta.gate_type], + &[loc1_meta.before, loc2_meta.before], + ) + .with_direct_source_family(DirectSourceFamily::ExclusiveSignature), + ); + } + if let Some((effect, magnitude)) = fit.residual { + dem.add_idle_noise_residual(NoiseChannelResidual { + location_index: u32::try_from(loc1) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::TwoQubitGate, + effect, + magnitude, + channel_weight, + }); + } + Ok(()) } fn two_qubit_effect_table( @@ -2471,39 +2606,6 @@ fn pauli_label_to_index(label: char) -> Option { } } -/// Computes the per-error probability for independent error channels. -/// -/// For a depolarizing channel with total error probability `p` split among `n` -/// independent Pauli channels, this computes the probability for each channel -/// such that the combined probability of any error occurring equals `p`. -/// -/// Formula: `p_each = 1 - (1-p)^(1/n)` -/// -/// This is derived from: `P(at least one error) = 1 - P(no errors) = 1 - (1-p_each)^n = p` -/// -/// For small `p`, this is approximately `p/n`, but the exact formula accounts -/// for the independence of error channels. -/// -/// # Arguments -/// -/// * `total_prob` - Total depolarizing probability (e.g., 0.02 for 2% error rate) -/// * `num_channels` - Number of independent error channels (3 for DEPOLARIZE1, 15 for DEPOLARIZE2) -/// -/// # Returns -/// -/// Per-channel error probability -#[inline] -fn per_channel_probability(total_prob: f64, num_channels: u32) -> f64 { - if total_prob <= 0.0 { - return 0.0; - } - if total_prob >= 1.0 { - return 1.0; - } - // p_each = 1 - (1-p)^(1/n) - 1.0 - (1.0 - total_prob).powf(1.0 / f64::from(num_channels)) -} - // ============================================================================ // Intra-Channel Decomposition // ============================================================================ @@ -4064,12 +4166,6 @@ mod tests { if location.num_alternatives == 0 { continue; } - let num_alternatives = f64::from( - u32::try_from(location.num_alternatives) - .expect("fault alternative count fits in u32"), - ); - let per_channel_probability = - 1.0 - location.no_fault_probability.powf(1.0 / num_alternatives); for fault in &location.faults { if fault.affected_detectors.is_empty() && fault.affected_observables.is_empty() { @@ -4086,7 +4182,7 @@ mod tests { .map(|&obs| u32::try_from(obs).unwrap()) .collect(); *by_effect.entry((detectors, observables)).or_insert(0.0) += - per_channel_probability; + fault.absolute_probability; } } by_effect @@ -4112,7 +4208,7 @@ mod tests { .collect() } - fn assert_catalog_dem_probabilities_match( + fn assert_catalog_dem_effects_match( catalog: &FaultCatalog, dem: &DetectorErrorModel, gate_type: GateType, @@ -4124,13 +4220,6 @@ mod tests { dem_probs.keys().collect::>(), "{gate_type:?} should produce the same non-empty effects in the fault catalog and DEM" ); - for (effect, catalog_probability) in catalog_probs { - let dem_probability = dem_probs[&effect]; - assert!( - (catalog_probability - dem_probability).abs() < 1e-12, - "{gate_type:?} effect {effect:?}: catalog probability {catalog_probability} != DEM probability {dem_probability}" - ); - } } for gate_type in [ @@ -4176,7 +4265,7 @@ mod tests { dem_has_source(&dem, gate_type), "DEM should track a source contribution for {gate_type:?}" ); - assert_catalog_dem_probabilities_match(&catalog, &dem, gate_type); + assert_catalog_dem_effects_match(&catalog, &dem, gate_type); } for gate_type in [ @@ -4224,7 +4313,7 @@ mod tests { dem_has_source(&dem, gate_type), "DEM should track a source contribution for {gate_type:?}" ); - assert_catalog_dem_probabilities_match(&catalog, &dem, gate_type); + assert_catalog_dem_effects_match(&catalog, &dem, gate_type); } } @@ -5194,38 +5283,6 @@ mod tests { assert!(vec.is_empty()); } - #[test] - fn test_per_channel_probability() { - // Test DEPOLARIZE1: p=0.01, n=3 - let p1 = per_channel_probability(0.01, 3); - // Should be 1 - (1-0.01)^(1/3) = 0.003344... - assert!((p1 - 0.003_344_506).abs() < 1e-6); - - // Verify: combining 3 channels gives back ~p - let combined = 1.0 - (1.0 - p1).powi(3); - assert!((combined - 0.01).abs() < 1e-10); - - // Test DEPOLARIZE2: p=0.02, n=15 - let p2 = per_channel_probability(0.02, 15); - // Should be 1 - (1-0.02)^(1/15) = 0.001346... - assert!((p2 - 0.001_345_941).abs() < 1e-6); - - // Verify: combining 15 channels gives back ~p - let combined2 = 1.0 - (1.0 - p2).powi(15); - assert!((combined2 - 0.02).abs() < 1e-10); - - // Edge cases - assert!((per_channel_probability(0.0, 3) - 0.0).abs() < f64::EPSILON); - assert!((per_channel_probability(1.0, 3) - 1.0).abs() < f64::EPSILON); - assert!((per_channel_probability(-0.1, 3) - 0.0).abs() < f64::EPSILON); - - // For small p, should be close to p/n - let small_p = per_channel_probability(0.001, 15); - let simple = 0.001 / 15.0; - // Difference should be < 0.1% for small p - assert!((small_p - simple).abs() / simple < 0.001); - } - /// Issue #325 regression: `from_circuit` once produced different DEMs for /// native `F`/`Fdg`/`SY`/`SYdg` versus their unitarily identical /// decompositions (86 mechanisms differed on the d=3 SZZ lowered diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index ce77e4336..5fb3d82e2 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 @@ -72,8 +72,10 @@ use std::collections::{BTreeMap, BTreeSet}; use wide::u64x4; use super::types::{ - NoiseConfig, PauliWeights, PerGateTypeNoise, ReplacementBranchApproximation, - combine_probabilities, + FaultMechanism, IdleChannelFamilies, NoiseChannelKind, NoiseChannelResidual, NoiseConfig, + PauliProbs, PauliWeights, PerGateTypeNoise, ReplacementBranchApproximation, + combine_probabilities, fit_exclusive_signatures, validate_exclusive_probabilities, + validate_idle_probabilities, }; // ============================================================================ @@ -110,6 +112,47 @@ impl DemMechanism { fn is_empty(&self) -> bool { self.detectors.is_empty() && self.dem_outputs.is_empty() } + + fn xor(&self, other: &Self) -> Self { + fn symmetric_difference( + left: &SmallVec<[u32; N]>, + right: &SmallVec<[u32; N]>, + ) -> SmallVec<[u32; N]> + where + [u32; N]: smallvec::Array, + { + let mut result = SmallVec::new(); + let (mut i, mut j) = (0, 0); + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => { + result.push(left[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + result.push(right[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + result.extend_from_slice(&left[i..]); + result.extend_from_slice(&right[j..]); + result + } + + Self { + detectors: symmetric_difference(&self.detectors, &other.detectors), + dem_outputs: symmetric_difference(&self.dem_outputs, &other.dem_outputs), + } + } + + fn as_fault_mechanism(&self) -> FaultMechanism { + FaultMechanism::from_sorted(self.detectors.clone(), self.dem_outputs.clone()) + } } // ============================================================================ @@ -226,6 +269,8 @@ pub struct SamplingEngine { num_detectors: usize, /// Number of DEM `L` outputs. num_dem_outputs: usize, + /// Quantified approximations introduced by categorical signature conversion. + idle_noise_residuals: Vec, } const U32_BASE_AS_F64: f64 = 4_294_967_296.0; @@ -266,6 +311,12 @@ impl SamplingEngine { self.num_dem_outputs } + /// Returns quantified categorical-channel approximations made while building. + #[must_use] + pub fn idle_noise_residuals(&self) -> &[NoiseChannelResidual] { + &self.idle_noise_residuals + } + /// Reconstruct a [`DetectorErrorModel`] from the aggregated `SoA` /// mechanism state for text output (e.g. Stim-format via /// [`DetectorErrorModel::to_string`]). @@ -293,6 +344,9 @@ impl SamplingEngine { ); dem.add_direct_contribution(mechanism, prob); } + for residual in &self.idle_noise_residuals { + dem.add_idle_noise_residual(residual.clone()); + } dem } @@ -366,6 +420,7 @@ impl SamplingEngine { dem_output_data, num_detectors, num_dem_outputs, + idle_noise_residuals: Vec::new(), } } @@ -391,6 +446,11 @@ impl SamplingEngine { /// and each non-identity Pauli is equally likely (p/3 for 1-qubit, /// p/15 for 2-qubit). For idle gates with T1/T2 noise, the Pauli /// distribution is biased (more Z than X/Y). + /// + /// # Panics + /// + /// Panics if a noise input or signature channel is invalid, or if a gate + /// event has no corresponding fault location. #[must_use] pub fn from_influence_map( influence_map: &DagFaultInfluenceMap, @@ -400,6 +460,7 @@ impl SamplingEngine { use pecos_core::gate_type::GateType; let mut aggregated: BTreeMap = BTreeMap::new(); + let mut idle_noise_residuals = Vec::new(); let gate_locs = influence_map.gate_fault_locations(); @@ -409,7 +470,7 @@ impl SamplingEngine { per_location_probs, &influence_map.locations, ); - if p <= 0.0 { + if p == 0.0 { continue; } @@ -418,49 +479,123 @@ impl SamplingEngine { continue; } - // For idle gates with T1/T2 noise, use per-Pauli probabilities. - // For all other gates, divide equally among events. let is_idle = loc.gate_type == GateType::Idle; - let idle_pauli_probs = if is_idle { + if is_idle { let duration = influence_map .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(0.0, |l| l.idle_duration.max(0.0)); - Some(noise.idle_pauli_probs(duration)) - } else { - None - }; + .map_or(0.0, |l| l.idle_duration); + let families = noise + .try_idle_channel_families(duration) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + let mut effects: [Option; 4] = [None, None, None, None]; + for event in &events { + let pauli = event + .pauli + .paulis() + .first() + .map_or(pecos_core::Pauli::I, |&(pauli, _)| pauli); + let detectors = event.detectors.iter().copied().collect(); + let dem_outputs = event + .dem_outputs + .iter() + .filter_map(|&idx| influence_map.observable_id_for_internal_dem_output(idx)) + .collect(); + let pauli_index = match pauli { + pecos_core::Pauli::I => 0, + pecos_core::Pauli::X => 1, + pecos_core::Pauli::Y => 2, + pecos_core::Pauli::Z => 3, + }; + effects[pauli_index] = Some(DemMechanism::new(detectors, dem_outputs)); + } + let x = effects[Pauli::X.as_u8() as usize] + .clone() + .unwrap_or_else(DemMechanism::empty); + let y = effects[Pauli::Y.as_u8() as usize] + .clone() + .unwrap_or_else(DemMechanism::empty); + let z = effects[Pauli::Z.as_u8() as usize] + .clone() + .unwrap_or_else(DemMechanism::empty); + debug_assert_eq!(y, x.xor(&z)); + let loc_idx = influence_map + .locations + .iter() + .position(|candidate| { + candidate.node == loc.node && candidate.before == loc.before + }) + .expect("idle gate location must have a fault location"); + + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let channel_weight = probabilities.total(); + let mut exclusive = BTreeMap::new(); + for (mechanism, probability) in [ + (x.clone(), probabilities.px), + (y.clone(), probabilities.py), + (z.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; + } + let context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &context) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| { + *held = combine_probabilities(*held, probability); + }) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + idle_noise_residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, + effect: mechanism.as_fault_mechanism(), + magnitude, + channel_weight, + }); + } + } + for probabilities in families.independent { + for (mechanism, probability) in [ + (x.clone(), probabilities.px), + (y.clone(), probabilities.py), + (z.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + aggregated + .entry(mechanism) + .and_modify(|held| { + *held = combine_probabilities(*held, probability); + }) + .or_insert(probability); + } + } + continue; + } // Get per-event probabilities based on gate type and noise config let n_qubits = loc.num_qubits(); - let custom_weights = if idle_pauli_probs.is_some() { - None - } else if n_qubits == 1 { + let custom_weights = if n_qubits == 1 { noise.p1_weights.as_ref() } else { noise.p2_weights.as_ref() }; - let event_weights: Vec = if let Some(pp) = &idle_pauli_probs { - // T1/T2 idle: absolute per-Pauli probabilities - events - .iter() - .map(|event| { - let pauli = event - .pauli - .paulis() - .first() - .map_or(pecos_core::Pauli::I, |&(pa, _)| pa); - match pauli { - pecos_core::Pauli::X => pp.px, - pecos_core::Pauli::Y => pp.py, - pecos_core::Pauli::Z => pp.pz, - pecos_core::Pauli::I => 0.0, - } - }) - .collect() - } else if let Some(weights) = custom_weights { + let event_weights: Vec = if let Some(weights) = custom_weights { // Custom per-Pauli weights: p * weight_for(pauli) events .iter() @@ -485,6 +620,25 @@ impl SamplingEngine { vec![per_event; events.len()] }; + let loc_idx = influence_map + .locations + .iter() + .position(|candidate| candidate.node == loc.node && candidate.before == loc.before) + .expect("gate event must have a fault location"); + let channel_kind = if n_qubits == 2 { + NoiseChannelKind::TwoQubitGate + } else { + NoiseChannelKind::SingleQubitGate + }; + let context = format!( + "{} {} channel at location {loc_idx}", + channel_kind.as_str(), + loc.gate_type + ); + validate_exclusive_probabilities(&event_weights, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let channel_weight = event_weights.iter().sum(); + let mut exclusive = BTreeMap::new(); for (event, &event_prob) in events.iter().zip(&event_weights) { let det_indices: SmallVec<[u32; 4]> = event.detectors.iter().copied().collect(); let dem_output_indices: SmallVec<[u32; 2]> = event @@ -494,11 +648,28 @@ impl SamplingEngine { .collect(); let mech = DemMechanism::new(det_indices, dem_output_indices); - if !mech.is_empty() { - let entry = aggregated.entry(mech).or_insert(0.0); - *entry = combine_probabilities(*entry, event_prob); + if !mech.is_empty() && event_prob != 0.0 { + *exclusive.entry(mech).or_insert(0.0) += event_prob; } } + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + idle_noise_residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind, + effect: mechanism.as_fault_mechanism(), + magnitude, + channel_weight, + }); + } } let num_detectors = influence_map.detectors.len(); @@ -508,7 +679,9 @@ impl SamplingEngine { .into_iter() .map(|(mech, prob)| (prob, mech.detectors.to_vec(), mech.dem_outputs.to_vec())); - Self::from_mechanisms(mechanisms, num_detectors, num_dem_outputs) + let mut engine = Self::from_mechanisms(mechanisms, num_detectors, num_dem_outputs); + engine.idle_noise_residuals = idle_noise_residuals; + engine } /// Sample a single shot. @@ -2035,6 +2208,7 @@ impl<'a> SamplingEngineBuilder<'a> { // Aggregation map: mechanism -> probability let mut aggregated: BTreeMap = BTreeMap::new(); + let mut idle_noise_residuals = Vec::new(); // Group two-qubit gate locations by node for paired processing let mut cx_groups: BTreeMap> = BTreeMap::new(); @@ -2112,12 +2286,13 @@ impl<'a> SamplingEngineBuilder<'a> { if !loc.before => { let rates = self.rates_1q(loc.gate_type, &loc.qubits); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_depolarizing_fault_rates( loc_idx, rates, &mechanism_context, &mut aggregated, + &mut idle_noise_residuals, ); } } @@ -2127,13 +2302,14 @@ impl<'a> SamplingEngineBuilder<'a> { // explicitly configured. if !loc.before => { - let rates = self.idle_rates(loc); - if rates.iter().any(|r| *r > 0.0) { - self.process_depolarizing_fault_rates( + let families = self.idle_families(loc); + if !families.exclusive.is_empty() || !families.independent.is_empty() { + self.process_idle_fault_families( loc_idx, - rates, + families, &mechanism_context, &mut aggregated, + &mut idle_noise_residuals, ); } } @@ -2143,8 +2319,8 @@ impl<'a> SamplingEngineBuilder<'a> { // Process two-qubit gates as pairs let has_any_2q_noise = self.per_gate.is_some() - || self.p2 > 0.0 - || self.p2_gate_rates.values().any(|rate| *rate > 0.0); + || self.p2 != 0.0 + || self.p2_gate_rates.values().any(|rate| *rate != 0.0); if has_any_2q_noise { for loc_indices in cx_groups.values() { for pair in loc_indices.chunks(2) { @@ -2164,13 +2340,14 @@ impl<'a> SamplingEngineBuilder<'a> { .copied() .collect(); let rates = self.rates_2q(gate_type, &pair_qubits); - if rates.iter().any(|r| *r > 0.0) { + if rates.iter().any(|r| *r != 0.0) { self.process_two_qubit_fault_rates( pair[0], pair[1], rates, &mechanism_context, &mut aggregated, + &mut idle_noise_residuals, ); } } @@ -2229,6 +2406,7 @@ impl<'a> SamplingEngineBuilder<'a> { dem_output_data, num_detectors, num_dem_outputs, + idle_noise_residuals, } } @@ -2348,11 +2526,11 @@ impl<'a> SamplingEngineBuilder<'a> { } } - /// Resolve per-Pauli rates for an explicit idle location. - fn idle_rates( + /// Resolve categorical and independent families for an explicit idle location. + fn idle_families( &self, loc: &crate::fault_tolerance::propagator::dag::DagSpacetimeLocation, - ) -> [f64; 3] { + ) -> IdleChannelFamilies { if let Some(pg) = &self.per_gate { let explicit_rates = loc .qubits @@ -2360,24 +2538,38 @@ impl<'a> SamplingEngineBuilder<'a> { .and_then(|q| pg.explicit_1q_rates_on(GateType::Idle, *q)) .or_else(|| pg.explicit_1q_rates(GateType::Idle)); if let Some(rates) = explicit_rates { - return rates; + let probabilities = PauliProbs { + px: rates[0], + py: rates[1], + pz: rates[2], + }; + validate_idle_probabilities(probabilities, "per-gate").unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + return IdleChannelFamilies { + exclusive: smallvec::smallvec![probabilities], + independent: SmallVec::new(), + }; } if pg.base.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = pg.base.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return pg + .base + .try_idle_channel_families(loc.idle_duration) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); } - return [0.0; 3]; + return IdleChannelFamilies::default(); } if let Some(noise) = &self.idle_noise && noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = noise.idle_pauli_probs(duration); - return [probs.px, probs.py, probs.pz]; + return noise + .try_idle_channel_families(loc.idle_duration) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")); } - [0.0; 3] + IdleChannelFamilies::default() } /// Resolve per-Pauli-pair rates for a 2Q gate (15 non-II pairs) on a @@ -2416,11 +2608,15 @@ impl<'a> SamplingEngineBuilder<'a> { rates: [f64; 3], context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, + residuals: &mut Vec, ) { + let gate_type = self.influence_map.locations[loc_idx].gate_type; + let fit_context = format!("one-qubit {gate_type} gate at location {loc_idx}"); + validate_exclusive_probabilities(&rates, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let channel_weight = rates.iter().sum(); + let mut exclusive = BTreeMap::new(); for (pauli, &per_pauli_prob) in [Pauli::X, Pauli::Y, Pauli::Z].iter().zip(rates.iter()) { - if per_pauli_prob == 0.0 { - continue; - } let mechanism = self.compute_mechanism( loc_idx, *pauli, @@ -2428,9 +2624,111 @@ impl<'a> SamplingEngineBuilder<'a> { context.influence_observable_ids, context.num_tc_measurements, ); - if !mechanism.is_empty() { - let entry = aggregated.entry(mechanism).or_insert(0.0); - *entry = combine_probabilities(*entry, per_pauli_prob); + if mechanism.is_empty() || per_pauli_prob == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += per_pauli_prob; + } + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::SingleQubitGate, + effect: mechanism.as_fault_mechanism(), + magnitude, + channel_weight, + }); + } + } + + fn process_idle_fault_families( + &self, + loc_idx: usize, + families: IdleChannelFamilies, + context: &FaultMechanismContext<'_>, + aggregated: &mut BTreeMap, + residuals: &mut Vec, + ) { + let x_mechanism = self.compute_mechanism( + loc_idx, + Pauli::X, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); + let y_mechanism = self.compute_mechanism( + loc_idx, + Pauli::Y, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); + let z_mechanism = self.compute_mechanism( + loc_idx, + Pauli::Z, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); + debug_assert_eq!(y_mechanism, x_mechanism.xor(&z_mechanism)); + + let add = |mechanism: DemMechanism, + probability: f64, + aggregated: &mut BTreeMap| { + if mechanism.is_empty() || probability == 0.0 { + return; + } + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + }; + + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let channel_weight = probabilities.total(); + let mut exclusive = BTreeMap::new(); + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; + } + let fit_context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + add(mechanism, probability, aggregated); + } + if let Some((mechanism, magnitude)) = fit.residual { + residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, + effect: mechanism.as_fault_mechanism(), + magnitude, + channel_weight, + }); + } + } + for probabilities in families.independent { + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + add(mechanism, probability, aggregated); } } } @@ -2444,7 +2742,13 @@ impl<'a> SamplingEngineBuilder<'a> { rates: [f64; 15], context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, + residuals: &mut Vec, ) { + let gate_type = self.influence_map.locations[loc1].gate_type; + let fit_context = format!("two-qubit {gate_type} gate at locations {loc1} and {loc2}"); + validate_exclusive_probabilities(&rates, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let channel_weight = rates.iter().sum(); let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; let mut effects1: [Option; 4] = [None, None, None, None]; @@ -2467,7 +2771,7 @@ impl<'a> SamplingEngineBuilder<'a> { )); } - // Iterate (p1, p2) with global index = 4*p1 + p2 (skipping II at idx 0). + let mut exclusive = BTreeMap::new(); for &p1 in &paulis { for &p2 in &paulis { if p1 == Pauli::I && p2 == Pauli::I { @@ -2493,11 +2797,28 @@ impl<'a> SamplingEngineBuilder<'a> { xor_mechanisms(e1, e2) }; if !mechanism.is_empty() { - let entry = aggregated.entry(mechanism).or_insert(0.0); - *entry = combine_probabilities(*entry, prob); + *exclusive.entry(mechanism).or_insert(0.0) += prob; } } } + let fit = fit_exclusive_signatures(exclusive, DemMechanism::xor, &fit_context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + aggregated + .entry(mechanism) + .and_modify(|held| *held = combine_probabilities(*held, probability)) + .or_insert(probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + residuals.push(NoiseChannelResidual { + location_index: u32::try_from(loc1) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::TwoQubitGate, + effect: mechanism.as_fault_mechanism(), + magnitude, + channel_weight, + }); + } } /// Compute the mechanism (detector/standard observable effects) for a fault. diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs index 48dbbbdaf..bf5571c17 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs @@ -17,7 +17,11 @@ //! the MNM maps faults directly to raw measurement flips for fast approximate //! sampling. -use super::types::{MeasurementMechanism, MeasurementNoiseModel, NoiseConfig}; +use super::types::{ + IdleChannelFamilies, MeasurementMechanism, MeasurementNoiseChannelResidual, + MeasurementNoiseModel, NoiseChannelKind, NoiseConfig, fit_exclusive_signatures, + validate_exclusive_probabilities, +}; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; use pecos_core::gate_type::GateType; use smallvec::SmallVec; @@ -65,6 +69,10 @@ impl<'a> MemBuilder<'a> { } /// Builds the Measurement Noise Model. + /// + /// # Panics + /// + /// Panics if a noise input or signature channel is invalid. #[must_use] pub fn build(&self) -> MeasurementNoiseModel { let num_measurements = self.influence_map.measurements.len(); @@ -124,24 +132,21 @@ impl<'a> MemBuilder<'a> { | GateType::RZ | GateType::U | GateType::R1XY - if self.noise.p1 > 0.0 && !loc.before => + if self.noise.p1 != 0.0 && !loc.before => { self.process_single_qubit_fault(loc_idx, &mut mem); } GateType::Idle if !loc.before => { if self.noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); - let probs = self.noise.idle_pauli_probs(duration); - if probs.px > 0.0 { - self.process_single_pauli_fault(loc_idx, Pauli::X, probs.px, &mut mem); - } - if probs.py > 0.0 { - self.process_single_pauli_fault(loc_idx, Pauli::Y, probs.py, &mut mem); - } - if probs.pz > 0.0 { - self.process_single_pauli_fault(loc_idx, Pauli::Z, probs.pz, &mut mem); - } - } else if self.noise.p1 > 0.0 { + let duration = loc.idle_duration; + let families = self + .noise + .try_idle_channel_families(duration) + .unwrap_or_else(|error| { + panic!("invalid DEM idle-noise configuration: {error}") + }); + self.process_idle_fault(loc_idx, families, &mut mem); + } else if self.noise.p1 != 0.0 { self.process_single_qubit_fault(loc_idx, &mut mem); } } @@ -149,7 +154,7 @@ impl<'a> MemBuilder<'a> { } } - if self.noise.p2 > 0.0 { + if self.noise.p2 != 0.0 { for loc_indices in two_qubit_groups.values() { for pair in loc_indices.chunks(2) { if pair.len() == 2 { @@ -204,13 +209,96 @@ impl<'a> MemBuilder<'a> { fn process_single_qubit_fault(&self, loc_idx: usize, mem: &mut MeasurementNoiseModel) { let prob = self.noise.p1 / 3.0; - for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { - self.process_single_pauli_fault(loc_idx, pauli, prob, mem); + let probabilities = [prob; 3]; + let context = format!("one-qubit gate at location {loc_idx}"); + validate_exclusive_probabilities(&probabilities, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + let mut exclusive = std::collections::BTreeMap::new(); + for (pauli, probability) in [Pauli::X, Pauli::Y, Pauli::Z] + .into_iter() + .zip(probabilities) + { + let mechanism = self.compute_mechanism(loc_idx, pauli); + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; + } + Self::add_exclusive_signatures( + loc_idx, + NoiseChannelKind::SingleQubitGate, + exclusive, + &context, + mem, + ); + } + + fn process_idle_fault( + &self, + loc_idx: usize, + families: IdleChannelFamilies, + mem: &mut MeasurementNoiseModel, + ) { + let x_mechanism = self.compute_mechanism(loc_idx, Pauli::X); + let y_mechanism = self.compute_mechanism(loc_idx, Pauli::Y); + let z_mechanism = self.compute_mechanism(loc_idx, Pauli::Z); + debug_assert_eq!( + y_mechanism, + xor_measurement_mechanisms(Some(&x_mechanism), Some(&z_mechanism)) + ); + + for (family_index, probabilities) in families.exclusive.into_iter().enumerate() { + let mut exclusive = std::collections::BTreeMap::new(); + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + if mechanism.is_empty() || probability == 0.0 { + continue; + } + *exclusive.entry(mechanism).or_insert(0.0) += probability; + } + + let context = format!("location {loc_idx} exclusive family {family_index}"); + let fit = fit_exclusive_signatures( + exclusive, + |left, right| xor_measurement_mechanisms(Some(left), Some(right)), + &context, + ) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + mem.add_mechanism(mechanism, probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + mem.add_idle_noise_residual(MeasurementNoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind: NoiseChannelKind::Idle, + mechanism, + magnitude, + }); + } + } + for probabilities in families.independent { + for (mechanism, probability) in [ + (x_mechanism.clone(), probabilities.px), + (y_mechanism.clone(), probabilities.py), + (z_mechanism.clone(), probabilities.pz), + ] { + if !mechanism.is_empty() && probability != 0.0 { + mem.add_mechanism(mechanism, probability); + } + } } } fn process_two_qubit_fault(&self, loc1: usize, loc2: usize, mem: &mut MeasurementNoiseModel) { let prob = self.noise.p2 / 15.0; + let probabilities = [prob; 15]; + let context = format!("two-qubit gate at locations {loc1} and {loc2}"); + validate_exclusive_probabilities(&probabilities, &context) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; let mut effects1: [Option; 4] = [None, None, None, None]; @@ -221,6 +309,7 @@ impl<'a> MemBuilder<'a> { effects2[p.as_u8() as usize] = Some(self.compute_mechanism(loc2, p)); } + let mut exclusive = std::collections::BTreeMap::new(); for &p1 in &paulis { for &p2 in &paulis { if p1 == Pauli::I && p2 == Pauli::I { @@ -238,11 +327,45 @@ impl<'a> MemBuilder<'a> { ) }; - if !mechanism.is_empty() { - mem.add_mechanism(mechanism, prob); + if !mechanism.is_empty() && prob != 0.0 { + *exclusive.entry(mechanism).or_insert(0.0) += prob; } } } + Self::add_exclusive_signatures( + loc1, + NoiseChannelKind::TwoQubitGate, + exclusive, + &context, + mem, + ); + } + + fn add_exclusive_signatures( + loc_idx: usize, + channel_kind: NoiseChannelKind, + exclusive: std::collections::BTreeMap, + context: &str, + mem: &mut MeasurementNoiseModel, + ) { + let fit = fit_exclusive_signatures( + exclusive, + |left, right| xor_measurement_mechanisms(Some(left), Some(right)), + context, + ) + .unwrap_or_else(|error| panic!("invalid DEM noise configuration: {error}")); + for (mechanism, probability) in fit.mechanisms { + mem.add_mechanism(mechanism, probability); + } + if let Some((mechanism, magnitude)) = fit.residual { + mem.add_idle_noise_residual(MeasurementNoiseChannelResidual { + location_index: u32::try_from(loc_idx) + .expect("fault-location index must fit in residual metadata"), + channel_kind, + mechanism, + magnitude, + }); + } } fn compute_mechanism(&self, loc_idx: usize, pauli: Pauli) -> MeasurementMechanism { diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 9ec0d40d0..40758639e 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1525,7 +1525,7 @@ pub(crate) fn compute_location_probs_from_noise( | GateType::RZZ => noise.p2_rate_for_gate(loc.gate_type), GateType::Idle => { if noise.uses_dedicated_idle_noise() { - let duration = loc.idle_duration.max(0.0); + let duration = loc.idle_duration; noise.idle_pauli_probs(duration).total() } else { 0.0 diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index b65f8f351..80959a07c 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -113,6 +113,10 @@ pub enum FaultSourceType { /// rendered DEM behavior. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DirectSourceFamily { + /// Independent mechanism obtained from a categorical channel's flip signature. + /// No Pauli label is attached because signature aliases are merged first. + ExclusiveSignature, + /// Single-location direct source without a Y Pauli label. SingleLocation, @@ -162,6 +166,9 @@ pub struct FaultContribution { pub location_indices: SmallVec<[u32; 2]>, /// Original Pauli channel at each tracked location. + /// + /// This is empty for exclusive-signature mechanisms, which are defined only + /// after equal Pauli effects have been merged. pub paulis: SmallVec<[Pauli; 2]>, /// Gate type at each tracked source location. @@ -317,7 +324,12 @@ impl FaultContribution { probability: f64, source: SourceMetadata<'_, u32>, ) -> Self { - debug_assert_eq!(source.location_indices.len(), source.paulis.len()); + debug_assert!( + source.location_indices.len() == source.paulis.len() + || (source.paulis.is_empty() + && source.direct_source_family_override + == Some(DirectSourceFamily::ExclusiveSignature)) + ); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); Self { @@ -2448,6 +2460,27 @@ pub fn omitted_two_qubit_gate_pauli_twirl( Some(entries.iter().copied().collect()) } +/// Rate and per-Pauli weights for one dedicated idle-noise family. +/// +/// `weights` may contain only `"X"`, `"Y"`, and `"Z"`. An empty map means +/// equal unit weight on all three axes. A zero `rate` disables the family +/// regardless of the map contents. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct IdleNoiseFamily { + /// Common family rate. + pub rate: f64, + /// Per-axis relative-rate weights. + pub weights: BTreeMap, +} + +impl IdleNoiseFamily { + /// Creates an idle-noise family from a common rate and per-axis weights. + #[must_use] + pub fn new(rate: f64, weights: BTreeMap) -> Self { + Self { rate, weights } + } +} + /// Noise model configuration for circuit-level fault analysis. #[derive(Debug, Clone)] pub struct NoiseConfig { @@ -2473,8 +2506,9 @@ pub struct NoiseConfig { pub p_prep: f64, /// Idle gate error rate per time unit. /// - /// The actual error probability for an idle gate is `p_idle * duration` - /// (clamped to [0, 1]), where `duration` is the gate's `TimeUnits` value. + /// The actual error probability for an idle gate is `p_idle * duration`, + /// where `duration` is the gate's `TimeUnits` value. Values outside the + /// probability range are rejected during model construction. /// Default is 0.0 (no idle noise). pub p_idle: f64, /// Optional T1 relaxation time (in the same time units as idle duration). @@ -2506,42 +2540,21 @@ pub struct NoiseConfig { /// /// This is the EEG H-type noise model for idle gates. Default is 0.0. pub idle_rz: f64, - /// Stochastic Z-memory error rate linear in idle duration. - /// - /// This mirrors PECOS engine idle memory noise in a DEM-compatible Pauli - /// channel: each explicit `Idle(duration, q)` contributes an independent - /// Z fault with probability `p_idle_linear_rate * duration`. - /// - /// This is the legacy Z-axis alias for `p_idle_z_linear_rate`. - pub p_idle_linear_rate: f64, - /// Stochastic Z-memory error rate for the quadratic idle term. - /// - /// Each explicit `Idle(duration, q)` contributes a Z-fault probability - /// term `p_idle_quadratic_rate * duration^2`. + /// Categorical Pauli-memory family linear in idle duration. /// - /// This is the legacy Z-axis alias for `p_idle_z_quadratic_rate`. - pub p_idle_quadratic_rate: f64, - /// Stochastic Z-memory sine-law rate for the quadratic idle term. + /// Axis `P` has probability `rate * weights[P] * duration`. The family is + /// converted to independent DEM mechanisms only after equal propagated + /// signatures have been collected. + pub p_idle_linear: IdleNoiseFamily, + /// Independent Pauli-memory family quadratic in idle duration. /// - /// Each explicit `Idle(duration, q)` contributes a Z-fault probability - /// term `sin(p_idle_quadratic_sine_rate * duration)^2`. This preserves - /// the small-duration quadratic behavior of coherent dephasing models - /// without changing the coefficient-style `p_idle_quadratic_rate` API. + /// The common rate has units of inverse time squared. Axis `P` has + /// probability `rate * weights[P] * duration^2`. + pub p_idle_quadratic: IdleNoiseFamily, + /// Independent sine-squared Pauli-memory family. /// - /// This is the legacy Z-axis alias for `p_idle_z_quadratic_sine_rate`. - pub p_idle_quadratic_sine_rate: f64, - /// Stochastic X-memory error rate linear in idle duration. - pub p_idle_x_linear_rate: f64, - /// Stochastic Y-memory error rate linear in idle duration. - pub p_idle_y_linear_rate: f64, - /// Stochastic X-memory error rate quadratic in idle duration. - pub p_idle_x_quadratic_rate: f64, - /// Stochastic Y-memory error rate quadratic in idle duration. - pub p_idle_y_quadratic_rate: f64, - /// Stochastic X-memory sine-law rate for the quadratic idle term. - pub p_idle_x_quadratic_sine_rate: f64, - /// Stochastic Y-memory sine-law rate for the quadratic idle term. - pub p_idle_y_quadratic_sine_rate: f64, + /// Axis `P` has probability `sin(rate * weights[P] * duration)^2`. + pub p_idle_quadratic_sine: IdleNoiseFamily, /// Per-payload local measurement-crosstalk event rate. /// /// This rate is multiplied by the selected hidden-measurement transition @@ -2642,7 +2655,7 @@ impl Default for MeasurementCrosstalkTransitionModel { } /// Per-Pauli error probabilities for a single qubit. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] pub struct PauliProbs { /// Probability of X error. pub px: f64, @@ -2652,6 +2665,445 @@ pub struct PauliProbs { pub pz: f64, } +#[derive(Debug, Clone, Default)] +pub(crate) struct IdleChannelFamilies { + /// Categorical Pauli channels whose equal signatures must be summed. + pub(crate) exclusive: SmallVec<[PauliProbs; 2]>, + /// Independent per-axis mechanism triples. + pub(crate) independent: SmallVec<[PauliProbs; 2]>, +} + +/// An invalid noise-channel configuration for DEM construction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoiseChannelError { + message: String, +} + +impl NoiseChannelError { + fn new(message: String) -> Self { + Self { message } + } +} + +impl fmt::Display for NoiseChannelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for NoiseChannelError {} + +/// Kind of categorical noise channel converted to independent DEM mechanisms. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum NoiseChannelKind { + /// Dedicated idle-gate noise. + Idle, + /// A single-qubit gate Pauli channel. + SingleQubitGate, + /// A two-qubit gate Pauli channel. + TwoQubitGate, +} + +impl NoiseChannelKind { + /// Stable user-facing label used by residual diagnostics. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::SingleQubitGate => "one-qubit gate", + Self::TwoQubitGate => "two-qubit gate", + } + } +} + +/// The non-negative boundary fit used for an infeasible signature channel. +/// +/// `magnitude` is the total-variation distance between the categorical target +/// channel and the emitted independent channel. For the two-dimensional +/// boundary used by idle and gate channels, `effect` receives that same excess +/// probability and identity has the matching deficit. +#[derive(Debug, Clone, PartialEq)] +pub struct NoiseChannelResidual { + /// Fault-location index whose channel required approximation. + pub location_index: u32, + /// Kind of channel that required approximation. + pub channel_kind: NoiseChannelKind, + /// Flip signature with the largest non-identity discrepancy. + pub effect: FaultMechanism, + /// Total-variation distance from the requested categorical channel. + pub magnitude: f64, + /// Total non-identity probability of the requested categorical channel. + pub channel_weight: f64, +} + +impl NoiseChannelResidual { + /// Returns the residual as a fraction of the requested channel's error weight. + /// + /// # Panics + /// + /// Panics if `channel_weight` is not finite and positive. Such a channel + /// cannot produce a residual, so this indicates a broken construction invariant. + #[must_use] + pub fn relative_magnitude(&self) -> f64 { + assert!( + self.channel_weight.is_finite() && self.channel_weight > 0.0, + "noise-channel residual invariant violated: channel_weight must be finite and positive" + ); + self.magnitude / self.channel_weight + } +} + +#[derive(Debug, Clone)] +pub(crate) struct IndependentSignatureFit { + pub(crate) mechanisms: BTreeMap, + pub(crate) residual: Option<(Signature, f64)>, +} + +pub(crate) fn validate_exclusive_probabilities( + probabilities: &[f64], + context: &str, +) -> Result<(), NoiseChannelError> { + let total: f64 = probabilities.iter().sum(); + if probabilities + .iter() + .any(|probability| !probability.is_finite() || !(0.0..=1.0).contains(probability)) + || !total.is_finite() + || !(0.0..=1.0).contains(&total) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} categorical probabilities {probabilities:?} with total {total}; every probability and their total must be finite and lie in [0, 1]" + ))); + } + Ok(()) +} + +pub(crate) fn validate_idle_probabilities( + probabilities: PauliProbs, + context: &str, +) -> Result<(), NoiseChannelError> { + let identity = 1.0 - probabilities.total(); + if !probabilities.px.is_finite() + || !probabilities.py.is_finite() + || !probabilities.pz.is_finite() + || !identity.is_finite() + || !(0.0..=1.0).contains(&probabilities.px) + || !(0.0..=1.0).contains(&probabilities.py) + || !(0.0..=1.0).contains(&probabilities.pz) + || !(0.0..=1.0).contains(&identity) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} idle channel probabilities [I={identity}, X={}, Y={}, Z={}]; every probability must be finite and lie in [0, 1]", + probabilities.px, probabilities.py, probabilities.pz + ))); + } + Ok(()) +} + +fn validate_independent_idle_probabilities( + probabilities: PauliProbs, + context: &str, +) -> Result<(), NoiseChannelError> { + if [probabilities.px, probabilities.py, probabilities.pz] + .into_iter() + .any(|probability| !probability.is_finite() || !(0.0..=1.0).contains(&probability)) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} idle mechanism probabilities [X={}, Y={}, Z={}]; every probability must be finite and lie in [0, 1]", + probabilities.px, probabilities.py, probabilities.pz + ))); + } + Ok(()) +} + +/// Fit one categorical channel over distinct non-empty XOR signatures with +/// independent Bernoulli mechanisms. +/// +/// With zero or one signature the categorical channel is already an exact DEM. +/// The distinct signatures are assigned deterministic coordinates in their +/// GF(2) span. The target channel's characters are transformed into independent +/// mechanism rates with a Walsh-Hadamard solve. The original closed form is +/// retained for dimension two so existing idle DEM numbers remain byte-exact. +pub(crate) fn fit_exclusive_signatures( + exclusive: BTreeMap, + xor: Xor, + context: &str, +) -> Result, NoiseChannelError> +where + Signature: Clone + Ord, + Xor: Fn(&Signature, &Signature) -> Signature, +{ + let total: f64 = exclusive.values().sum(); + if exclusive + .values() + .any(|probability| !probability.is_finite() || !(0.0..=1.0).contains(probability)) + || !total.is_finite() + || !(0.0..=1.0).contains(&total) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} signature probabilities with total {total}; every probability and their total must be finite and lie in [0, 1]" + ))); + } + + if exclusive.len() <= 1 { + return Ok(IndependentSignatureFit { + mechanisms: exclusive, + residual: None, + }); + } + + let mut coordinates = BTreeMap::::new(); + let mut dimension = 0usize; + for signature in exclusive.keys() { + if coordinates.contains_key(signature) { + continue; + } + let coordinate = 1usize << dimension; + let additions = coordinates + .iter() + .map(|(held, &held_coordinate)| (xor(signature, held), coordinate | held_coordinate)) + .collect::>(); + coordinates.insert(signature.clone(), coordinate); + coordinates.extend(additions); + dimension += 1; + } + + if dimension == 2 { + return fit_exclusive_dimension_two(&exclusive, xor, context); + } + + let size = 1usize << dimension; + debug_assert_eq!(coordinates.len(), size - 1); + let mut signatures = vec![None; size]; + for (signature, coordinate) in &coordinates { + signatures[*coordinate] = Some(signature.clone()); + } + + let mut target = vec![0.0; size]; + target[0] = 1.0 - total; + for (signature, probability) in &exclusive { + target[coordinates[signature]] = *probability; + } + + let mut characters = vec![0.0; size]; + for (dual, character) in characters.iter_mut().enumerate() { + *character = target + .iter() + .enumerate() + .map(|(coordinate, probability)| { + if (dual & coordinate).count_ones().is_multiple_of(2) { + *probability + } else { + -*probability + } + }) + .sum(); + } + if characters + .iter() + .any(|character| !character.is_finite() || *character <= 0.0) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} signature channel: characters {characters:?} must all be positive" + ))); + } + + let log_characters = characters + .iter() + .map(|character| character.ln()) + .collect::>(); + let size_f64 = f64::from(u32::try_from(size).expect("signature-space size must fit in u32")); + let inverse_scale = -2.0 / size_f64; + let mut probabilities = vec![0.0; size]; + for (coordinate, probability) in probabilities.iter_mut().enumerate().skip(1) { + let transform: f64 = log_characters + .iter() + .enumerate() + .map(|(dual, log_character)| { + if (dual & coordinate).count_ones().is_multiple_of(2) { + *log_character + } else { + -*log_character + } + }) + .sum(); + let log_term = inverse_scale * transform; + *probability = (1.0 - log_term.exp()) / 2.0; + } + + if probabilities[1..] + .iter() + .all(|probability| probability.is_finite() && (0.0..=0.5).contains(probability)) + { + let mechanisms = (1..size) + .filter(|coordinate| probabilities[*coordinate] > 0.0) + .map(|coordinate| { + ( + signatures[coordinate] + .clone() + .expect("every nonzero span coordinate has a signature"), + probabilities[coordinate], + ) + }) + .collect(); + return Ok(IndependentSignatureFit { + mechanisms, + residual: None, + }); + } + + for probability in &mut probabilities[1..] { + *probability = if probability.is_finite() { + probability.clamp(0.0, 0.5) + } else if probability.is_sign_negative() { + 0.0 + } else { + 0.5 + }; + } + let mut fitted = vec![0.0; size]; + fitted[0] = 1.0; + for (coordinate, &probability) in probabilities.iter().enumerate().skip(1) { + if probability == 0.0 { + continue; + } + let previous = fitted.clone(); + for effect in 0..size { + fitted[effect] = previous[effect] * (1.0 - probability) + + previous[effect ^ coordinate] * probability; + } + } + let magnitude = fitted + .iter() + .zip(&target) + .map(|(actual, expected)| (actual - expected).abs()) + .sum::() + / 2.0; + let representative = (1..size) + .max_by(|left, right| { + (fitted[*left] - target[*left]) + .abs() + .total_cmp(&(fitted[*right] - target[*right]).abs()) + }) + .expect("a nontrivial span has a nonzero coordinate"); + let mechanisms = (1..size) + .filter(|coordinate| probabilities[*coordinate] > 0.0) + .map(|coordinate| { + ( + signatures[coordinate] + .clone() + .expect("every nonzero span coordinate has a signature"), + probabilities[coordinate], + ) + }) + .collect(); + Ok(IndependentSignatureFit { + mechanisms, + residual: Some(( + signatures[representative] + .clone() + .expect("the representative coordinate has a signature"), + magnitude, + )), + }) +} + +fn fit_exclusive_dimension_two( + exclusive: &BTreeMap, + xor: Xor, + context: &str, +) -> Result, NoiseChannelError> +where + Signature: Clone + Ord, + Xor: Fn(&Signature, &Signature) -> Signature, +{ + let total: f64 = exclusive.values().sum(); + let mut signatures: Vec = exclusive.keys().cloned().collect(); + let mut target: Vec = exclusive.values().copied().collect(); + if signatures.len() == 2 { + signatures.push(xor(&signatures[0], &signatures[1])); + target.push(0.0); + } else { + debug_assert!(xor(&signatures[0], &signatures[1]) == signatures[2]); + } + + let identity = 1.0 - total; + let eigenvalues = [ + identity + target[0] - target[1] - target[2], + identity - target[0] + target[1] - target[2], + identity - target[0] - target[1] + target[2], + ]; + if eigenvalues + .iter() + .any(|eigenvalue| !eigenvalue.is_finite() || *eigenvalue <= 0.0) + { + return Err(NoiseChannelError::new(format!( + "invalid {context} signature channel: characters [{}, {}, {}] must all be positive", + eigenvalues[0], eigenvalues[1], eigenvalues[2] + ))); + } + + let exact = [ + (1.0 - (eigenvalues[1] * eigenvalues[2] / eigenvalues[0]).sqrt()) / 2.0, + (1.0 - (eigenvalues[0] * eigenvalues[2] / eigenvalues[1]).sqrt()) / 2.0, + (1.0 - (eigenvalues[0] * eigenvalues[1] / eigenvalues[2]).sqrt()) / 2.0, + ]; + let negative: Vec = exact + .iter() + .enumerate() + .filter_map(|(index, probability)| { + (!probability.is_finite() || *probability < 0.0).then_some(index) + }) + .collect(); + if negative.is_empty() { + let mechanisms = signatures + .into_iter() + .zip(exact) + .filter(|(_, probability)| *probability > 0.0) + .collect(); + return Ok(IndependentSignatureFit { + mechanisms, + residual: None, + }); + } + + debug_assert_eq!(negative.len(), 1); + let omitted = negative[0]; + let retained: Vec = (0..3).filter(|index| *index != omitted).collect(); + let [first, second] = retained.as_slice() else { + unreachable!("one omitted signature leaves exactly two retained signatures") + }; + let discriminant = + (1.0 - target[*first] - target[*second]).powi(2) - 4.0 * target[*first] * target[*second]; + debug_assert!(discriminant >= 0.0); + let root = discriminant.sqrt(); + let first_denominator = 1.0 + target[*first] - target[*second] + root; + let second_denominator = 1.0 + target[*second] - target[*first] + root; + let first_probability = if target[*first] == 0.0 { + 0.0 + } else { + 2.0 * target[*first] / first_denominator + }; + let second_probability = if target[*second] == 0.0 { + 0.0 + } else { + 2.0 * target[*second] / second_denominator + }; + let residual = first_probability * second_probability - target[omitted]; + debug_assert!(residual > 0.0); + + let mut mechanisms = BTreeMap::new(); + if first_probability > 0.0 { + mechanisms.insert(signatures[*first].clone(), first_probability); + } + if second_probability > 0.0 { + mechanisms.insert(signatures[*second].clone(), second_probability); + } + Ok(IndependentSignatureFit { + mechanisms, + residual: Some((signatures[omitted].clone(), residual)), + }) +} + impl PauliProbs { /// Total error probability (px + py + pz). #[must_use] @@ -2685,7 +3137,7 @@ impl PauliProbs { let px = gamma / 4.0; let py = gamma / 4.0; - let pz = (lambda_t2 / 2.0 - gamma / 4.0).max(0.0); + let pz = lambda_t2 / 2.0 - gamma / 4.0; Self { px, py, pz } } @@ -2707,15 +3159,9 @@ impl Default for NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2742,15 +3188,9 @@ impl NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2775,15 +3215,9 @@ impl NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2808,15 +3242,9 @@ impl NoiseConfig { p2_weights: None, p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, - p_idle_linear_rate: 0.0, - p_idle_quadratic_rate: 0.0, - p_idle_quadratic_sine_rate: 0.0, - p_idle_x_linear_rate: 0.0, - p_idle_y_linear_rate: 0.0, - p_idle_x_quadratic_rate: 0.0, - p_idle_y_quadratic_rate: 0.0, - p_idle_x_quadratic_sine_rate: 0.0, - p_idle_y_quadratic_sine_rate: 0.0, + p_idle_linear: IdleNoiseFamily::default(), + p_idle_quadratic: IdleNoiseFamily::default(), + p_idle_quadratic_sine: IdleNoiseFamily::default(), p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2831,61 +3259,24 @@ impl NoiseConfig { self } - /// Sets the linear stochastic Z-memory rate for explicit idle gates. - #[must_use] - pub fn set_idle_linear_rate(mut self, rate: f64) -> Self { - self.p_idle_linear_rate = rate.max(0.0); - self - } - - /// Sets the quadratic stochastic Z-memory rate for explicit idle gates. - #[must_use] - pub fn set_idle_quadratic_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_rate = rate.max(0.0); - self - } - - /// Sets the sine-law quadratic stochastic Z-memory rate for explicit idle gates. + /// Sets the categorical linear idle-noise family. #[must_use] - pub fn set_idle_quadratic_sine_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_sine_rate = rate.max(0.0); + pub fn set_idle_linear(mut self, family: IdleNoiseFamily) -> Self { + self.p_idle_linear = family; self } - /// Sets the linear stochastic Pauli-memory rates for explicit idle gates. + /// Sets the independent coefficient-quadratic idle-noise family. #[must_use] - pub fn set_idle_pauli_linear_rates(mut self, px_rate: f64, py_rate: f64, pz_rate: f64) -> Self { - self.p_idle_x_linear_rate = px_rate.max(0.0); - self.p_idle_y_linear_rate = py_rate.max(0.0); - self.p_idle_linear_rate = pz_rate.max(0.0); + pub fn set_idle_quadratic(mut self, family: IdleNoiseFamily) -> Self { + self.p_idle_quadratic = family; self } - /// Sets the quadratic stochastic Pauli-memory rates for explicit idle gates. + /// Sets the independent sine-squared idle-noise family. #[must_use] - pub fn set_idle_pauli_quadratic_rates( - mut self, - px_rate: f64, - py_rate: f64, - pz_rate: f64, - ) -> Self { - self.p_idle_x_quadratic_rate = px_rate.max(0.0); - self.p_idle_y_quadratic_rate = py_rate.max(0.0); - self.p_idle_quadratic_rate = pz_rate.max(0.0); - self - } - - /// Sets the sine-law quadratic stochastic Pauli-memory rates for explicit idle gates. - #[must_use] - pub fn set_idle_pauli_quadratic_sine_rates( - mut self, - px_rate: f64, - py_rate: f64, - pz_rate: f64, - ) -> Self { - self.p_idle_x_quadratic_sine_rate = px_rate.max(0.0); - self.p_idle_y_quadratic_sine_rate = py_rate.max(0.0); - self.p_idle_quadratic_sine_rate = pz_rate.max(0.0); + pub fn set_idle_quadratic_sine(mut self, family: IdleNoiseFamily) -> Self { + self.p_idle_quadratic_sine = family; self } @@ -3054,59 +3445,203 @@ impl NoiseConfig { self } - fn idle_memory_probability( - linear_rate: f64, - quadratic_rate: f64, - quadratic_sine_rate: f64, + fn validate_idle_rates(family: &str, rates: PauliProbs) -> Result<(), NoiseChannelError> { + if !rates.px.is_finite() + || !rates.py.is_finite() + || !rates.pz.is_finite() + || rates.px < 0.0 + || rates.py < 0.0 + || rates.pz < 0.0 + { + return Err(NoiseChannelError::new(format!( + "invalid {family} idle rate/model [X={}, Y={}, Z={}]; rates must be finite and non-negative", + rates.px, rates.py, rates.pz + ))); + } + Ok(()) + } + + fn idle_family_rates( + family_name: &str, + family: &IdleNoiseFamily, + ) -> Result { + if family.rate == 0.0 { + return Ok(PauliProbs::default()); + } + + for key in family.weights.keys() { + if !matches!(key.as_str(), "X" | "Y" | "Z") { + return Err(NoiseChannelError::new(format!( + "invalid {family_name} idle rate/model key {key:?}; weights must use only X, Y, and Z" + ))); + } + } + let weights = if family.weights.is_empty() { + PauliProbs { + px: 1.0, + py: 1.0, + pz: 1.0, + } + } else { + PauliProbs { + px: family.weights.get("X").copied().unwrap_or(0.0), + py: family.weights.get("Y").copied().unwrap_or(0.0), + pz: family.weights.get("Z").copied().unwrap_or(0.0), + } + }; + let weighted_rate = |weight: f64| { + if weight == 0.0 { + 0.0 + } else { + family.rate * weight + } + }; + let rates = PauliProbs { + px: weighted_rate(weights.px), + py: weighted_rate(weights.py), + pz: weighted_rate(weights.pz), + }; + if !family.rate.is_finite() + || family.rate < 0.0 + || !weights.px.is_finite() + || weights.px < 0.0 + || !weights.py.is_finite() + || weights.py < 0.0 + || !weights.pz.is_finite() + || weights.pz < 0.0 + { + return Err(NoiseChannelError::new(format!( + "invalid {family_name} idle rate/model [X={}, Y={}, Z={}]; rates must be finite and non-negative", + rates.px, rates.py, rates.pz + ))); + } + Self::validate_idle_rates(family_name, rates)?; + Ok(rates) + } + + fn base_idle_pauli_probs(&self, duration: f64) -> Result { + if !duration.is_finite() || duration < 0.0 { + return Err(NoiseChannelError::new(format!( + "invalid idle duration {duration}; duration must be finite and non-negative" + ))); + } + if !self.p_idle.is_finite() || self.p_idle < 0.0 { + return Err(NoiseChannelError::new(format!( + "invalid uniform idle rate {}; rates must be finite and non-negative", + self.p_idle + ))); + } + let probabilities = if let (Some(t1), Some(t2)) = (self.t1, self.t2) { + if !t1.is_finite() || !t2.is_finite() || t1 <= 0.0 || t2 <= 0.0 || t2 > 2.0 * t1 { + return Err(NoiseChannelError::new(format!( + "invalid idle T1/T2 values [T1={t1}, T2={t2}]; both must be finite and positive, with T2 <= 2*T1" + ))); + } + PauliProbs::from_t1_t2(duration, t1, t2) + } else { + if self.t1.is_some() || self.t2.is_some() { + return Err(NoiseChannelError::new( + "invalid idle T1/T2 configuration; T1 and T2 must be supplied together" + .to_string(), + )); + } + PauliProbs::depolarizing(self.p_idle * duration) + }; + validate_idle_probabilities(probabilities, "base")?; + Ok(probabilities) + } + + pub(crate) fn try_idle_channel_families( + &self, duration: f64, - ) -> f64 { - let duration = duration.max(0.0); - let sine_angle = quadratic_sine_rate.max(0.0) * duration; - (linear_rate.max(0.0) * duration - + quadratic_rate.max(0.0) * duration * duration - + sine_angle.sin().powi(2)) - .clamp(0.0, 1.0) + ) -> Result { + let base = self.base_idle_pauli_probs(duration)?; + let linear_rates = Self::idle_family_rates("linear", &self.p_idle_linear)?; + let quadratic_rates = + Self::idle_family_rates("coefficient-quadratic", &self.p_idle_quadratic)?; + let sine_rates = Self::idle_family_rates("sine-squared", &self.p_idle_quadratic_sine)?; + + let duration_squared = duration * duration; + let linear = PauliProbs { + px: linear_rates.px * duration, + py: linear_rates.py * duration, + pz: linear_rates.pz * duration, + }; + validate_idle_probabilities(linear, "linear")?; + let quadratic = PauliProbs { + px: quadratic_rates.px * duration_squared, + py: quadratic_rates.py * duration_squared, + pz: quadratic_rates.pz * duration_squared, + }; + validate_independent_idle_probabilities(quadratic, "coefficient-quadratic")?; + let sine = PauliProbs { + px: (sine_rates.px * duration).sin().powi(2), + py: (sine_rates.py * duration).sin().powi(2), + pz: (sine_rates.pz * duration).sin().powi(2), + }; + validate_independent_idle_probabilities(sine, "sine-squared")?; + + let mut families = IdleChannelFamilies::default(); + if base.total() > 0.0 { + families.exclusive.push(base); + } + if linear.total() > 0.0 { + families.exclusive.push(linear); + } + if quadratic.total() > 0.0 { + families.independent.push(quadratic); + } + if sine.total() > 0.0 { + families.independent.push(sine); + } + Ok(families) } - /// Dedicated idle-memory Pauli probabilities for `Idle(duration, q)`. + /// Try to compute the effective Pauli channel of all dedicated + /// idle-memory terms. + /// + /// # Errors + /// + /// Returns an error for a negative/non-finite rate or duration, or when a + /// configured family produces an out-of-range probability. + pub fn try_idle_memory_pauli_probs( + &self, + duration: f64, + ) -> Result { + let families = self.try_idle_channel_families(duration)?; + let mut channel = PauliProbs::default(); + let base_is_present = self.base_idle_pauli_probs(duration)?.total() > 0.0; + for exclusive in families + .exclusive + .into_iter() + .skip(usize::from(base_is_present)) + { + channel = Self::compose_pauli_channel(channel, exclusive); + } + for independent in families.independent { + channel = Self::compose_independent_pauli_mechanisms(channel, independent); + } + Ok(channel) + } + + /// Dedicated idle-memory effective Pauli channel for `Idle(duration, q)`. + /// + /// # Panics + /// + /// Panics if an idle input is invalid or produces an out-of-range channel. #[must_use] pub fn idle_memory_pauli_probs(&self, duration: f64) -> PauliProbs { - let mut probs = PauliProbs { - px: Self::idle_memory_probability( - self.p_idle_x_linear_rate, - self.p_idle_x_quadratic_rate, - self.p_idle_x_quadratic_sine_rate, - duration, - ), - py: Self::idle_memory_probability( - self.p_idle_y_linear_rate, - self.p_idle_y_quadratic_rate, - self.p_idle_y_quadratic_sine_rate, - duration, - ), - pz: Self::idle_memory_probability( - self.p_idle_linear_rate, - self.p_idle_quadratic_rate, - self.p_idle_quadratic_sine_rate, - duration, - ), - }; - let total = probs.total(); - if total > 1.0 { - probs.px /= total; - probs.py /= total; - probs.pz /= total; - } - probs + self.try_idle_memory_pauli_probs(duration) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")) } fn compose_pauli_channel(probs: PauliProbs, channel: PauliProbs) -> PauliProbs { - if channel.total() <= f64::EPSILON { + if channel.total() == 0.0 { return probs; } - let p_identity = (1.0 - probs.total()).max(0.0); - let c_identity = (1.0 - channel.total()).max(0.0); + let p_identity = 1.0 - probs.total(); + let c_identity = 1.0 - channel.total(); PauliProbs { px: p_identity * channel.px + probs.px * c_identity @@ -3123,18 +3658,63 @@ impl NoiseConfig { } } + fn compose_independent_pauli_mechanisms( + mut channel: PauliProbs, + mechanisms: PauliProbs, + ) -> PauliProbs { + for mechanism in [ + PauliProbs { + px: mechanisms.px, + py: 0.0, + pz: 0.0, + }, + PauliProbs { + px: 0.0, + py: mechanisms.py, + pz: 0.0, + }, + PauliProbs { + px: 0.0, + py: 0.0, + pz: mechanisms.pz, + }, + ] { + channel = Self::compose_pauli_channel(channel, mechanism); + } + channel + } + /// Compute per-Pauli idle noise probabilities for a given duration. /// /// If T1/T2 are set, uses the Pauli-twirled model (biased noise). /// Otherwise, uses uniform depolarizing with `p_idle * duration`. + /// + /// # Panics + /// + /// Panics if an idle input is invalid or produces an out-of-range channel. #[must_use] pub fn idle_pauli_probs(&self, duration: f64) -> PauliProbs { - let probs = if let (Some(t1), Some(t2)) = (self.t1, self.t2) { - PauliProbs::from_t1_t2(duration, t1, t2) - } else { - PauliProbs::depolarizing((self.p_idle * duration).min(1.0)) - }; - Self::compose_pauli_channel(probs, self.idle_memory_pauli_probs(duration)) + self.try_idle_pauli_probs(duration) + .unwrap_or_else(|error| panic!("invalid DEM idle-noise configuration: {error}")) + } + + /// Try to compute the effective Pauli idle channel for a given duration. + /// + /// # Errors + /// + /// Returns an error for a negative/non-finite rate or duration, an invalid + /// T1/T2 pair, or an out-of-range categorical probability. + pub fn try_idle_pauli_probs(&self, duration: f64) -> Result { + let families = self.try_idle_channel_families(duration)?; + let mut channel = PauliProbs::default(); + for exclusive in families.exclusive { + channel = Self::compose_pauli_channel(channel, exclusive); + } + for independent in families.independent { + channel = Self::compose_independent_pauli_mechanisms(channel, independent); + } + validate_idle_probabilities(channel, "composed")?; + Ok(channel) } /// Returns true when idle locations use the dedicated idle-noise model. @@ -3142,17 +3722,12 @@ impl NoiseConfig { /// Otherwise `Idle` is a no-op for noise. #[must_use] pub fn uses_dedicated_idle_noise(&self) -> bool { - self.p_idle > 0.0 - || matches!((self.t1, self.t2), (Some(_), Some(_))) - || self.p_idle_linear_rate > 0.0 - || self.p_idle_quadratic_rate.abs() > f64::EPSILON - || self.p_idle_quadratic_sine_rate > 0.0 - || self.p_idle_x_linear_rate > 0.0 - || self.p_idle_y_linear_rate > 0.0 - || self.p_idle_x_quadratic_rate > 0.0 - || self.p_idle_y_quadratic_rate > 0.0 - || self.p_idle_x_quadratic_sine_rate > 0.0 - || self.p_idle_y_quadratic_sine_rate > 0.0 + self.p_idle != 0.0 + || self.t1.is_some() + || self.t2.is_some() + || self.p_idle_linear.rate != 0.0 + || self.p_idle_quadratic.rate != 0.0 + || self.p_idle_quadratic_sine.rate != 0.0 } } @@ -3899,6 +4474,19 @@ impl fmt::Debug for MeasurementMechanism { } } +/// A quantified categorical-channel approximation in raw-measurement space. +#[derive(Debug, Clone, PartialEq)] +pub struct MeasurementNoiseChannelResidual { + /// Fault-location index whose channel required approximation. + pub location_index: u32, + /// Kind of channel that required approximation. + pub channel_kind: NoiseChannelKind, + /// Raw-measurement flip signature with the largest discrepancy. + pub mechanism: MeasurementMechanism, + /// Total-variation distance from the requested categorical channel. + pub magnitude: f64, +} + /// A measurement noise model for fast approximate raw-measurement sampling. #[derive(Debug, Clone, Default)] pub struct MeasurementNoiseModel { @@ -3908,6 +4496,8 @@ pub struct MeasurementNoiseModel { pub num_measurements: usize, /// Optional mapping from influence-map index to original circuit order. pub im_to_tc_order: Option>, + /// Quantified approximations introduced by categorical signature conversion. + pub idle_noise_residuals: Vec, } impl MeasurementNoiseModel { @@ -3918,6 +4508,7 @@ impl MeasurementNoiseModel { mechanisms: BTreeMap::new(), num_measurements, im_to_tc_order: None, + idle_noise_residuals: Vec::new(), } } @@ -3952,6 +4543,10 @@ impl MeasurementNoiseModel { .or_insert(probability); } + pub(crate) fn add_idle_noise_residual(&mut self, residual: MeasurementNoiseChannelResidual) { + self.idle_noise_residuals.push(residual); + } + /// Samples measurement outcomes into a pre-sized buffer. pub fn sample_into(&self, outcomes: &mut [bool], rng: &mut R) { outcomes.fill(false); @@ -4132,6 +4727,8 @@ pub struct DetectorErrorModel { /// component effects are non-empty and graphlike (≤2 detectors). /// Used to determine output format: ≥2 → 3 forms, 1 → 2 forms, 0 → 1 form. graphlike_decomposable_counts: BTreeMap<(u32, u32), u32>, + /// Quantified approximations introduced by infeasible categorical signature channels. + idle_noise_residuals: Vec, } /// Structured DEM mechanism tuple: `(probability, detector_ids, observable_ids)`. @@ -4150,6 +4747,7 @@ impl DetectorErrorModel { tracked_paulis: Vec::new(), contributions: Vec::new(), graphlike_decomposable_counts: BTreeMap::new(), + idle_noise_residuals: Vec::new(), } } @@ -4162,6 +4760,7 @@ impl DetectorErrorModel { tracked_paulis: Vec::new(), contributions: Vec::new(), graphlike_decomposable_counts: BTreeMap::new(), + idle_noise_residuals: Vec::new(), } } @@ -4247,6 +4846,22 @@ impl DetectorErrorModel { self.contributions.len() } + /// Returns every quantified categorical-channel approximation made during build. + /// + /// Each record identifies the channel kind, a representative concrete flip + /// signature, the requested channel's total error weight, and the absolute + /// and relative total-variation residual magnitudes. + /// An empty slice means every categorical conversion was exact. + #[inline] + #[must_use] + pub fn idle_noise_residuals(&self) -> &[NoiseChannelResidual] { + &self.idle_noise_residuals + } + + pub(crate) fn add_idle_noise_residual(&mut self, residual: NoiseChannelResidual) { + self.idle_noise_residuals.push(residual); + } + /// Exports PECOS-only metadata that is not representable in standard DEM syntax. /// /// The standard DEM string remains decoder-compatible and uses ordinary @@ -4679,6 +5294,7 @@ impl DetectorErrorModel { fn direct_source_family_label(family: DirectSourceFamily) -> &'static str { match family { + DirectSourceFamily::ExclusiveSignature => "ExclusiveSignature", DirectSourceFamily::SingleLocation => "SingleLocation", DirectSourceFamily::SingleLocationY => "SingleLocationY", DirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", @@ -6407,6 +7023,92 @@ fn trim_trailing_zeros(s: &str) -> String { #[cfg(test)] mod tests { + + fn residual_with_channel_weight(channel_weight: f64) -> NoiseChannelResidual { + NoiseChannelResidual { + location_index: 0, + channel_kind: NoiseChannelKind::Idle, + effect: FaultMechanism::new(), + magnitude: 0.002, + channel_weight, + } + } + + #[test] + fn noise_channel_residual_reports_relative_magnitude() { + assert_eq!( + residual_with_channel_weight(0.02) + .relative_magnitude() + .to_bits(), + 0.1_f64.to_bits() + ); + } + + #[test] + fn noise_channel_residual_rejects_invalid_channel_weight() { + for channel_weight in [0.0, -0.01, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] { + let result = std::panic::catch_unwind(|| { + residual_with_channel_weight(channel_weight).relative_magnitude() + }); + assert!( + result.is_err(), + "channel weight {channel_weight:?} must violate the residual invariant" + ); + } + } + + /// The single-qubit gate channel needs conversion too, at its own scale. + /// + /// Three Paulis at `p1/3` with distinct signatures. The expected value was computed + /// independently from the Pauli-channel characters, not from this implementation. + #[test] + fn exclusive_fit_converts_the_single_qubit_gate_channel() { + let per_pauli = 0.002 / 3.0; + let mut exclusive = std::collections::BTreeMap::new(); + exclusive.insert(1u8, per_pauli); + exclusive.insert(2u8, per_pauli); + exclusive.insert(3u8, per_pauli); + + let fit = super::fit_exclusive_signatures(exclusive, |a: &u8, b: &u8| a ^ b, "test") + .expect("uniform three-signature channel is exactly representable"); + + assert!(fit.residual.is_none()); + for probability in fit.mechanisms.values() { + assert!( + (probability - 6.671_117_046_932e-4).abs() < 1e-15, + "got {probability}, expected the converted 6.671117046932e-4 rather than \ + the unconverted {per_pauli}", + ); + } + } + + /// The exclusive->independent fit must convert, not pass probabilities through. + /// + /// Three distinct signatures each carrying `4 * p2/15 = 5.333e-3` (the twelve + /// detectable two-qubit Paulis merging four-to-one). Independent mechanisms also + /// fire together, so each must be raised to 5.362e-3 for the composed channel to + /// equal the requested one. The expected value was computed independently from the + /// Pauli-channel characters, not from this implementation. + #[test] + fn exclusive_fit_raises_probabilities_to_offset_joint_firing() { + let group = 4.0 * 0.02 / 15.0; + let mut exclusive = std::collections::BTreeMap::new(); + exclusive.insert(1u8, group); + exclusive.insert(2u8, group); + exclusive.insert(3u8, group); + + let fit = super::fit_exclusive_signatures(exclusive, |a: &u8, b: &u8| a ^ b, "test") + .expect("uniform three-signature channel is exactly representable"); + + assert!(fit.residual.is_none(), "channel is exactly representable"); + for (signature, probability) in &fit.mechanisms { + assert!( + (probability - 5.362_085_292_012e-3).abs() < 1e-12, + "signature {signature} got {probability}, expected the converted 5.362085292012e-3 \ + rather than the unconverted {group}", + ); + } + } use super::*; #[test] diff --git a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs index 33fe9229b..eec29a7f6 100644 --- a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs +++ b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs @@ -102,7 +102,7 @@ impl LookupDecoder { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(0.0, |l| l.idle_duration.max(0.0)); + .map_or(0.0, |l| l.idle_duration); Some(noise.idle_pauli_probs(duration)) } else { None diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index 50100c2e2..b57965835 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -82,7 +82,7 @@ pub use distance::{ }; pub use fault_tolerance::dem_builder::{ DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, - FaultMechanism, NoiseConfig, PecosDemMetadataError, combine_probabilities, + FaultMechanism, IdleNoiseFamily, NoiseConfig, PecosDemMetadataError, combine_probabilities, }; pub use fault_tolerance::{ CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, ErrorClass, diff --git a/crates/pecos-qec/tests/gate_channel_conversion_tests.rs b/crates/pecos-qec/tests/gate_channel_conversion_tests.rs new file mode 100644 index 000000000..2c69b33a2 --- /dev/null +++ b/crates/pecos-qec/tests/gate_channel_conversion_tests.rs @@ -0,0 +1,369 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Regression tests for categorical gate-Pauli channels converted to independent +//! DEM mechanisms after propagation to concrete flip signatures. + +use pecos_core::QubitId; +use pecos_qec::fault_tolerance::dem_builder::{ + DemBuilder, DetectorErrorModel, DirectSourceFamily, FaultMechanism, MeasurementNoiseModel, + MemBuilder, NoiseChannelKind, NoiseConfig, PerGateTypeNoise, combine_probabilities, +}; +use pecos_qec::fault_tolerance::propagator::{DagFaultInfluenceMap, DagSpacetimeLocation}; +use pecos_quantum::GateType; + +const FOUR_DETECTORS: &str = r#"[ + {"id": 0, "records": [-4]}, + {"id": 1, "records": [-3]}, + {"id": 2, "records": [-2]}, + {"id": 3, "records": [-1]} +]"#; + +fn synthetic_one_qubit_influence( + gate_type: GateType, + before: bool, + x: &[u32], + y: &[u32], + z: &[u32], +) -> DagFaultInfluenceMap { + let mut influence = DagFaultInfluenceMap::with_capacity(1); + influence.locations.push(DagSpacetimeLocation { + node: 0, + qubits: vec![QubitId::from(0usize)], + before, + gate_type, + idle_duration: 0.0, + }); + influence.influences.detectors_x.extend(x.iter().copied()); + influence.influences.detectors_y.extend(y.iter().copied()); + influence.influences.detectors_z.extend(z.iter().copied()); + influence.influences.finish_location(); + influence.measurements = (0..4).map(|index| (index, index, 0)).collect(); + influence +} + +fn synthetic_two_qubit_influence() -> DagFaultInfluenceMap { + let mut influence = DagFaultInfluenceMap::with_capacity(2); + for (qubit, x, y, z) in [ + (0, &[0][..], &[0, 1][..], &[1][..]), + (1, &[2][..], &[2, 3][..], &[3][..]), + ] { + influence.locations.push(DagSpacetimeLocation { + node: 0, + qubits: vec![QubitId::from(qubit)], + before: false, + gate_type: GateType::CX, + idle_duration: 0.0, + }); + influence.influences.detectors_x.extend(x.iter().copied()); + influence.influences.detectors_y.extend(y.iter().copied()); + influence.influences.detectors_z.extend(z.iter().copied()); + influence.influences.finish_location(); + } + influence.measurements = (0..4).map(|index| (index, index, 0)).collect(); + influence +} + +fn independent_distribution(model: &MeasurementNoiseModel, dimension: usize) -> Vec { + let mechanisms = model.mechanisms.iter().map(|(mechanism, &probability)| { + let mask = mechanism + .measurements + .iter() + .fold(0usize, |mask, &index| mask ^ (1usize << index)); + (mask, probability) + }); + compose_independent_mechanisms(mechanisms, dimension) +} + +fn fault_mechanism_mask(mechanism: &FaultMechanism) -> usize { + mechanism + .detectors + .iter() + .fold(0usize, |mask, &index| mask ^ (1usize << index)) +} + +fn compose_independent_mechanisms( + mechanisms: impl IntoIterator, + dimension: usize, +) -> Vec { + let size = 1usize << dimension; + let mut distribution = vec![0.0; size]; + distribution[0] = 1.0; + for (mask, probability) in mechanisms { + let previous = distribution.clone(); + for effect in 0..size { + distribution[effect] = + previous[effect] * (1.0 - probability) + previous[effect ^ mask] * probability; + } + } + distribution +} + +fn build_synthetic_dem(influence: &DagFaultInfluenceMap, noise: NoiseConfig) -> DetectorErrorModel { + DemBuilder::new(influence) + .with_noise_config(noise) + .with_detectors_json(FOUR_DETECTORS) + .expect("valid detector metadata") + .try_build() + .expect("valid categorical channel") +} + +fn build_synthetic_per_gate_dem( + influence: &DagFaultInfluenceMap, + gate_type: GateType, + rates: [f64; 3], +) -> DetectorErrorModel { + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_1q_rates(gate_type, rates); + DemBuilder::new(influence) + .with_per_gate_noise(noise) + .with_detectors_json(FOUR_DETECTORS) + .expect("valid detector metadata") + .try_build() + .expect("valid categorical channel") +} + +fn gate_signature_mechanisms(dem: &DetectorErrorModel) -> Vec<(FaultMechanism, f64)> { + dem.contribution_render_records() + .into_iter() + .map(|record| { + let contribution = record.contribution; + assert!(contribution.paulis.is_empty()); + assert_eq!( + contribution.direct_source_family, + Some(DirectSourceFamily::ExclusiveSignature) + ); + (contribution.effect, contribution.probability) + }) + .collect() +} + +#[test] +fn single_qubit_gate_mechanisms_compose_to_the_three_pauli_channel() { + let p1 = 0.002; + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[0, 1], &[1]); + let model = MemBuilder::new(&influence) + .with_noise_config(NoiseConfig::new(p1, 0.0, 0.0, 0.0)) + .build(); + let distribution = independent_distribution(&model, 2); + let target = p1 / 3.0; + + assert_eq!(model.mechanisms.len(), 3); + for &probability in model.mechanisms.values() { + assert!((probability - 0.000_667_111_704_693_190_7).abs() < 1e-15); + } + assert!((distribution[0] - (1.0 - p1)).abs() < 1e-12); + for effect_probability in &distribution[1..] { + assert!((effect_probability - target).abs() < 1e-12); + } + + let dem = build_synthetic_dem(&influence, NoiseConfig::new(p1, 0.0, 0.0, 0.0)); + let mechanisms = gate_signature_mechanisms(&dem); + assert_eq!(mechanisms.len(), 3); + assert!(dem.idle_noise_residuals().is_empty()); + let dem_distribution = compose_independent_mechanisms( + mechanisms + .iter() + .map(|(effect, probability)| (fault_mechanism_mask(effect), *probability)), + 2, + ); + for (actual, expected) in dem_distribution + .iter() + .zip([1.0 - p1, target, target, target]) + { + assert!( + (actual - expected).abs() < 1e-12, + "distribution={dem_distribution:?}, mechanisms={mechanisms:?}" + ); + } +} + +#[test] +fn two_qubit_gate_mechanisms_compose_to_the_fifteen_pauli_channel() { + let p2 = 0.02; + let influence = synthetic_two_qubit_influence(); + let model = MemBuilder::new(&influence) + .with_noise_config(NoiseConfig::new(0.0, p2, 0.0, 0.0)) + .build(); + let distribution = independent_distribution(&model, 4); + let target = p2 / 15.0; + + assert_eq!(model.mechanisms.len(), 15); + for &probability in model.mechanisms.values() { + assert!((probability - 0.001_345_946_290_707_722_4).abs() < 1e-15); + } + assert!((distribution[0] - (1.0 - p2)).abs() < 1e-12); + for effect_probability in &distribution[1..] { + assert!((effect_probability - target).abs() < 1e-12); + } + + let dem = build_synthetic_dem(&influence, NoiseConfig::new(0.0, p2, 0.0, 0.0)); + let mechanisms = gate_signature_mechanisms(&dem); + assert_eq!(mechanisms.len(), 15); + assert!(dem.idle_noise_residuals().is_empty()); + let dem_distribution = compose_independent_mechanisms( + mechanisms + .iter() + .map(|(effect, probability)| (fault_mechanism_mask(effect), *probability)), + 4, + ); + assert!( + (dem_distribution[0] - (1.0 - p2)).abs() < 1e-12, + "distribution={dem_distribution:?}, mechanisms={mechanisms:?}" + ); + for effect_probability in &dem_distribution[1..] { + assert!((effect_probability - target).abs() < 1e-12); + } +} + +#[test] +fn equal_gate_signatures_sum_before_independent_merging() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[], &[0]); + let dem = build_synthetic_per_gate_dem(&influence, GateType::H, [0.2, 0.0, 0.3]); + let mechanisms = gate_signature_mechanisms(&dem); + + assert_eq!(mechanisms.len(), 1); + assert_eq!(mechanisms[0].1.to_bits(), 0.5_f64.to_bits()); + assert_ne!( + mechanisms[0].1.to_bits(), + combine_probabilities(0.2, 0.3).to_bits() + ); + assert!(dem.idle_noise_residuals().is_empty()); +} + +#[test] +fn vanishing_gate_signatures_drop_without_changing_survivors() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[], &[1], &[1]); + let dem = build_synthetic_per_gate_dem(&influence, GateType::H, [0.2, 0.3, 0.4]); + let mechanisms = gate_signature_mechanisms(&dem); + + assert_eq!(mechanisms.len(), 1); + assert!((mechanisms[0].1 - 0.7).abs() < 1e-15); + assert!(dem.idle_noise_residuals().is_empty()); +} + +#[test] +fn prep_and_measurement_channels_remain_single_exact_mechanisms() { + for (gate_type, before, noise, expected) in [ + ( + GateType::PZ, + false, + NoiseConfig::new(0.0, 0.0, 0.0, 0.25), + 0.25_f64, + ), + ( + GateType::MZ, + true, + NoiseConfig::new(0.0, 0.0, 0.375, 0.0), + 0.375_f64, + ), + ] { + let influence = synthetic_one_qubit_influence(gate_type, before, &[0], &[], &[]); + let model = MemBuilder::new(&influence) + .with_noise_config(noise.clone()) + .build(); + assert_eq!(model.mechanisms.len(), 1); + assert_eq!( + model + .mechanisms + .values() + .next() + .expect("single prep or measurement mechanism") + .to_bits(), + expected.to_bits() + ); + assert!(model.idle_noise_residuals.is_empty()); + + let dem = build_synthetic_dem(&influence, noise); + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].contribution.probability.to_bits(), + expected.to_bits() + ); + assert!(dem.idle_noise_residuals().is_empty()); + } +} + +#[test] +fn infeasible_gate_channel_reports_kind_and_queryable_magnitude() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[0, 1], &[1]); + let dem = build_synthetic_per_gate_dem(&influence, GateType::H, [0.0075, 0.0, 0.0225]); + + let [residual] = dem.idle_noise_residuals() else { + panic!("the infeasible gate channel must report one residual") + }; + assert_eq!(residual.channel_kind, NoiseChannelKind::SingleQubitGate); + assert_eq!(fault_mechanism_mask(&residual.effect), 0b11); + assert_eq!(residual.channel_weight.to_bits(), 0.03_f64.to_bits()); + assert!(residual.magnitude > 0.0); + assert_eq!( + residual.relative_magnitude().to_bits(), + (residual.magnitude / 0.03).to_bits() + ); + let mechanisms = gate_signature_mechanisms(&dem); + let distribution = compose_independent_mechanisms( + mechanisms + .iter() + .map(|(effect, probability)| (fault_mechanism_mask(effect), *probability)), + 2, + ); + assert!((distribution[0b11] - residual.magnitude).abs() < 1e-12); +} + +#[test] +fn broken_gate_probabilities_and_nonpositive_characters_are_hard_errors() { + let influence = synthetic_one_qubit_influence(GateType::H, false, &[0], &[0, 1], &[1]); + let build = |rates| { + DemBuilder::new(&influence) + .with_per_gate_noise( + PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_1q_rates(GateType::H, rates), + ) + .with_detectors_json(FOUR_DETECTORS) + .expect("valid detector metadata") + .try_build() + }; + + let probability_error = build([0.6, 0.6, 0.0]) + .expect_err("a categorical total above one must be rejected") + .to_string(); + assert!(probability_error.contains("one-qubit H gate")); + assert!(probability_error.contains("total 1.2")); + assert!(probability_error.contains("must be finite and lie in [0, 1]")); + + for rates in [[-0.1, 0.1, 0.0], [f64::NAN, 0.0, 0.0]] { + let probability_error = build(rates) + .expect_err("non-finite and out-of-range probabilities must be rejected") + .to_string(); + assert!(probability_error.contains("one-qubit H gate")); + assert!(probability_error.contains("must be finite and lie in [0, 1]")); + } + + let character_error = build([0.25, 0.0, 0.25]) + .expect_err("a zero signature character must be rejected") + .to_string(); + assert!(character_error.contains("one-qubit H gate")); + assert!(character_error.contains("characters")); + assert!(character_error.contains("must all be positive")); +} + +#[test] +fn identical_gate_configuration_produces_byte_identical_dem_text() { + let influence = synthetic_two_qubit_influence(); + let build = + || build_synthetic_dem(&influence, NoiseConfig::new(0.0, 0.02, 0.0, 0.0)).to_string(); + let expected = build(); + for _ in 0..16 { + assert_eq!(build().as_bytes(), expected.as_bytes()); + } +} diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 9f9af638a..6d0292010 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -14,12 +14,38 @@ //! noise is explicitly attached to idle locations via dedicated idle noise or //! per-gate idle rates. +use pecos_core::pauli::{X, Y, Z}; use pecos_core::{QubitId, TimeUnits}; use pecos_qec::fault_tolerance::dem_builder::{ - DemBuilder, DemSamplerBuilder, NoiseConfig, PerGateTypeNoise, + DemBuilder, DemSamplerBuilder, DetectorErrorModel, FaultMechanism, IdleNoiseFamily, MemBuilder, + NoiseConfig, PauliProbs, PerGateTypeNoise, SamplingEngine, combine_probabilities, +}; +use pecos_qec::fault_tolerance::propagator::{ + DagFaultAnalyzer, DagFaultInfluenceMap, DagSpacetimeLocation, DetectorId, MeasurementId, Pauli, }; -use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::{DagCircuit, GateType}; +use std::collections::BTreeMap; + +fn idle_family( + rate: f64, + weights: impl IntoIterator, +) -> IdleNoiseFamily { + IdleNoiseFamily::new( + rate, + weights + .into_iter() + .map(|(axis, weight)| (axis.to_string(), weight)) + .collect(), + ) +} + +fn z_idle_family(rate: f64) -> IdleNoiseFamily { + idle_family(rate, [("Z", 1.0)]) +} + +fn axis_rate_family(px: f64, py: f64, pz: f64) -> IdleNoiseFamily { + idle_family(1.0, [("X", px), ("Y", py), ("Z", pz)]) +} fn build_idle_then_measure(num_idles: usize) -> DagCircuit { // Prep N qubits, idle each once, measure each. Very simple fixture @@ -47,6 +73,158 @@ fn build_nanosecond_idle_x_basis_measure() -> DagCircuit { dag } +fn build_unit_idle_with_pauli_tracking() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.idle(TimeUnits::new(1), &[0]); + dag.tracked_pauli_labeled("tracked_x", X(0)); + dag.tracked_pauli_labeled("tracked_y", Y(0)); + dag.tracked_pauli_labeled("tracked_z", Z(0)); + dag.mz(&[0]); + dag +} + +fn build_unit_idle_tracking_x() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.idle(TimeUnits::new(1), &[0]); + dag.tracked_pauli_labeled("tracked_x", X(0)); + dag.mz(&[0]); + dag +} + +fn build_unit_idle_tracking_y() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.idle(TimeUnits::new(1), &[0]); + dag.tracked_pauli_labeled("tracked_y", Y(0)); + dag.mz(&[0]); + dag +} + +fn build_tracked_idle_dem(noise: NoiseConfig) -> Result { + let dag = build_unit_idle_with_pauli_tracking(); + DemBuilder::try_from_circuit_with_noise_config(&dag, noise).map_err(|error| error.to_string()) +} + +fn synthetic_idle_influence( + x_signature: &[u32], + y_signature: &[u32], + z_signature: &[u32], +) -> DagFaultInfluenceMap { + let mut influence = DagFaultInfluenceMap::with_capacity(1); + influence.locations.push(DagSpacetimeLocation { + node: 0, + qubits: vec![QubitId::from(0usize)], + before: false, + gate_type: GateType::Idle, + idle_duration: 1.0, + }); + influence + .influences + .detectors_x + .extend(x_signature.iter().copied()); + influence + .influences + .detectors_y + .extend(y_signature.iter().copied()); + influence + .influences + .detectors_z + .extend(z_signature.iter().copied()); + influence.influences.finish_location(); + influence.measurements = vec![(0, 0, 0), (1, 0, 0)]; + influence +} + +fn build_synthetic_idle_dem( + influence: &DagFaultInfluenceMap, + noise: NoiseConfig, +) -> Result { + DemBuilder::new(influence) + .with_noise_config(noise) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .map_err(|error| error.to_string())? + .try_build() + .map_err(|error| error.to_string()) +} + +fn compose_xyz_mechanisms(mechanisms: PauliProbs) -> [f64; 4] { + let PauliProbs { px, py, pz } = mechanisms; + [ + (1.0 - px) * (1.0 - py) * (1.0 - pz) + px * py * pz, + px * (1.0 - py) * (1.0 - pz) + (1.0 - px) * py * pz, + (1.0 - px) * py * (1.0 - pz) + px * (1.0 - py) * pz, + (1.0 - px) * (1.0 - py) * pz + px * py * (1.0 - pz), + ] +} + +fn compose_pauli_channels(left: [f64; 4], right: [f64; 4]) -> [f64; 4] { + let [li, lx, ly, lz] = left; + let [ri, rx, ry, rz] = right; + [ + li * ri + lx * rx + ly * ry + lz * rz, + li * rx + lx * ri + ly * rz + lz * ry, + li * ry + ly * ri + lx * rz + lz * rx, + li * rz + lz * ri + lx * ry + ly * rx, + ] +} + +fn idle_signature_contributions(dem: &DetectorErrorModel) -> Vec<(FaultMechanism, f64)> { + let mut mechanisms = Vec::new(); + for record in dem.contribution_render_records() { + let contribution = record.contribution; + assert_eq!(contribution.source_gate_types.as_slice(), [GateType::Idle]); + assert!(contribution.paulis.is_empty()); + mechanisms.push((contribution.effect, contribution.probability)); + } + mechanisms +} + +fn raw_idle_signature( + influence: &DagFaultInfluenceMap, + loc_idx: usize, + pauli: Pauli, +) -> FaultMechanism { + FaultMechanism::from_unsorted_with_tracked_paulis( + influence + .get_detector_indices(loc_idx, pauli.as_u8()) + .iter() + .copied(), + influence + .get_observable_indices(loc_idx, pauli.as_u8()) + .iter() + .copied(), + influence + .get_tracked_pauli_indices(loc_idx, pauli.as_u8()) + .iter() + .copied(), + ) +} + +fn idle_location(influence: &DagFaultInfluenceMap) -> usize { + influence + .locations + .iter() + .position(|location| location.gate_type == GateType::Idle && !location.before) + .expect("after-idle fault location") +} + +fn independent_signature_distribution( + mechanisms: &[(FaultMechanism, f64)], +) -> BTreeMap { + let mut distribution = BTreeMap::from([(FaultMechanism::new(), 1.0)]); + for (mechanism, probability) in mechanisms { + let mut next = BTreeMap::new(); + for (effect, mass) in distribution { + *next.entry(effect.clone()).or_insert(0.0) += mass * (1.0 - probability); + *next.entry(effect.xor(mechanism)).or_insert(0.0) += mass * probability; + } + distribution = next; + } + distribution +} + #[test] fn idle_locations_contribute_mechanisms_when_rates_set() { let dag = build_idle_then_measure(2); @@ -210,7 +388,9 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { let influence = analyzer.build_influence_map(); let dem = DemBuilder::new(&influence) - .with_noise_config(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(1.0e-3)) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(z_idle_family(1.0e-3)), + ) .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) .unwrap() .build(); @@ -224,43 +404,519 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { #[test] fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { let linear = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_linear_rate(1.0e-3) + .set_idle_linear(z_idle_family(1.0e-3)) .idle_pauli_probs(20.0); assert_eq!(linear.px.to_bits(), 0.0_f64.to_bits()); assert_eq!(linear.py.to_bits(), 0.0_f64.to_bits()); assert!((linear.pz - 0.02).abs() < 1e-15); let quadratic = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_quadratic_rate(0.1) + .set_idle_quadratic(z_idle_family(0.1)) .idle_pauli_probs(2.0); assert_eq!(quadratic.px.to_bits(), 0.0_f64.to_bits()); assert_eq!(quadratic.py.to_bits(), 0.0_f64.to_bits()); assert!((quadratic.pz - 0.4).abs() < 1e-15); let pauli = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_pauli_linear_rates(1.0e-3, 2.0e-3, 3.0e-3) - .set_idle_pauli_quadratic_rates(1.0e-4, 2.0e-4, 3.0e-4) + .set_idle_linear(axis_rate_family(1.0e-3, 2.0e-3, 3.0e-3)) + .set_idle_quadratic(axis_rate_family(1.0e-4, 2.0e-4, 3.0e-4)) .idle_memory_pauli_probs(10.0); - assert!((pauli.px - 0.02).abs() < 1e-15); - assert!((pauli.py - 0.04).abs() < 1e-15); - assert!((pauli.pz - 0.06).abs() < 1e-15); + let expected = compose_pauli_channels( + [0.94, 0.01, 0.02, 0.03], + compose_xyz_mechanisms(PauliProbs { + px: 0.01, + py: 0.02, + pz: 0.03, + }), + ); + assert!((pauli.px - expected[1]).abs() < 1e-15); + assert!((pauli.py - expected[2]).abs() < 1e-15); + assert!((pauli.pz - expected[3]).abs() < 1e-15); } #[test] fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { let z_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_quadratic_sine_rate(0.2) + .set_idle_quadratic_sine(z_idle_family(0.2)) .idle_memory_pauli_probs(3.0); assert_eq!(z_sine.px.to_bits(), 0.0_f64.to_bits()); assert_eq!(z_sine.py.to_bits(), 0.0_f64.to_bits()); assert!((z_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); let pauli_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_pauli_quadratic_sine_rates(0.1, 0.2, 0.3) + .set_idle_quadratic_sine(axis_rate_family(0.1, 0.2, 0.3)) .idle_memory_pauli_probs(2.0); - assert!((pauli_sine.px - 0.2_f64.sin().powi(2)).abs() < 1e-15); - assert!((pauli_sine.py - 0.4_f64.sin().powi(2)).abs() < 1e-15); - assert!((pauli_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); + let expected = compose_xyz_mechanisms(PauliProbs { + px: 0.2_f64.sin().powi(2), + py: 0.4_f64.sin().powi(2), + pz: 0.6_f64.sin().powi(2), + }); + assert!((pauli_sine.px - expected[1]).abs() < 1e-15); + assert!((pauli_sine.py - expected[2]).abs() < 1e-15); + assert!((pauli_sine.pz - expected[3]).abs() < 1e-15); +} + +#[test] +fn unset_idle_weight_map_is_symmetric() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let implicit = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear(IdleNoiseFamily::new(0.005, BTreeMap::new())); + let explicit = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear(idle_family(0.005, [("X", 1.0), ("Y", 1.0), ("Z", 1.0)])); + + assert_eq!( + build_synthetic_idle_dem(&influence, implicit) + .expect("implicit symmetric family") + .to_string(), + build_synthetic_idle_dem(&influence, explicit) + .expect("explicit symmetric family") + .to_string(), + ); +} + +#[test] +fn single_axis_idle_map_matches_pinned_z_linear_dem() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(idle_family(0.005, [("Z", 1.0)])); + + assert_eq!( + build_synthetic_idle_dem(&influence, noise) + .expect("single-axis Z family") + .to_string(), + "detector D0\ndetector D1\nerror(0.005) D1", + ); +} + +#[test] +fn zero_idle_family_rate_is_inactive_regardless_of_weights() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let omitted = NoiseConfig::new(0.0, 0.0, 0.0, 0.0); + let configured = NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(IdleNoiseFamily::new( + 0.0, + BTreeMap::from([("invalid".to_string(), f64::NAN), ("X".to_string(), -1.0)]), + )); + + assert!(!configured.uses_dedicated_idle_noise()); + let probabilities = configured + .try_idle_memory_pauli_probs(1.0) + .expect("zero-rate family bypasses its map"); + assert_eq!(probabilities.px.to_bits(), 0.0_f64.to_bits()); + assert_eq!(probabilities.py.to_bits(), 0.0_f64.to_bits()); + assert_eq!(probabilities.pz.to_bits(), 0.0_f64.to_bits()); + assert_eq!( + build_synthetic_idle_dem(&influence, configured) + .expect("zero-rate family is inactive") + .to_string(), + build_synthetic_idle_dem(&influence, omitted) + .expect("omitted family") + .to_string(), + ); +} + +#[test] +fn idle_weight_map_insertion_order_does_not_affect_dem_text() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let xyz = idle_family(0.005, [("X", 0.4), ("Y", 0.6), ("Z", 1.0)]); + let zyx = idle_family(0.005, [("Z", 1.0), ("Y", 0.6), ("X", 0.4)]); + + assert!(std::any::type_name_of_val(&xyz.weights).contains("BTreeMap")); + + assert_eq!( + build_synthetic_idle_dem( + &influence, + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(xyz), + ) + .expect("XYZ insertion order") + .to_string(), + build_synthetic_idle_dem( + &influence, + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(zyx), + ) + .expect("ZYX insertion order") + .to_string(), + ); +} + +#[test] +fn invalid_idle_family_rates_and_weights_keep_existing_errors() { + let cases = [ + ( + IdleNoiseFamily::new(-0.01, BTreeMap::from([("Z".to_string(), 1.0)])), + "invalid linear idle rate/model [X=0, Y=0, Z=-0.01]", + ), + ( + IdleNoiseFamily::new(f64::INFINITY, BTreeMap::from([("Z".to_string(), 1.0)])), + "invalid linear idle rate/model [X=0, Y=0, Z=inf]", + ), + ( + IdleNoiseFamily::new(0.01, BTreeMap::from([("X".to_string(), -1.0)])), + "invalid linear idle rate/model [X=-0.01, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new(0.01, BTreeMap::from([("X".to_string(), f64::NAN)])), + "invalid linear idle rate/model [X=NaN, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new( + -f64::MIN_POSITIVE, + BTreeMap::from([("X".to_string(), f64::MIN_POSITIVE)]), + ), + "invalid linear idle rate/model [X=-0, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new(f64::INFINITY, BTreeMap::from([("X".to_string(), 0.0)])), + "invalid linear idle rate/model [X=0, Y=0, Z=0]", + ), + ( + IdleNoiseFamily::new( + f64::MIN_POSITIVE, + BTreeMap::from([("X".to_string(), -f64::MIN_POSITIVE)]), + ), + "invalid linear idle rate/model [X=-0, Y=0, Z=0]", + ), + ]; + + for (family, expected) in cases { + let error = + build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(family)) + .expect_err("invalid family inputs must be rejected"); + assert!(error.contains(expected), "error={error:?}"); + assert!(error.contains("rates must be finite and non-negative")); + } +} + +#[test] +fn invalid_idle_weight_map_key_is_rejected() { + let family = IdleNoiseFamily::new(0.01, BTreeMap::from([("not-a-pauli".to_string(), 1.0)])); + let error = + build_tracked_idle_dem(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(family)) + .expect_err("invalid family key must be rejected"); + + assert!(error.contains("invalid linear idle rate/model key \"not-a-pauli\"")); + assert!(error.contains("weights must use only X, Y, and Z")); +} + +#[test] +fn equal_idle_signatures_sum_exclusive_probabilities_before_dem_merging() { + let influence = synthetic_idle_influence(&[0], &[], &[0]); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("equal signatures need no independent conversion"); + let contributions = idle_signature_contributions(&dem); + + assert_eq!(contributions.len(), 1); + assert_eq!(contributions[0].1.to_bits(), 1.0_f64.to_bits()); + assert_ne!( + contributions[0].1.to_bits(), + combine_probabilities(0.25, 0.75).to_bits(), + "exclusive aliases must sum, not use the independent XOR rule", + ); + assert!(dem.idle_noise_residuals().is_empty()); + + let measurement_model = MemBuilder::new(&influence) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)), + ) + .build(); + assert_eq!(measurement_model.mechanisms.len(), 1); + let measurement_probability = *measurement_model + .mechanisms + .values() + .next() + .expect("equal raw-measurement signatures must emit one mechanism"); + assert_eq!(measurement_probability.to_bits(), 1.0_f64.to_bits()); + assert_ne!( + measurement_probability.to_bits(), + combine_probabilities(0.25, 0.75).to_bits() + ); +} + +#[test] +fn empty_idle_signature_is_dropped_before_conversion() { + let influence = synthetic_idle_influence(&[], &[0], &[0]); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("an undetectable X branch cannot obstruct the surviving Z branch"); + let contributions = idle_signature_contributions(&dem); + + assert_eq!(contributions.len(), 1); + assert_eq!(contributions[0].1.to_bits(), 0.75_f64.to_bits()); + assert!(dem.idle_noise_residuals().is_empty()); + + let measurement_model = MemBuilder::new(&influence) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.75)), + ) + .build(); + assert_eq!(measurement_model.mechanisms.len(), 1); + assert_eq!( + measurement_model + .mechanisms + .values() + .next() + .expect("surviving Z measurement signature") + .to_bits(), + 0.75_f64.to_bits() + ); + assert!(measurement_model.idle_noise_residuals.is_empty()); +} + +#[test] +fn biased_xz_idle_channel_builds_with_quantified_boundary_residual() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let loc_idx = idle_location(&influence); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.0075, 0.0, 0.0225)); + let legacy_sampling_engine = SamplingEngine::from_influence_map(&influence, &[1.0], &noise); + let mut sampler_influence = influence.clone(); + sampler_influence + .detectors + .push(DetectorId::single(MeasurementId { + tick: 0, + qubit: 0, + basis: 0, + })); + let sampler = DemSamplerBuilder::new(&sampler_influence) + .with_noise_config(noise.clone()) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .expect("valid detector metadata") + .build() + .expect("valid detector sampler"); + let sampler_dem = sampler.to_detector_error_model(); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("ordinary biased X/Z idle noise must produce a usable DEM"); + let mechanisms = idle_signature_contributions(&dem); + let distribution = independent_signature_distribution(&mechanisms); + let x_effect = raw_idle_signature(&influence, loc_idx, Pauli::X); + let y_effect = raw_idle_signature(&influence, loc_idx, Pauli::Y); + let z_effect = raw_idle_signature(&influence, loc_idx, Pauli::Z); + + assert!( + (distribution[&x_effect] - 0.0075).abs() < 1e-12, + "distribution={distribution:?}, mechanisms={mechanisms:?}" + ); + assert!((distribution[&z_effect] - 0.0225).abs() < 1e-12); + let [residual] = dem.idle_noise_residuals() else { + panic!("the infeasible two-signature channel must report one residual") + }; + assert_eq!(residual.effect, y_effect); + assert_eq!(residual.channel_weight.to_bits(), 0.03_f64.to_bits()); + assert!((distribution[&y_effect] - residual.magnitude).abs() < 1e-12); + for sampler_residuals in [ + legacy_sampling_engine.idle_noise_residuals(), + sampler_dem.idle_noise_residuals(), + ] { + let [sampler_residual] = sampler_residuals else { + panic!("each sampler path must retain the idle-channel residual") + }; + assert_eq!( + sampler_residual.channel_weight.to_bits(), + 0.03_f64.to_bits() + ); + assert_eq!( + sampler_residual.relative_magnitude().to_bits(), + residual.relative_magnitude().to_bits() + ); + } + let qx = mechanisms + .iter() + .find_map(|(effect, probability)| (effect == &x_effect).then_some(*probability)) + .expect("X signature mechanism"); + let qz = mechanisms + .iter() + .find_map(|(effect, probability)| (effect == &z_effect).then_some(*probability)) + .expect("Z signature mechanism"); + assert!((residual.magnitude - qx * qz).abs() < 1e-15); +} + +#[test] +fn three_distinct_idle_signatures_match_engines_pauli_channel() { + // pecos-engines samples one event with probability 0.01, then selects + // X/Y/Z categorically with the configured relative weights. Include both + // the documented 0.25/0.25/0.50 model and an asymmetric model so every + // eigenvalue denominator is independently exercised. + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let loc_idx = idle_location(&influence); + let x_effect = raw_idle_signature(&influence, loc_idx, Pauli::X); + let y_effect = raw_idle_signature(&influence, loc_idx, Pauli::Y); + let z_effect = raw_idle_signature(&influence, loc_idx, Pauli::Z); + + for [px, py, pz] in [[0.0025, 0.0025, 0.005], [0.002, 0.003, 0.005]] { + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(px, py, pz)); + let dem = build_synthetic_idle_dem(&influence, noise) + .expect("three-signature engines channel is exactly representable"); + let distribution = independent_signature_distribution(&idle_signature_contributions(&dem)); + + assert!( + (distribution[&FaultMechanism::new()] - (1.0 - px - py - pz)).abs() < 1e-12, + "distribution={distribution:?}" + ); + assert!((distribution[&x_effect] - px).abs() < 1e-12); + assert!((distribution[&y_effect] - py).abs() < 1e-12); + assert!((distribution[&z_effect] - pz).abs() < 1e-12); + assert!(dem.idle_noise_residuals().is_empty()); + } +} + +#[test] +fn idle_y_signature_is_xor_of_x_and_z_at_every_tested_location() { + let synthetic = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let synthetic_loc = idle_location(&synthetic); + assert_eq!( + raw_idle_signature(&synthetic, synthetic_loc, Pauli::Y), + raw_idle_signature(&synthetic, synthetic_loc, Pauli::X).xor(&raw_idle_signature( + &synthetic, + synthetic_loc, + Pauli::Z + )), + "synthetic non-empty signatures", + ); + + for dag in [ + build_unit_idle_with_pauli_tracking(), + build_unit_idle_tracking_x(), + build_unit_idle_tracking_y(), + build_idle_then_measure(3), + ] { + let influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + for (loc_idx, location) in influence.locations.iter().enumerate() { + if location.gate_type != GateType::Idle || location.before { + continue; + } + let x_effect = raw_idle_signature(&influence, loc_idx, Pauli::X); + let y_effect = raw_idle_signature(&influence, loc_idx, Pauli::Y); + let z_effect = raw_idle_signature(&influence, loc_idx, Pauli::Z); + assert_eq!(y_effect, x_effect.xor(&z_effect), "idle location {loc_idx}"); + } + } +} + +#[test] +fn linear_and_sine_idle_families_emit_separate_contributions() { + let linear_probability = 0.01; + let sine_rate: f64 = 0.2; + let sine_probability = sine_rate.sin().powi(2); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear(z_idle_family(linear_probability)) + .set_idle_quadratic_sine(z_idle_family(sine_rate)); + let dem = build_tracked_idle_dem(noise).expect("valid composed-family DEM"); + + let mut z_probabilities = dem + .contribution_render_records() + .into_iter() + .filter_map(|record| { + (record.contribution.source_gate_types.as_slice() == [GateType::Idle]) + .then_some(record.contribution.probability) + }) + .collect::>(); + z_probabilities.sort_by(f64::total_cmp); + + assert_eq!(z_probabilities.len(), 2); + assert!((z_probabilities[0] - linear_probability).abs() < 1e-15); + assert!((z_probabilities[1] - sine_probability).abs() < 1e-15); + assert!( + (z_probabilities.iter().sum::() - (linear_probability + sine_probability)).abs() + < 1e-15 + ); +} + +#[test] +fn nonpositive_signature_channel_character_returns_specific_error() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let error = build_synthetic_idle_dem( + &influence, + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.25, 0.0, 0.25)), + ) + .expect_err("zero signature-channel characters are broken input"); + + assert!(error.contains("DEM builder configuration error")); + assert!(error.contains("location")); + assert!(error.contains("characters")); + assert!(error.contains("must all be positive")); +} + +#[test] +fn oversized_coefficient_quadratic_mechanism_returns_specific_error() { + let error = build_tracked_idle_dem( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_quadratic(z_idle_family(1.1)), + ) + .expect_err("probabilities above one must not be clamped"); + + assert!(error.contains("coefficient-quadratic idle mechanism probabilities")); + assert!(error.contains("Z=1.1")); + assert!(error.contains("must be finite and lie in [0, 1]")); +} + +#[test] +fn negative_idle_rate_is_rejected_instead_of_clamped() { + let error = build_tracked_idle_dem( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(z_idle_family(-0.01)), + ) + .expect_err("negative rates must not be clamped"); + + assert!(error.contains("invalid linear idle rate/model [X=0, Y=0, Z=-0.01]")); + assert!(error.contains("rates must be finite and non-negative")); +} + +#[test] +fn negative_idle_duration_is_rejected_instead_of_clamped() { + let dag = build_unit_idle_tracking_x(); + let mut influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + let loc_idx = idle_location(&influence); + influence.locations[loc_idx].idle_duration = -1.0; + let error = DemBuilder::new(&influence) + .with_noise_config( + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(z_idle_family(0.01)), + ) + .try_build() + .expect_err("negative idle durations must not be clamped"); + + assert!(error.to_string().contains("invalid idle duration -1")); + assert!( + error + .to_string() + .contains("duration must be finite and non-negative") + ); +} + +#[test] +fn identical_idle_configuration_produces_byte_identical_dem_text() { + let influence = synthetic_idle_influence(&[0], &[0, 1], &[1]); + let noise = + NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear(axis_rate_family(0.002, 0.003, 0.005)); + let build = || { + build_synthetic_idle_dem(&influence, noise.clone()) + .expect("valid deterministic DEM") + .to_string() + }; + + let expected = build(); + assert_eq!( + expected, + "detector D0\ndetector D1\nerror(0.002001) D0\nerror(0.003011) D0 D1\nerror(0.005019) D1", + "this full dimension-two DEM text is pinned to commit 79e8aa833", + ); + let dem = build_synthetic_idle_dem(&influence, noise.clone()).expect("valid pinned DEM"); + let probabilities = idle_signature_contributions(&dem) + .into_iter() + .map(|(_, probability)| probability.to_bits()) + .collect::>(); + assert_eq!( + probabilities, + [ + 0.002_000_955_040_586_616_f64.to_bits(), + 0.003_011_095_091_214_222_f64.to_bits(), + 0.005_019_131_070_643_723_f64.to_bits(), + ], + "dimension-two idle mechanism bits are pinned to commit 79e8aa833", + ); + for _ in 0..16 { + assert_eq!(build().as_bytes(), expected.as_bytes()); + } } #[test] diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 7cfc5f604..5cc443b61 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -101,7 +101,8 @@ pub enum QuantumOp { RZZ(f64, usize, usize), // Measurement - Measure(usize, usize), // qubit, result_id + Measure(usize, usize), // qubit, result_id + MeasureLeaked(usize, usize), // qubit, result_id; outcome is 0, 1, or 2 // Reset Reset(usize), diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 0e5c955b4..d476cbe38 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -745,6 +745,24 @@ pub unsafe extern "C" fn ___lazy_measure(qubit: i64) -> i64 { }) } +/// Lazy leakage-aware measurement function (Selene/HUGR-LLVM style). +/// +/// # Safety +/// The same requirements as [`___lazy_measure`] apply. +/// +/// # Panics +/// Panics if the allocated result ID is too large to fit in i64. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ___lazy_measure_leaked(qubit: i64) -> i64 { + let qubit_id = i64_to_usize(qubit); + with_interface(|interface| { + let result_id = interface.allocate_result(); + interface.queue_operation(Operation::AllocateResult { id: result_id }); + interface.queue_operation(QuantumOp::MeasureLeaked(qubit_id, result_id).into()); + i64::try_from(result_id).expect("Result ID too large for i64") + }) +} + /// Read a future boolean value (Guppy/HUGR-LLVM style) /// /// This function retrieves a measurement result from a future/deferred measurement. @@ -831,6 +849,38 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { } } +/// Read an integer-valued measurement future (Selene/HUGR-LLVM style). +/// +/// Leakage-aware Guppy measurements use an unsigned future with outcomes 0, 1, +/// or 2 (leaked). +/// +/// # Safety +/// The same requirements as [`___read_future_bool`] apply. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ___read_future_uint(future_id: i64) -> u64 { + log::debug!("___read_future_uint called with future_id={future_id}"); + let result_id = i64_to_usize(future_id); + + if crate::is_dynamic_mode_active() { + if let Some(result) = crate::get_measurement_outcome(result_id as u64) { + record_result_read(result_id); + return result; + } + if crate::wait_for_result_ready(result_id as u64, 30_000) { + let result = crate::get_measurement_outcome(result_id as u64); + if result.is_some() { + record_result_read(result_id); + } + return result.unwrap_or(0); + } + } + + // Static collection cannot synthesize a leak. Reuse the Boolean collection + // behavior so bounded probing and repeat-until-success termination remain + // consistent with ordinary measurements. + u64::from(unsafe { ___read_future_bool(future_id) }) +} + /// Reset the collection mode read counter. /// /// This should be called at the start of each new execution to reset the loop @@ -1012,6 +1062,48 @@ pub unsafe extern "C" fn print_bool(label_ptr: *const u8, label_len: i64, value: } } +/// Record an integer result whose value is in the detector-compatible 0/1 domain. +/// +/// Guppy permits integer literals in ``result(...)`` calls. PECOS named results +/// are currently Boolean, but cultivation programs use integer zero for a +/// detector known to be satisfied. Preserve those 0/1 outputs and reject other +/// integers instead of silently coercing arbitrary values. +/// +/// # Safety +/// `label_ptr` must reference a tket2 string with at least `label_len + 1` +/// bytes, as for [`print_bool`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn print_int(label_ptr: *const u8, label_len: i64, value: i64) { + let Ok(label_len_usize) = usize::try_from(label_len) else { + log::error!("print_int: invalid label length {label_len}"); + return; + }; + let data_ptr = unsafe { label_ptr.add(1) }; + let label_slice = unsafe { std::slice::from_raw_parts(data_ptr, label_len_usize) }; + let Ok(label) = std::str::from_utf8(label_slice) else { + log::error!("print_int: invalid UTF-8 in label"); + return; + }; + let name = label.strip_prefix("USER:INT:").unwrap_or(label); + let value = match value { + 0 => false, + 1 => true, + _ => { + log::error!( + "print_int: named result '{name}' has value {value}; PECOS currently supports only Boolean 0/1 named results" + ); + return; + } + }; + + if let Some(ctx) = crate::get_execution_context() { + // SAFETY: The registered context is valid for the execution duration. + unsafe { &*ctx }.store_named_bool(name, value); + } else { + log::warn!("print_int: no execution context for '{name}' = {value}"); + } +} + /// Dense 1D array struct matching the LLVM ABI from tket2 /// /// This struct is passed by pointer from LLVM-compiled code. @@ -1962,6 +2054,67 @@ mod tests { }); } + #[test] + fn test_lazy_measure_leaked_uses_an_allocated_measurement_result() { + setup_test(); + let result_id = unsafe { ___lazy_measure_leaked(0) }; + + assert_eq!(result_id, 0); + with_interface(|iface| { + assert_eq!(iface.operations.len(), 2); + assert_eq!(iface.operations[0], Operation::AllocateResult { id: 0 }); + assert_eq!( + iface.operations[1], + Operation::Quantum(QuantumOp::MeasureLeaked(0, 0)) + ); + }); + } + + #[test] + fn test_read_future_uint_preserves_the_ideal_measurement_value() { + setup_test(); + with_interface(|iface| iface.store_result(4, true)); + + assert_eq!(unsafe { ___read_future_uint(4) }, 1); + } + + #[test] + fn test_read_future_uint_preserves_leakage_outcome() { + setup_test(); + let ctx = crate::pecos_create_execution_context(); + let context = unsafe { &*ctx }; + context + .dynamic_mode_active + .store(true, std::sync::atomic::Ordering::SeqCst); + unsafe { crate::pecos_register_execution_context(ctx) }; + crate::pecos_set_measurement_outcome(4, 2); + + assert_eq!(unsafe { ___read_future_uint(4) }, 2); + + unsafe { + crate::pecos_register_execution_context(std::ptr::null_mut()); + crate::pecos_destroy_execution_context(ctx); + } + } + + #[test] + fn test_print_int_records_boolean_detector_literals() { + let ctx = crate::pecos_create_execution_context(); + unsafe { crate::pecos_register_execution_context(ctx) }; + let label = b"\x08DETECTOR"; + + unsafe { print_int(label.as_ptr(), 8, 0) }; + assert_eq!( + unsafe { &*ctx }.get_named_results()["DETECTOR"], + vec![false] + ); + + unsafe { + crate::pecos_register_execution_context(std::ptr::null_mut()); + crate::pecos_destroy_execution_context(ctx); + } + } + #[test] fn test_read_future_bool_with_stored_result() { setup_test(); diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index 47a1a5d2a..9d8657124 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -62,8 +62,10 @@ pub struct ExecutionContext { pub sync_condvar: Condvar, /// Storage for pending operations (shared between threads) pub pending_ops: Mutex>, - /// Storage for measurement results (shared between threads) - pub measurement_results: Mutex>>, + /// Storage for measurement outcomes (shared between threads). + /// + /// Ordinary measurements use 0/1. Leakage-aware measurements may also use 2. + pub measurement_results: Mutex>>, /// Storage for named results from `print_bool`/`print_bool_arr` (e.g., "synx", "final") pub named_results: Mutex>>, /// Runtime provenance for each `result(...)` output call. @@ -650,7 +652,7 @@ pub fn is_dynamic_mode_active() -> bool { /// This is used by the worker thread to get results set by the main thread. /// Returns None if no execution context is registered. #[must_use] -pub fn get_measurement_result(result_id: u64) -> Option { +pub fn get_measurement_outcome(result_id: u64) -> Option { let ctx = get_execution_context()?; let result_index = usize::try_from(result_id).ok()?; // SAFETY: Context is valid for duration of execution @@ -664,6 +666,23 @@ pub fn get_measurement_result(result_id: u64) -> Option { } } +/// Get a Boolean measurement result from the execution context. +/// +/// Returns `None` for a leakage outcome instead of silently treating 2 as true. +#[must_use] +pub fn get_measurement_result(result_id: u64) -> Option { + match get_measurement_outcome(result_id)? { + 0 => Some(false), + 1 => Some(true), + value => { + log::error!( + "get_measurement_result: result_id={result_id} has non-Boolean outcome {value}" + ); + None + } + } +} + /// Set a measurement result via FFI (called by main thread after simulation) /// /// This stores in the execution context so worker thread can access it. @@ -673,6 +692,20 @@ pub fn get_measurement_result(result_id: u64) -> Option { /// This function is safe to call from any thread. #[unsafe(no_mangle)] pub extern "C" fn pecos_set_measurement_result(result_id: u64, value: bool) { + pecos_set_measurement_outcome(result_id, u64::from(value)); +} + +/// Set an integer-valued measurement outcome via FFI. +/// +/// Ordinary measurement results are 0/1; leakage-aware results may also be 2. +#[unsafe(no_mangle)] +pub extern "C" fn pecos_set_measurement_outcome(result_id: u64, value: u64) { + if value > 2 { + log::error!( + "pecos_set_measurement_outcome: invalid outcome {value} for result_id={result_id}" + ); + return; + } log::debug!("pecos_set_measurement_result: result_id={result_id}, value={value}"); if let Some(ctx) = get_execution_context() { let Ok(result_index) = usize::try_from(result_id) else { @@ -919,7 +952,7 @@ mod tests { context.waiting_for_result.store(42, Ordering::SeqCst); if let Ok(mut results) = context.measurement_results.lock() { results.resize(1, None); - results[0] = Some(true); + results[0] = Some(1); } if let Ok(mut ops) = context.pending_ops.lock() { ops.push(Operation::AllocateQubit { id: 0 }); diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index e8e6a0922..a45b6a3b2 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -65,6 +65,12 @@ pub struct OperationTraceChunk { pub lowered_quantum_ops: Vec, pub lowered_quantum_ops_complete: bool, pub named_result_traces: Vec, + /// Physical measurement outcomes keyed by stable QIS result id. + /// + /// This is populated only on the terminal ``trace_complete`` chunk. It + /// lets consumers certify aggregate named-result provenance without + /// relying on when the compiled program happened to read each future. + pub measurement_results: BTreeMap, } /// Shared in-memory store for traced QIS operation batches. @@ -271,7 +277,7 @@ pub struct QisEngine { measurement_mapping: Vec, /// Stored measurement results for `get_results()` - measurement_results: BTreeMap, + measurement_results: BTreeMap, /// RNG for generating per-shot seeds rng: PecosRng, @@ -321,26 +327,22 @@ pub struct QisEngine { } impl QisEngine { - fn parse_measurement_outcomes(message: &ByteMessage) -> Result, PecosError> { + fn parse_measurement_outcomes(message: &ByteMessage) -> Result, PecosError> { message .outcomes() - .map(|outcomes| outcomes.into_iter().map(|value| value as usize).collect()) + .map(|outcomes| outcomes.into_iter().collect()) .map_err(|e| PecosError::Generic(format!("Failed to parse measurements: {e}"))) } - fn map_measurements( - measurement_mapping: &[usize], - measurements: &[usize], - ) -> Vec<(usize, bool)> { + fn map_measurements(measurement_mapping: &[usize], measurements: &[u32]) -> Vec<(usize, u32)> { measurement_mapping .iter() .copied() .zip(measurements.iter().copied()) - .map(|(result_id, value)| (result_id, value != 0)) .collect() } - fn store_measurement_updates(&mut self, updates: &[(usize, bool)]) { + fn store_measurement_updates(&mut self, updates: &[(usize, u32)]) { for &(result_id, value) in updates { self.measurement_results.insert(result_id, value); debug!("QisEngine: Stored measurement result_id={result_id}, value={value}"); @@ -349,14 +351,14 @@ impl QisEngine { fn provide_measurement_updates_to_runtime( &mut self, - updates: &[(usize, bool)], + updates: &[(usize, u32)], ) -> Result<(), PecosError> { if updates.is_empty() { return Ok(()); } - let measurement_map: BTreeMap = updates.iter().copied().collect(); + let measurement_map: BTreeMap = updates.iter().copied().collect(); self.runtime - .provide_measurements(measurement_map) + .provide_measurement_outcomes(measurement_map) .map_err(|e| PecosError::Generic(format!("Failed to provide measurements: {e}"))) } @@ -703,6 +705,11 @@ impl QisEngine { builder.mz(&[self.mapped_qubit(*qubit, qop)?]); Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } + QuantumOp::MeasureLeaked(qubit, result_id) => { + self.measurement_mapping.push(*result_id); + builder.measure_leakages(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); + } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[( self.mapped_qubit(*qubit1, qop)?, @@ -850,6 +857,11 @@ impl QisEngine { builder.mz(&[qubit]); gate_metadata.push(metadata); } + QuantumOp::MeasureLeaked(qubit, result_id) => { + self.measurement_mapping.push(result_id); + builder.measure_leakages(&[qubit]); + gate_metadata.push(metadata); + } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[(qubit1, qubit2)]); gate_metadata.push(metadata); @@ -984,7 +996,7 @@ impl QisEngine { .iter() .map(|q| usize::from(*q)) .collect::>(); - let measurement_result_ids = if gate_type == "MZ" { + let measurement_result_ids = if matches!(gate_type.as_str(), "MZ" | "MeasureLeaked") { let end = measurement_cursor + qubits.len(); if end > measurement_mapping.len() { return Err( @@ -1070,6 +1082,11 @@ impl QisEngine { lowered_quantum_ops: lowered_trace, lowered_quantum_ops_complete, named_result_traces: Vec::new(), + measurement_results: if stage == "trace_complete" { + self.measurement_results.clone() + } else { + BTreeMap::new() + }, }; if let Some(ref collector) = self.operation_trace_collector { @@ -1140,6 +1157,7 @@ impl QisEngine { lowered_quantum_ops: Vec::new(), lowered_quantum_ops_complete: true, named_result_traces: named_result_traces.to_vec(), + measurement_results: BTreeMap::new(), }; if let Some(ref collector) = self.operation_trace_collector { @@ -1278,7 +1296,7 @@ impl QisEngine { } /// Set a measurement result for the running program - fn set_dynamic_result(&mut self, result_id: u64, value: bool) -> Result<(), PecosError> { + fn set_dynamic_result(&mut self, result_id: u64, value: u32) -> Result<(), PecosError> { let state = self .dynamic_state .as_ref() @@ -1289,7 +1307,7 @@ impl QisEngine { .ok_or_else(|| PecosError::Generic("No sync handle available".to_string()))?; handle - .set_measurement_result(result_id, value) + .set_measurement_outcome(result_id, u64::from(value)) .map_err(|e| PecosError::Generic(format!("Failed to set measurement result: {e}")))?; debug!("Set dynamic result: {result_id} = {value}"); Ok(()) @@ -1442,7 +1460,7 @@ impl QisEngine { /// from stale state, and no later gate can certify that trace. fn provide_measurements_terminal( &mut self, - updates: &[(usize, bool)], + updates: &[(usize, u32)], ) -> Result<(), PecosError> { match self.provide_measurement_updates_to_runtime(updates) { Ok(()) => Ok(()), @@ -1656,15 +1674,9 @@ impl ClassicalEngine for QisEngine { // results (from result() calls) are consistent. if !has_named_results { for (result_id, value) in &self.measurement_results { - shot.data.insert( - format!("measurement_{result_id}"), - Data::U32(u32::from(*value)), - ); - debug!( - "QisEngine: Added to shot: measurement_{} = {}", - result_id, - i32::from(*value) - ); + shot.data + .insert(format!("measurement_{result_id}"), Data::U32(*value)); + debug!("QisEngine: Added to shot: measurement_{result_id} = {value}"); } } @@ -2063,6 +2075,67 @@ mod tests { in_memory[0].lowered_quantum_ops[3].measurement_result_ids, vec![7] ); + assert!(in_memory[0].measurement_results.is_empty()); + drop(in_memory); + + engine.measurement_results.insert(7, 1); + engine.trace_complete_chunk(); + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory[1].stage, "trace_complete"); + assert_eq!(in_memory[1].measurement_results, BTreeMap::from([(7, 1)])); + } + + #[test] + fn test_direct_lowering_preserves_leakage_measurement() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + let ops = vec![ + Operation::AllocateQubit { id: 0 }, + QuantumOp::MeasureLeaked(0, 8).into(), + ]; + + let lowered = engine + .lower_operations_to_commands(&ops) + .expect("lower leakage-aware measurement"); + let quantum_ops = lowered + .commands + .quantum_ops() + .expect("parse quantum operations"); + + assert_eq!(quantum_ops.len(), 2); + assert_eq!( + quantum_ops[1].gate_type, + pecos_core::gate_type::GateType::MeasureLeaked + ); + assert_eq!(engine.measurement_mapping, vec![8]); + } + + #[test] + fn test_general_noise_returns_two_for_lowered_leakage_measurement() { + use pecos_engines::QuantumSystem; + use pecos_engines::noise::general::GeneralNoiseModel; + use pecos_engines::quantum::StateVecEngine; + + let mut emission_model = BTreeMap::new(); + emission_model.insert("L".to_string(), 1.0); + let noise = GeneralNoiseModel::builder() + .with_p1(1.0) + .with_p1_emission_ratio(1.0) + .with_p1_emission_model(&emission_model) + .build(); + let mut system = QuantumSystem::new(Box::new(noise), Box::new(StateVecEngine::new(1))); + let mut builder = ByteMessage::quantum_operations_builder(); + builder.pz(&[0]); + builder.r1xy( + Angle64::from_radians(std::f64::consts::FRAC_PI_2), + Angle64::from_radians(3.0 * std::f64::consts::FRAC_PI_2), + &[0], + ); + builder.rz(Angle64::HALF_TURN, &[0]); + builder.measure_leakages(&[0]); + + let result = system.process(builder.build()).expect("simulate leakage"); + + assert_eq!(result.outcomes().expect("parse outcome"), vec![2]); } #[test] diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index 3bd3fe45e..f767419e5 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -378,6 +378,7 @@ enum ExecutionEntryPoint<'a> { } type WaitForNeedResultFn = unsafe extern "C" fn(u64) -> u64; type SetMeasurementResultFn = unsafe extern "C" fn(u64, bool); +type SetMeasurementOutcomeFn = unsafe extern "C" fn(u64, u64); type SignalResultReadyFn = unsafe extern "C" fn(); type AbortExecutionFn = unsafe extern "C" fn(); type GetNamedResultsJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; @@ -437,6 +438,20 @@ impl DynamicSyncHandle for HeliosSyncHandle { Ok(()) } + fn set_measurement_outcome(&self, result_id: u64, value: u64) -> Result<(), InterfaceError> { + let lib = Self::get_lib()?; + let set_fn: Symbol = unsafe { + lib.get(b"pecos_set_measurement_outcome\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_set_measurement_outcome: {e}" + )) + })? + }; + unsafe { set_fn(result_id, value) }; + debug!("HeliosSyncHandle: Set measurement outcome {result_id} = {value}"); + Ok(()) + } + fn signal_result_ready(&self) -> Result<(), InterfaceError> { let lib = Self::get_lib()?; let signal_fn: Symbol = unsafe { @@ -2472,6 +2487,24 @@ impl QisInterface for QisHeliosInterface { Ok(()) } + fn set_measurement_outcome( + &mut self, + result_id: u64, + value: u64, + ) -> Result<(), InterfaceError> { + let lib = Self::get_qis_ffi_lib_singleton()?; + let set_fn: Symbol = unsafe { + lib.get(b"pecos_set_measurement_outcome\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_set_measurement_outcome: {e}" + )) + })? + }; + unsafe { set_fn(result_id, value) }; + debug!("Set measurement outcome via FFI: {result_id} = {value}"); + Ok(()) + } + fn signal_result_ready(&mut self) -> Result<(), InterfaceError> { // Get the process-wide QIS FFI library singleton let lib = Self::get_qis_ffi_lib_singleton()?; diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index bb39af454..0ef5b0b2e 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -182,6 +182,27 @@ pub trait QisInterface: Send + Sync { )) } + /// Set an integer-valued measurement outcome for the running program. + /// + /// Existing interfaces remain Boolean-only by default. + /// + /// # Errors + /// Returns an error if `value` is not 0 or 1, since a Boolean-only interface + /// cannot represent a leakage outcome, or if setting the result itself fails. + fn set_measurement_outcome( + &mut self, + result_id: u64, + value: u64, + ) -> Result<(), InterfaceError> { + match value { + 0 => self.set_measurement_result(result_id, false), + 1 => self.set_measurement_result(result_id, true), + _ => Err(InterfaceError::Other(format!( + "dynamic interface does not support leakage outcome {value}" + ))), + } + } + /// Signal that the measurement result is ready /// /// This wakes up the blocked program to continue execution. @@ -259,6 +280,21 @@ pub trait DynamicSyncHandle: Send + Sync { /// Returns an error if the FFI call fails or no execution context is registered. fn set_measurement_result(&self, result_id: u64, value: bool) -> Result<(), InterfaceError>; + /// Set an integer-valued measurement outcome for the running program. + /// + /// # Errors + /// Returns an error if `value` is not 0 or 1, since a Boolean-only interface + /// cannot represent a leakage outcome, or if setting the result itself fails. + fn set_measurement_outcome(&self, result_id: u64, value: u64) -> Result<(), InterfaceError> { + match value { + 0 => self.set_measurement_result(result_id, false), + 1 => self.set_measurement_result(result_id, true), + _ => Err(InterfaceError::Other(format!( + "dynamic interface does not support leakage outcome {value}" + ))), + } + } + /// Signal that the measurement result is ready /// /// # Errors diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 5696c9c13..d169a1ced 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -146,6 +146,28 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { /// Returns an error if the measurements cannot be provided. fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()>; + /// Provide integer-valued measurement outcomes back to the runtime. + /// + /// The default keeps existing Boolean runtimes compatible and rejects a + /// leakage outcome instead of silently converting 2 to true. + /// + /// # Errors + /// Returns an error if any outcome is not 0 or 1, since a Boolean runtime cannot + /// represent a leakage outcome, or if providing the measurements themselves fails. + fn provide_measurement_outcomes(&mut self, outcomes: BTreeMap) -> Result<()> { + let measurements = outcomes + .into_iter() + .map(|(result_id, value)| match value { + 0 => Ok((result_id, false)), + 1 => Ok((result_id, true)), + _ => Err(RuntimeError::ExecutionError(format!( + "runtime does not support leakage outcome {value} for result {result_id}" + ))), + }) + .collect::>>()?; + self.provide_measurements(measurements) + } + /// Get the current classical state (for debugging/inspection) fn get_classical_state(&self) -> &ClassicalState; diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 9346f5cbe..a872cebf4 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -265,6 +265,9 @@ pub struct SeleneRuntime { /// Reverse lookup for measurement operations emitted by the runtime plugin. runtime_to_program_results: BTreeMap, + /// Program results produced by leakage-aware measurements. + leakage_results: BTreeSet, + /// End timestamp of the last scheduled physical operation per runtime qubit. last_gate_time_end_nanos: Vec, @@ -308,6 +311,7 @@ impl SeleneRuntime { program_to_runtime_qubits: BTreeMap::new(), program_to_runtime_results: BTreeMap::new(), runtime_to_program_results: BTreeMap::new(), + leakage_results: BTreeSet::new(), last_gate_time_end_nanos: Vec::new(), pending_shot_start: None, active_shot: None, @@ -484,6 +488,7 @@ impl SeleneRuntime { self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); + self.leakage_results.clear(); self.last_gate_time_end_nanos.clear(); Ok(()) } @@ -855,6 +860,44 @@ impl SeleneRuntime { self.force_runtime_result(runtime_result) } + fn call_runtime_measure_leaked( + &mut self, + runtime_qubit: u64, + program_result: usize, + ) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + let runtime_result = unsafe { + let measure_fn = lib + .get:: i32>( + b"selene_runtime_measure_leaked", + ) + .map_err(|e| { + RuntimeError::FfiError(format!("Missing leakage measurement function: {e}")) + })?; + let mut runtime_result = 0; + let errno = measure_fn(instance, runtime_qubit, &raw mut runtime_result); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "measure_leaked failed with errno {errno}" + ))); + } + runtime_result + }; + + self.program_to_runtime_results + .insert(program_result, runtime_result); + self.runtime_to_program_results + .insert(runtime_result, program_result); + self.force_runtime_result(runtime_result) + } + fn force_runtime_result(&self, runtime_result: u64) -> Result<()> { let lib = self .library @@ -989,6 +1032,9 @@ impl SeleneRuntime { QuantumOp::RZZ(*theta, map(*qubit_1)?, map(*qubit_2)?) } QuantumOp::Measure(qubit, result_id) => QuantumOp::Measure(map(*qubit)?, *result_id), + QuantumOp::MeasureLeaked(qubit, result_id) => { + QuantumOp::MeasureLeaked(map(*qubit)?, *result_id) + } QuantumOp::Reset(qubit) => QuantumOp::Reset(map(*qubit)?), }) } @@ -1018,6 +1064,13 @@ impl SeleneRuntime { self.program_to_runtime_qubits.remove(qubit); self.runtime_qfree(runtime_qubit)?; } + QuantumOp::MeasureLeaked(qubit, result_id) => { + self.leakage_results.insert(*result_id); + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_measure_leaked(runtime_qubit, *result_id)?; + self.program_to_runtime_qubits.remove(qubit); + self.runtime_qfree(runtime_qubit)?; + } QuantumOp::Reset(qubit) => { let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; self.call_runtime_reset(runtime_qubit)?; @@ -1297,6 +1350,10 @@ impl SeleneRuntime { ( QuantumOp::Measure(source_qubit, source_result), QuantumOp::Measure(lowered_qubit, lowered_result), + ) + | ( + QuantumOp::MeasureLeaked(source_qubit, source_result), + QuantumOp::MeasureLeaked(lowered_qubit, lowered_result), ) => source_qubit == lowered_qubit && source_result == lowered_result, _ => false, } @@ -1332,6 +1389,7 @@ impl SeleneRuntime { | QuantumOp::RXY(_, _, qubit) | QuantumOp::Idle(_, qubit) | QuantumOp::Measure(qubit, _) + | QuantumOp::MeasureLeaked(qubit, _) | QuantumOp::Reset(qubit) => { qubits.insert(*qubit); } @@ -1470,15 +1528,30 @@ impl SeleneRuntime { RuntimeScheduledOp::Measure { qubit_id, result_id, + } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + let program_result = self.runtime_result_to_program_result(result_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + if self.leakage_results.contains(&program_result) { + // The pinned runtime ABI allocates both Boolean and + // leakage-aware futures through `runtime_measure`. + // Restore the source result kind after scheduling so + // PECOS executes MeasureLeaked and can produce 2. + lowered_ops.push(QuantumOp::MeasureLeaked(qubit, program_result)); + } else { + lowered_ops.push(QuantumOp::Measure(qubit, program_result)); + } + self.mark_gate_end(qubit, end_time); } - | RuntimeScheduledOp::MeasureLeaked { + RuntimeScheduledOp::MeasureLeaked { qubit_id, result_id, } => { let qubit = self.runtime_qubit_to_usize(qubit_id)?; let program_result = self.runtime_result_to_program_result(result_id)?; + self.leakage_results.insert(program_result); self.push_idle_before(&mut lowered_ops, qubit, start_time)?; - lowered_ops.push(QuantumOp::Measure(qubit, program_result)); + lowered_ops.push(QuantumOp::MeasureLeaked(qubit, program_result)); self.mark_gate_end(qubit, end_time); } RuntimeScheduledOp::Reset { qubit_id } => { @@ -1578,6 +1651,7 @@ impl Clone for SeleneRuntime { program_to_runtime_qubits: self.program_to_runtime_qubits.clone(), program_to_runtime_results: self.program_to_runtime_results.clone(), runtime_to_program_results: self.runtime_to_program_results.clone(), + leakage_results: self.leakage_results.clone(), last_gate_time_end_nanos: self.last_gate_time_end_nanos.clone(), pending_shot_start: self.pending_shot_start, active_shot: self.active_shot, @@ -1650,8 +1724,11 @@ fn operation_capacity_with_mode( } fn include_quantum_result_capacity(qop: &QuantumOp, num_results: &mut usize) { - if let QuantumOp::Measure(_, result) = qop { - include_result(num_results, *result); + match qop { + QuantumOp::Measure(_, result) | QuantumOp::MeasureLeaked(_, result) => { + include_result(num_results, *result); + } + _ => {} } } @@ -1686,7 +1763,7 @@ fn include_quantum_op_capacity(qop: &QuantumOp, num_qubits: &mut usize, num_resu include_qubit(num_qubits, *qubit_2); include_qubit(num_qubits, *qubit_3); } - QuantumOp::Measure(qubit, result) => { + QuantumOp::Measure(qubit, result) | QuantumOp::MeasureLeaked(qubit, result) => { include_qubit(num_qubits, *qubit); include_result(num_results, *result); } @@ -1877,6 +1954,15 @@ impl QisRuntime for SeleneRuntime { } fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { + self.provide_measurement_outcomes( + measurements + .into_iter() + .map(|(result_id, value)| (result_id, u32::from(value))) + .collect(), + ) + } + + fn provide_measurement_outcomes(&mut self, measurements: BTreeMap) -> Result<()> { debug!( "Received {} measurement results, num_results={}, allocated_results={:?}", measurements.len(), @@ -1890,18 +1976,44 @@ impl QisRuntime for SeleneRuntime { "Measurement result {} = {} (num_results={})", result_id, value, self.num_results ); - self.state.measurements.insert(*result_id, *value); + if *value <= 1 { + self.state.measurements.insert(*result_id, *value == 1); + } if let Some(runtime_result_id) = self.program_to_runtime_results.get(result_id) { if let Some(lib) = &self.library && let Some(instance) = self.instance { unsafe { - if let Ok(set_result_fn) = + if self.leakage_results.contains(result_id) { + if let Ok(set_result_fn) = + lib.get:: i32>( + b"selene_runtime_set_u64_result", + ) + { + let errno = + set_result_fn(instance, *runtime_result_id, u64::from(*value)); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "selene_runtime_set_u64_result failed with errno {errno} \ + for result {result_id}" + ))); + } + } + } else if let Ok(set_result_fn) = lib.get:: i32>( b"selene_runtime_set_bool_result", ) { + let bool_value = match *value { + 0 => false, + 1 => true, + _ => { + return Err(RuntimeError::ExecutionError(format!( + "ordinary measurement result {result_id} has non-Boolean outcome {value}" + ))); + } + }; // A delivery FAILURE is fatal: the scheduler // would otherwise proceed on stale/default state // while the QIS worker advances on the real bit, @@ -1909,7 +2021,7 @@ impl QisRuntime for SeleneRuntime { // An ABSENT symbol stays legal -- a runtime that // never conditions on results has no delivery to // fail. - let errno = set_result_fn(instance, *runtime_result_id, *value); + let errno = set_result_fn(instance, *runtime_result_id, bool_value); if errno != 0 { return Err(RuntimeError::FfiError(format!( "selene_runtime_set_bool_result failed with errno {errno} \ @@ -1925,8 +2037,10 @@ impl QisRuntime for SeleneRuntime { ); } - if let Some(interface) = &mut self.interface { - interface.store_result(*result_id, *value); + if let Some(interface) = &mut self.interface + && *value <= 1 + { + interface.store_result(*result_id, *value == 1); } } @@ -1999,6 +2113,7 @@ impl QisRuntime for SeleneRuntime { self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); + self.leakage_results.clear(); self.last_gate_time_end_nanos.clear(); self.pending_shot_start = Some((shot_id, seed)); self.apply_pending_shot_start()?; @@ -2057,6 +2172,7 @@ impl QisRuntime for SeleneRuntime { self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); + self.leakage_results.clear(); self.last_gate_time_end_nanos.clear(); self.pending_shot_start = None; self.active_shot = None; diff --git a/crates/pecos-relay-bp/src/config.rs b/crates/pecos-relay-bp/src/config.rs index 8bec52bf9..12bfcc0d8 100644 --- a/crates/pecos-relay-bp/src/config.rs +++ b/crates/pecos-relay-bp/src/config.rs @@ -85,6 +85,23 @@ impl MinSumConfig { } } + /// Validate min-sum tuning parameters. + /// + /// # Errors + /// + /// Returns a configuration error if a scaling value is not finite or is negative. + pub fn validate(&self) -> crate::errors::Result<()> { + if self + .alpha + .is_some_and(|alpha| !alpha.is_finite() || alpha < 0.0) + { + return Err(crate::errors::RelayBpError::Configuration( + "alpha must be finite and non-negative".to_string(), + )); + } + Ok(()) + } + /// Convert to relay-bp's internal config type. /// /// This creates an `ndarray_016::Array1` (relay-bp's pinned ndarray 0.16), diff --git a/crates/pecos-relay-bp/src/decoder.rs b/crates/pecos-relay-bp/src/decoder.rs index 08dcab23d..6c88b6d0c 100644 --- a/crates/pecos-relay-bp/src/decoder.rs +++ b/crates/pecos-relay-bp/src/decoder.rs @@ -27,6 +27,8 @@ pub struct RelayBpDecoder { inner: relay_bp::bp::relay::RelayDecoder, num_checks: usize, num_bits: usize, + min_sum_config: MinSumConfig, + relay_config: RelayConfig, } impl RelayBpDecoder { @@ -40,6 +42,7 @@ impl RelayBpDecoder { min_sum_config: &MinSumConfig, relay_config: &RelayConfig, ) -> Result { + min_sum_config.validate()?; let num_checks = check_matrix.nrows(); let num_bits = check_matrix.ncols(); @@ -57,6 +60,8 @@ impl RelayBpDecoder { inner, num_checks, num_bits, + min_sum_config: min_sum_config.clone(), + relay_config: relay_config.clone(), }) } @@ -102,6 +107,24 @@ impl RelayBpDecoder { pub fn bit_count(&self) -> usize { self.num_bits } + + /// Get the maximum number of BP iterations. + #[must_use] + pub fn max_iter(&self) -> usize { + self.min_sum_config.max_iter + } + + /// Get the optional min-sum scaling factor. + #[must_use] + pub fn alpha(&self) -> Option { + self.min_sum_config.alpha + } + + /// Get the random seed used for relay parameter sampling. + #[must_use] + pub fn seed(&self) -> u64 { + self.relay_config.seed + } } /// Min-sum BP decoder @@ -112,6 +135,7 @@ pub struct MinSumBpDecoder { inner: relay_bp::bp::min_sum::MinSumBPDecoder, num_checks: usize, num_bits: usize, + config: MinSumConfig, } impl MinSumBpDecoder { @@ -121,6 +145,7 @@ impl MinSumBpDecoder { /// /// Returns [`RelayBpError::InvalidMatrix`] if the check matrix is invalid. pub fn new(check_matrix: &ArrayView2, config: &MinSumConfig) -> Result { + config.validate()?; let num_checks = check_matrix.nrows(); let num_bits = check_matrix.ncols(); @@ -133,6 +158,7 @@ impl MinSumBpDecoder { inner, num_checks, num_bits, + config: config.clone(), }) } @@ -178,6 +204,18 @@ impl MinSumBpDecoder { pub fn bit_count(&self) -> usize { self.num_bits } + + /// Get the maximum number of BP iterations. + #[must_use] + pub fn max_iter(&self) -> usize { + self.config.max_iter + } + + /// Get the optional min-sum scaling factor. + #[must_use] + pub fn alpha(&self) -> Option { + self.config.alpha + } } #[cfg(test)] @@ -204,6 +242,32 @@ mod tests { assert!(result.converged); } + #[test] + fn test_alpha_validation_names_parameter() { + let h = repetition_code_matrix(); + let mut config = MinSumConfig::new(vec![0.1, 0.1, 0.1]); + config.alpha = Some(f64::NAN); + + let error = MinSumBpDecoder::new(&h.view(), &config) + .err() + .unwrap() + .to_string(); + assert!(error.contains("alpha")); + + config.alpha = Some(-0.1); + let error = MinSumBpDecoder::new(&h.view(), &config) + .err() + .unwrap() + .to_string(); + assert!(error.contains("alpha")); + + let error = RelayBpDecoder::new(&h.view(), &config, &RelayConfig::default()) + .err() + .unwrap() + .to_string(); + assert!(error.contains("alpha")); + } + #[test] fn test_relay_decoder() { let h = repetition_code_matrix(); diff --git a/crates/pecos-tesseract/src/decoder.rs b/crates/pecos-tesseract/src/decoder.rs index 517f4d8fd..c2492bcaf 100644 --- a/crates/pecos-tesseract/src/decoder.rs +++ b/crates/pecos-tesseract/src/decoder.rs @@ -67,6 +67,31 @@ impl Default for TesseractConfig { } impl TesseractConfig { + /// Validate configuration values before passing them through FFI. + /// + /// # Errors + /// + /// Returns [`TesseractError::InvalidConfig`] when a numeric tuning parameter + /// is outside its supported range. + pub fn validate(&self) -> Result<(), TesseractError> { + if self.det_beam == 0 { + return Err(TesseractError::InvalidConfig( + "det_beam must be greater than 0".to_string(), + )); + } + if self.pqlimit == 0 { + return Err(TesseractError::InvalidConfig( + "pqlimit must be greater than 0".to_string(), + )); + } + if !self.det_penalty.is_finite() || self.det_penalty < 0.0 { + return Err(TesseractError::InvalidConfig( + "det_penalty must be finite and non-negative".to_string(), + )); + } + Ok(()) + } + /// Create a new configuration with optimized settings for performance #[must_use] pub fn fast() -> Self { @@ -176,6 +201,7 @@ impl TesseractDecoder { /// - The DEM contains unsupported error mechanisms /// - Memory allocation fails pub fn new(dem_string: &str, config: TesseractConfig) -> Result { + config.validate()?; let config_repr = config.to_ffi_repr(); let inner = ffi::create_tesseract_decoder(dem_string, &config_repr) @@ -448,4 +474,65 @@ mod tests { assert!(!config.beam_climbing); assert!(!config.no_revisit_dets); } + + #[test] + fn test_tesseract_config_validation_names_invalid_parameter() { + let mut config = TesseractConfig { + det_beam: 0, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("det_beam") + ); + + config = TesseractConfig { + pqlimit: 0, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("pqlimit") + ); + + config = TesseractConfig { + det_penalty: f64::NAN, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("det_penalty") + ); + + config = TesseractConfig { + det_penalty: -0.1, + ..TesseractConfig::default() + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("det_penalty") + ); + + config = TesseractConfig { + pqlimit: 0, + ..TesseractConfig::default() + }; + let error = TesseractDecoder::new("error(0.1) D0\ndetector D0", config) + .err() + .unwrap() + .to_string(); + assert!(error.contains("pqlimit")); + } } diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs index 36224887b..be54e7399 100644 --- a/crates/pecos/tests/neo_emission_test.rs +++ b/crates/pecos/tests/neo_emission_test.rs @@ -64,14 +64,13 @@ fn rate_zero(shots: &pecos_engines::shot_results::ShotVec) -> (u64, f64) { /// fresh builder each call since `.noise()` consumes it. fn emission_noise_1q() -> pecos_engines::noise::GeneralNoiseModelBuilder { pecos_engines::noise::GeneralNoiseModel::builder() - .with_p1_probability(P1) + .with_p1(P1) .with_p1_emission_ratio(EMISSION) - .with_p2_probability(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) + .with_p2(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) } fn engines_zero_count() -> u64 { @@ -193,14 +192,13 @@ const CX_MEASURE: &str = r#" fn emission_noise_2q() -> pecos_engines::noise::GeneralNoiseModelBuilder { pecos_engines::noise::GeneralNoiseModel::builder() - .with_p1_probability(0.0) - .with_p2_probability(P2) + .with_p1(0.0) + .with_p2(P2) .with_p2_emission_ratio(1.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) } fn engines_2q_zero_count() -> u64 { diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index cf0037d9a..d4495fef5 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -160,10 +160,10 @@ impl NoiseCell { let builder = sim(Qasm::from_string(qasm)).stack(stack).seed(seed); let depol = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { pecos_engines::noise::DepolarizingNoiseModel::builder() - .with_prep_probability(p_prep) - .with_meas_probability(p_meas) - .with_p1_probability(p1) - .with_p2_probability(p2) + .with_p_prep(p_prep) + .with_p_meas(p_meas) + .with_p1(p1) + .with_p2(p2) }; let results = match *self { Self::Meas(p) => builder.noise(depol(0.0, p, 0.0, 0.0)).shots(SHOTS).run(), @@ -176,19 +176,17 @@ impl NoiseCell { .run(), Self::GnmSimple { average_p1, p_meas } => builder .noise( - // GeneralNoiseModel has realistic non-zero defaults; - // zero everything outside the simple Pauli subset so - // the cell physics is exactly known. + // Spell out the zero channels so the cell physics is immediately visible, + // even though GeneralNoiseModel now defaults them off. pecos_engines::noise::GeneralNoiseModel::builder() - .with_average_p1_probability(average_p1) - .with_average_p2_probability(0.0) + .with_average_p1(average_p1) + .with_average_p2(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas), + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas), ) .shots(SHOTS) .run(), @@ -198,21 +196,19 @@ impl NoiseCell { angle_power, } => builder .noise( - // Plain Pauli two-qubit noise with angle scaling; zero - // every other channel and the non-neutral GNM defaults so - // only the angle-scaled RZZ depolarizing noise remains. + // Plain Pauli two-qubit noise with angle scaling; spell out every other + // channel as zero so only angle-scaled RZZ depolarizing noise remains. pecos_engines::noise::GeneralNoiseModel::builder() - .with_p2_probability(p2) + .with_p2(p2) .with_p2_angle_params(a, b, c, d) .with_p2_angle_power(angle_power) - .with_average_p1_probability(0.0) + .with_average_p1(0.0) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0), + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0), ) .shots(SHOTS) .run(), @@ -220,11 +216,11 @@ impl NoiseCell { .noise( // Asymmetric record-flip measurement, no gate/prep noise. pecos_engines::noise::BiasedDepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(p_meas_1) - .with_single_qubit_probability(0.0) - .with_two_qubit_probability(0.0), + .with_p_prep(0.0) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(p_meas_1) + .with_p1(0.0) + .with_p2(0.0), ) .shots(SHOTS) .run(), diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index abfd77804..c745f1cc1 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -176,10 +176,10 @@ fn neo_stack_measurement_noise_rate_matches_engines() { let p_meas = 0.2; let shots = 4000; let noise = pecos_engines::noise::DepolarizingNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_probability(p_meas) - .with_p1_probability(0.0) - .with_p2_probability(0.0); + .with_p_prep(0.0) + .with_p_meas(p_meas) + .with_p1(0.0) + .with_p2(0.0); let engines = sim(x_measure_qasm()) .stack(SimStack::Engines) @@ -274,7 +274,7 @@ fn neo_stack_biased_depolarizing_struct_rate_matches_engines() { #[test] fn neo_stack_general_noise_average_convention_matches() { - // The critical convention test: engines' with_average_p1_probability + // The critical convention test: engines' with_average_p1 // stores p1 = 1.5 x average internally (standard depolarizing // convention), which the mapping carries one-to-one to neo. With // average_p1 = 0.2 the effective depolarizing p1 is 0.3, so the @@ -284,19 +284,8 @@ fn neo_stack_general_noise_average_convention_matches() { let shots = 4000; let expected_flip = 0.2; let run = |stack: SimStack| { - // GeneralNoiseModel defaults are realistic (nonzero emission, prep - // leak, idle, and base probabilities); zero everything except the - // 1q Pauli channel so the physics is plain depolarizing. - let noise = pecos_engines::noise::GeneralNoiseModel::builder() - .with_average_p1_probability(0.2) - .with_p1_emission_ratio(0.0) - .with_p2_emission_ratio(0.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p2_probability(0.0); + // No-effect defaults leave only the explicitly configured 1q Pauli channel. + let noise = pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1(0.2); sim(x_measure_qasm()) .stack(stack) .noise(noise) @@ -321,13 +310,11 @@ fn neo_stack_general_noise_average_convention_matches() { #[test] fn neo_stack_rejects_unmapped_noise() { - // A bare GeneralNoiseModel keeps its realistic defaults for prep leak - // (0.5) and linear idling (0.001) — physics beyond the simple Pauli - // subset, so the mapping must refuse rather than silently change the - // model. (Spontaneous emission IS now mapped, so it is the prep-leak - // and idle defaults that force the rejection here.) - let general = - pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1_probability(0.01); + // Explicit preparation leakage is beyond the simple Pauli subset, so the mapping must refuse + // rather than silently change the model. Spontaneous emission is mapped separately. + let general = pecos_engines::noise::GeneralNoiseModel::builder() + .with_average_p1(0.01) + .with_prep_leak_ratio(0.5); let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .noise(general) @@ -348,15 +335,14 @@ fn neo_stack_rejects_nonunit_emission_scale() { // DIFFERENT emission rate to neo than engines runs. The facade must reject // it rather than silently diverge. (Codex batch-4 finding 1.) let general = pecos_engines::noise::GeneralNoiseModel::builder() - .with_p1_probability(0.3) + .with_p1(0.3) .with_p1_emission_ratio(0.25) .with_emission_scale(2.0) - .with_p2_probability(0.0) - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_p2(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_prep_leak_ratio(0.0); let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .noise(general) diff --git a/crates/pecos/tests/neo_surface_ler_test.rs b/crates/pecos/tests/neo_surface_ler_test.rs index 755089eaa..dc8eb30c6 100644 --- a/crates/pecos/tests/neo_surface_ler_test.rs +++ b/crates/pecos/tests/neo_surface_ler_test.rs @@ -279,10 +279,10 @@ fn shots_to_syndromes( /// Uniform circuit-level depolarizing noise for the engines/neo mapping. fn depolarizing_noise(p: f64) -> pecos_engines::noise::DepolarizingNoiseModelBuilder { pecos_engines::noise::DepolarizingNoiseModel::builder() - .with_prep_probability(p) - .with_meas_probability(p) - .with_p1_probability(p) - .with_p2_probability(p) + .with_p_prep(p) + .with_p_meas(p) + .with_p1(p) + .with_p2(p) } /// Run the experiment on one stack and return its `ShotVec`. diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index 538949c7f..52138258c 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -179,7 +179,7 @@ line. - Runtime-produced `Idle` gates are preserved in the QIS operation trace and replayed into QEC circuits as `TimeUnits` with the convention `1 TimeUnit = 1 ns`. They only affect DEMs when an idle-noise parameter such - as `p_idle`, `t1/t2`, `p_idle_linear_rate`, or `p_idle_quadratic_rate` is set. + as an idle family, `t1`, or `t2` is set. - Keep fail-closed regression coverage for entirely raw traces, transformed scalar results, and aggregate arrays. Generated adapters may expose direct scalar sideband tags while retaining aggregate results for researcher-facing diff --git a/docs/user-guide/decoders.md b/docs/user-guide/decoders.md index e6588ee75..177a58a20 100644 --- a/docs/user-guide/decoders.md +++ b/docs/user-guide/decoders.md @@ -30,12 +30,30 @@ The decoder system in PECOS is designed around modularity and performance: ### Python Decoders -The following decoders are currently available in Python: - -| Decoder | Description | Use Case | -|---------|-------------|----------| -| `MWPM2D` | Minimum Weight Perfect Matching for 2D codes | Surface codes, repetition codes | -| `DummyDecoder` | No-op decoder for testing | Testing and benchmarking | +The following decoder APIs and supporting types are publicly re-exported from +`pecos.decoders`: + +| API | Primary input | Description | +|-----|---------------|-------------| +| `MWPM2D` | QECC object | Legacy minimum-weight perfect matching for 2D codes. | +| `DummyDecoder` | None | No-op decoder for tests and interface benchmarks. | +| `PyMatchingDecoder` | Graph-like DEM text or `CheckMatrix` | PyMatching minimum-weight perfect matching, with optional correlated decoding. | +| `FusionBlossomDecoder` | Check matrix, standard-code parameters, or a manual graph | Pure-Rust minimum-weight perfect matching. | +| `TesseractDecoder` | DEM text | Search-based decoder that accepts raw hyperedges. | +| `DemAwareDecoder` | DEM text | Maps DEM mechanisms and observables onto BP-OSD and other check-matrix decoders. | +| `BpOsdBuilder` / `BpOsdDecoder` | `SparseMatrix` check matrix or DEM text | Belief propagation with ordered-statistics post-processing. | +| `BpLsdBuilder` / `BpLsdDecoder` | `SparseMatrix` check matrix or DEM text | Belief propagation with localized-statistics post-processing. | +| `MinSumBpBuilder` / `MinSumBpDecoder` | Dense check matrix and error priors, or DEM text | Min-sum belief propagation. | +| `RelayBpBuilder` / `RelayBpDecoder` | Dense check matrix and error priors, or DEM text | Relay belief propagation. | +| `UnionFindBuilder` / `UnionFindDecoder` | `SparseMatrix` check matrix or DEM text | Union-find decoding with inversion or peeling. | +| `CheckMatrix` / `SparseMatrix` | Dense or coordinate-form matrix data | Matrix containers used by matching and LDPC decoder constructors. | +| `MwpmResult` / `BpResult` / `TesseractResult` | Decoder output | Result objects for matching, belief-propagation, and Tesseract decoders. | + +Python decoder inputs name their encoding explicitly: use +`decode_syndrome(...)` for a dense detector vector and +`decode_from_defects(...)` for sparse detector indices. The BP/LDPC classes' +`from_dem(...)` constructors return a `DemAwareDecoder` wrapper so their results +include `observable_flips` and their instances retain the DEM dimensions. ### Rust Decoders @@ -68,7 +86,7 @@ The Rust API provides access to a broader set of decoders: pip install quantum-pecos ``` - The Python decoders (`MWPM2D`, `DummyDecoder`) are included by default. + The Python package exports the decoder APIs listed above. === ":fontawesome-brands-rust: Rust" diff --git a/docs/user-guide/dem-from-guppy.md b/docs/user-guide/dem-from-guppy.md index f04552091..1a9156683 100644 --- a/docs/user-guide/dem-from-guppy.md +++ b/docs/user-guide/dem-from-guppy.md @@ -2,8 +2,21 @@ This guide covers `DetectorErrorModel.from_guppy`, which builds a circuit-level detector error model (DEM) from a Guppy program by tracing it -through the Selene QIS engine. This is the recommended way to get a DEM for -a logical circuit you intend to run on a Selene-compatible runtime. +through the Selene QIS engine. Use this entry point when detector and +observable definitions are already available as records, measurement IDs, or +scalar measurement-result tags. + +If the program has only computed detector and observable **values**, choose the +inferred-output workflow instead: + +| Available information | Entry point | +| --- | --- | +| Static parity definitions | `DetectorErrorModel.from_guppy` (this guide) | +| Raw measurements plus computed detector/observable outputs | [`infer_guppy_dem_annotations`](inferred-guppy-dem.md) | +| An annotated `TickCircuit` | `DetectorErrorModel.from_circuit` | + +The inferred-output workflow leaves an existing Guppy program unchanged and +recovers the missing static definitions from its `result()` outputs. ## What You'll Learn @@ -11,7 +24,10 @@ a logical circuit you intend to run on a Selene-compatible runtime. - Referencing measurements with `records`, `meas_ids`, and `result_tags` - Building a DEM for a generated surface-code memory experiment - Sampling and decoding from the resulting DEM -- Choosing the Selene runtime, and the limitations to know about +- Adding explicit idle gates so idle-noise parameters take effect +- Exporting native Stim DEM text and graph-like projections +- Comparing PyMatching, Tesseract, and BP-OSD on the same samples +- Choosing the Selene runtime and understanding the limitations ## Overview @@ -167,8 +183,8 @@ assert batch.num_shots == 1000 decoder = PyMatchingDecoder.from_dem(dem.to_string_decomposed()) errors = 0 for shot in range(batch.num_shots): - predicted = decoder.decode(batch.get_syndrome(shot)).correction[0] - actual = batch.get_observable_mask(shot) & 1 + predicted = decoder.decode_syndrome(batch.get_syndrome(shot)).observable_flips[0] + actual = batch.get_observable_flips(shot)[0] errors += predicted != actual print(f"logical error rate: {errors / batch.num_shots:.4f}") ``` @@ -236,6 +252,446 @@ Because the trace records the runtime-lowered QIS operation stream, a runtime that schedules or lowers differently produces a (correctly) different DEM. +## Grouping Noise Parameters + +Both Guppy DEM entry points accept either the existing flat noise keywords or +one `NoiseParameters` instance containing the complete noise configuration. +`NoiseParameters` is available from the `pecos` top level, and supports both +its dataclass constructor and immutable family/setter chaining. +The grouped and flat forms below are equivalent. Do not mix them in one call: +even explicitly passing a flat parameter at its default value conflicts with +`noise`. When `noise` is present, its defaults fully replace the entry point's +defaults — `NoiseParameters().p1` is `0.0`, not the flat `p1=0.001` default. + +Each `with_` returns a new `NoiseParameters`, so chains never mutate +the object they start from. The idle families are the one exception to the +one-method-per-field rule: each takes its rate and model **together**, because a +model without a rate is inert and the two halves cannot be set in separate +calls. + +```python +from pecos import NoiseParameters + +noise = ( + NoiseParameters() + .with_p1(0.002) + .with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5}) + .with_p_idle_sin_squared(0.03, {"Z": 1.0}) +) +``` + + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos import NoiseParameters +from pecos.qec import DetectorErrorModel + + +@guppy +def noisy_pair() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +common = { + "num_qubits": 2, + "detectors_json": '[{"id": "D0", "result_tags": ["m0"]}]', + "observables_json": '[{"id": "L0", "result_tags": ["m1"]}]', + "seed": 0, +} +noise = NoiseParameters().with_p1(0.002).with_p2(0.004).with_p_meas(0.006).with_p_prep(0.008) + +grouped = DetectorErrorModel.from_guppy(noisy_pair, noise=noise, **common) +flat = DetectorErrorModel.from_guppy( + noisy_pair, + p1=0.002, + p2=0.004, + p_meas=0.006, + p_prep=0.008, + **common, +) +assert grouped.to_string() == flat.to_string() + +try: + DetectorErrorModel.from_guppy(noisy_pair, noise=noise, p1=0.002, **common) +except ValueError as exc: + assert "p1" in str(exc) +else: + raise AssertionError("grouped and flat noise must not be mixed") +``` + +`NoiseParameters.p2_szz` and `p2_szzdg` are gate-type total-rate overrides. +When either is unset, that gate inherits the shared `p2` rate, so omitting both +causes no DEM/simulator divergence. A non-default override is a documented API +gap: it is not expressible through the engines `general_noise()` builder. Neo +can represent the distinction with a `PerGatePauliChannel`, but the standard +Guppy DEM entry points reject explicit `p2_szz` or `p2_szzdg` values rather than +silently dropping them. + +## Idle Noise + +The recommended structured interface has three rate-and-model families. Every +model value is a **relative-rate multiplier**: for each channel, +`channel_rate = family_rate * channel_multiplier`. The linear law is additive, so +its multipliers are exactly the engines relative-probability distribution and +must sum to 1. The nonlinear laws are not additive, so their finite, +non-negative multipliers have no sum constraint. + +- Linear: `p_idle_linear` with `p_idle_linear_model`. An axis fault has + probability `(p_idle_linear * m_axis) * t`. The model keys are `X`, `Y`, and + `Z`, plus the engines leakage key `L`. The default is the uniform + `{X: 1/3, Y: 1/3, Z: 1/3}` engines model. An explicit `L` weight participates + in the sum-to-1 requirement. This is a categorical Pauli channel. DEM + construction first propagates its Pauli branches to detector/observable flip + signatures, discards empty signatures, and adds probabilities for aliases. + Two or more distinct signatures are converted to independent mechanisms; a + non-negative boundary fit and its quantified residual are reported when the + exact conversion would require a negative mechanism. +- Sine-squared: `p_idle_sin_squared` with `p_idle_sin_squared_model`. A Pauli + fault has probability `sin((p_idle_sin_squared * m_axis) * t) ** 2`. The + model keys are `X`, `Y`, `Z`, and `L`; there is no sum constraint. The + symmetric default `{X: 1.0, Y: 1.0, Z: 1.0}` applies the full family rate to + every Pauli axis—these are multipliers, not shares of a normalized total. + To request pure dephasing instead, pass the explicit model `{"Z": 1.0}`. +- Coherent: `p_idle_coherent` with `p_idle_coherent_model`. An axis rotation has + angle `(p_idle_coherent * m_axis) * t`. The symmetric default is + `{RX: 1.0, RY: 1.0, RZ: 1.0}`. The standard DEM builder cannot represent + coherent idle noise, so it rejects every nonzero family rate at call time; + its previous lowering silently stored the Pauli twirl and discarded exactly + the coherence requested. The EEG coherent route in `exp/pecos-eeg` is the + consumer that can represent coherent idle noise, and only with an RZ + generator even there. For an honest stochastic equivalent, the exact Pauli + twirl of `RZ(rate * t)`, use `p_idle_sin_squared=rate/2` with + `p_idle_sin_squared_model={"Z": 1.0}`. A coherent rate of zero or `None` has + no effect. The `RX`, `RY`, and `RZ` model keys are validation-only on this DEM + route; `L` and `U` are not valid coherent-model keys. + +The engines simulators can consume leakage models, such as an engines-bound +linear model `{"X": 0.8, "L": 0.2}`. DEM fault propagation is Pauli-only: +these DEM entry points accept `L` in linear and sine-squared models for model +compatibility, but reject it at call time when its weight is nonzero. A zero +`L` weight is silently accepted. Multi-qubit idle faults are outside the scope +of these keyword arguments and will arrive through a typed channel interface. + +These rates match the engines family setters and runtime application semantics. +The removed quadratic builder spelling was the exception: its input was in +cycles per time and was converted before the runtime saw it. Family sine rates +are in radians per time and are not converted. + +Every residual is readable from `dem.idle_noise_residuals` as a dictionary +containing `channel_kind`, `location_index`, the concrete +`detectors`/`dem_outputs`/`tracked_paulis` signature, `magnitude`, +`channel_weight`, and `relative_magnitude`. Gate and idle categorical Pauli +channels share this list. The channel weight is the sum of the requested +channel's non-identity probabilities before conversion, including branches +whose propagated signature is empty. The magnitude is the total-variation +distance between the requested categorical channel and the emitted independent +mechanisms, and `relative_magnitude = magnitude / channel_weight`; in the +two-dimensional boundary case the absolute magnitude is also the excess on the +reported signature and the matching identity deficit. Audited Guppy builds +copy this list to +`dem_build.audit["idle_noise_residuals"]`. An empty list certifies that all +categorical signature conversions were exact. + +`DetectorErrorModel.builder().with_residual_warning_threshold(fraction)` sets +a relative physics tolerance. For example, `fraction=0.002` accepts an inexact +conversion whose total-variation residual is at most 0.2% of that requested +channel's total error weight. The default is zero, and a build warns when any +residual is greater than the accepted fraction. The threshold gates only that +warning: every exact figure remains in `dem.idle_noise_residuals` and the audit +entry regardless of the tolerance. To suppress warnings wholesale, use +`warnings.filterwarnings`; the builder setter encodes an accepted channel +approximation, not a blanket quiet mode. + +The families are the only public way to configure these idle channels on +`NoiseParameters`. They translate into underscore-prefixed canonical per-axis +fields internally; those fields are implementation details consumed by the +Rust DEM boundary, not public constructor arguments or fluent setters. + +Migration from the removed setters is mechanical: + +| Removed | Replacement | +|---|---| +| `with_p_idle_z_linear_rate(r)` | `with_p_idle_linear(r, {"Z": 1.0})` | +| `with_p_idle_x_quadratic_sine_rate(r)` | `with_p_idle_sin_squared(r, {"X": 1.0})` | +| `with_p_idle_linear_rate(r)` | `with_p_idle_linear(r, {"Z": 1.0})` | + +The last row is intentionally Z-only: despite its axis-free name, +`NoiseParameters.with_p_idle_linear_rate` configured only the Z channel. The +identically named setter on `general_noise()` configured a total linear rate +split according to its model and has now also been removed. Migrate that +engines spelling to `with_p_idle_linear(r, model)`, passing the symmetric +`{"X": 1/3, "Y": 1/3, "Z": 1/3}` model if the old model setter was not used. +Copying a numeric value between the two old interfaces did not preserve the +channel. + +The engines quadratic migration has a unit conversion that must not be +omitted. At the removed path's default coherent-to-incoherent factor of `1.0`: + +```text +with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0}) +``` + +The left-hand rate was in cycles per time; the family rate is in radians per +time. The orphaned `with_p_idle_coherent_to_incoherent_factor` setter has no +replacement. The two `with_average_p_idle_*_rate` spellings were also removed: +a gate-channel average-error conversion does not define a duration-independent +idle-family rate, especially for the nonlinear sine-squared law. + +The default Selene runtime does not emit idle gates. These parameters and +`t1`/`t2` therefore have no locations to attach to unless the runtime supplies +scheduled idles or you insert them explicitly. `from_guppy` raises +`ValueError` when an idle-noise rate is supplied but the final traced circuit +contains no `Idle` gates; it does not silently build a DEM without the +requested noise. + +Both `DetectorErrorModel.from_guppy` and `build_dem_from_guppy` accept two +passes for controlling those locations: + +- `strip_traced_idles=True` removes identity-like gates from the normalized + trace, including `I`, `Idle`, and zero-angle rotations. +- `idle_after_2q_duration=` inserts an `Idle` of that duration + on both qubits after every two-qubit gate. + +Stripping runs before insertion. By default, setting `idle_after_2q_duration` +also strips first: inserting a uniform idle convention on top of +runtime-emitted idles would double-count idle noise. Pass +`strip_traced_idles=False` explicitly to keep runtime-emitted idles alongside +the inserted ones. + + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import DetectorErrorModel + + +@guppy +def idle_demo() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +common = { + "num_qubits": 2, + "detectors_json": '[{"id": "D0", "result_tags": ["m0"]}]', + "observables_json": '[{"id": "L0", "result_tags": ["m1"]}]', + "p1": 0.0, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + "seed": 0, +} + +without_idle_noise = DetectorErrorModel.from_guppy( + idle_demo, + idle_after_2q_duration=1.0, + **common, +) +with_idle_noise = DetectorErrorModel.from_guppy( + idle_demo, + idle_after_2q_duration=1.0, + p_idle_linear=0.01, + p_idle_linear_model={"X": 0.25, "Z": 0.75}, + p_idle_sin_squared=0.02, + **common, +) + + +def count_errors(model: DetectorErrorModel) -> int: + return model.to_string().count("error(") + + +assert count_errors(with_idle_noise) > count_errors(without_idle_noise) + +try: + DetectorErrorModel.from_guppy(idle_demo, p_idle_linear=0.01, **common) +except ValueError as exc: + assert "idle-noise parameters have no idle gates" in str(exc) +else: + raise AssertionError("idle noise without Idle gates should fail") +``` + +Runtime-emitted idle durations are replayed as nanosecond `TimeUnits`. +Inserted idles instead carry the duration passed to +`idle_after_2q_duration`, which must be finite and positive. Linear and +sine-law idle rates are per time unit. For example, uniform linear idle noise +uses `(p_idle_linear / 3) * duration` per Pauli axis, clamped to the probability +range. The low-level coefficient-style quadratic rates multiply `duration**2` +and therefore scale as inverse time squared. T1 and T2 values must use the same +units as the idle duration. + +## Exporting the DEM as Stim Text + +PECOS's native DEM text is the Stim DEM format; there is no separate +`to_stim()` conversion. `dem.to_string()` emits standard +`error(p) D... L...` mechanisms and can be parsed directly as a Stim detector +error model. The export itself has no extra dependency. + +`dem.to_string_decomposed()` uses decomposition components attached to the +original fault source, writing `^`-separated components when that provenance +is available. It preserves residual hyperedges when a true hyperedge has no +source-attached decomposition. Graph matchers instead need +`dem.to_string_terminal_graphlike_decomposed()`, an explicitly lossy +hyperedge-to-edge projection based on detector terminals rather than a proof +of source provenance. + + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import DetectorErrorModel + + +@guppy +def idle_demo() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +dem = DetectorErrorModel.from_guppy( + idle_demo, + num_qubits=2, + detectors_json='[{"id": "D0", "result_tags": ["m0"]}]', + observables_json='[{"id": "L0", "result_tags": ["m1"]}]', + idle_after_2q_duration=1.0, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_linear=0.01, + seed=0, +) + +raw_text = dem.to_string() +source_decomposed_text = dem.to_string_decomposed() +graphlike_text = dem.to_string_terminal_graphlike_decomposed() + +print(raw_text) +print(source_decomposed_text) +print(graphlike_text) +assert "error(" in raw_text +``` + +To verify interoperability against Stim itself, install the optional extra +(`pip install "quantum-pecos[stim]"`) — the base install does not depend on +stim: + + +```python +import stim +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import DetectorErrorModel + + +@guppy +def idle_demo() -> None: + q0, q1 = qubit(), qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +dem = DetectorErrorModel.from_guppy( + idle_demo, + num_qubits=2, + detectors_json='[{"id": "D0", "result_tags": ["m0"]}]', + observables_json='[{"id": "L0", "result_tags": ["m1"]}]', + idle_after_2q_duration=1.0, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_linear=0.01, + seed=0, +) + +stim.DetectorErrorModel(dem.to_string()) +stim.DetectorErrorModel(dem.to_string_decomposed()) +stim.DetectorErrorModel(dem.to_string_terminal_graphlike_decomposed()) +``` + +## Decoding: PyMatching, Tesseract, and BP-OSD + +A sampled `SampleBatch` provides the uniform +`batch.decode_count(dem_text, name)` interface. The names used here are +`"pymatching"` (correlated matching by default), `"tesseract"`, and +`"bp_osd"`. Passing the same batch to each decoder compares them on identical +shots rather than on three independently sampled experiments. + + +```python +from pecos.decoders import BpOsdDecoder, TesseractDecoder +from pecos.guppy_gen import get_num_qubits, make_surface_code +from pecos.qec import DetectorErrorModel +from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + +patch = SurfacePatch.create(distance=3) +meta_tc = generate_tick_circuit_from_patch(patch, num_rounds=3, basis="Z") +dem = DetectorErrorModel.from_guppy( + make_surface_code(distance=3, num_rounds=3, basis="Z"), + num_qubits=get_num_qubits(3), + detectors_json=meta_tc.get_meta("detectors"), + observables_json=meta_tc.get_meta("observables"), + num_measurements=int(meta_tc.get_meta("num_measurements")), + p1=0.005, + p2=0.005, + p_meas=0.005, + p_prep=0.005, +) + +batch = dem.to_sampler().sample_batch(1000, 0) +error_counts = { + "pymatching": batch.decode_count( + dem.to_string_terminal_graphlike_decomposed(), + "pymatching", + ), + "tesseract": batch.decode_count( + dem.to_string_source_graphlike_decomposed(), + "tesseract", + ), + "bp_osd": batch.decode_count(dem.to_string(), "bp_osd"), +} +assert all(0 <= count <= batch.num_shots for count in error_counts.values()) +print(error_counts) + +# Construct a decoder directly when you need per-shot results. The "fast" +# preset matches the configuration decode_count(..., "tesseract") uses. +syndrome = batch.get_syndrome(0) +tesseract = TesseractDecoder.from_dem(dem.to_string(), preset="fast") +tesseract_result = tesseract.decode_syndrome(syndrome) +assert tesseract_result.observable_flips.mask >= 0 + +bp_osd = BpOsdDecoder.from_dem(dem.to_string()) +bp_osd_result = bp_osd.decode_syndrome(syndrome) +assert bp_osd_result.observable_flips.mask >= 0 +``` + +For direct PyMatching construction, use the +`PyMatchingDecoder.from_dem(...)` pattern in the +[surface-memory example](#surface-code-memory-dem). That DEM's source-attached +decomposition is already graph-like; in general, matching decoders require the +terminal-decomposed graph-like projection. Tesseract and BP-OSD can consume the +raw hyperedge DEM directly; the batch comparison above uses the established +source-graphlike form for Tesseract so it matches the QEC-with-Guppy workflow. + ## Limitations - **Measurement-dependent quantum control flow is unsupported and @@ -260,10 +716,10 @@ different DEM. - **`num_qubits` is required** for HUGR-bytes programs; use `get_num_qubits(...)` for the built-in generators. - **Idle noise needs idle gates.** The default simple runtime does not emit - explicit idles, while other compatible runtimes may emit scheduled idle - durations. Runtime-emitted idles are preserved in the traced circuit as - nanosecond `TimeUnits`; idle/T1/T2 noise parameters apply only where those - gates are present. + explicit idles. Use `idle_after_2q_duration` to insert them, optionally after + `strip_traced_idles` removes runtime-provided identity-like gates. Passing + idle-noise parameters without any final `Idle` gates raises `ValueError`; see + [Idle Noise](#idle-noise). - **Hand-authored tracked-Pauli observables are rejected** in `observables_json`; tracked Paulis come from circuit annotations only. diff --git a/docs/user-guide/fault-catalog.md b/docs/user-guide/fault-catalog.md index f5a7e2f18..8d5700ce1 100644 --- a/docs/user-guide/fault-catalog.md +++ b/docs/user-guide/fault-catalog.md @@ -28,7 +28,7 @@ print(f"{len(result)} shots, {len(result[0])} measurements each") If you want to inspect what faults are possible in that circuit: - + ```python from pecos_rslib_exp import fault_catalog @@ -106,7 +106,6 @@ structural fields like `affected_detectors` will be empty, but The expensive work (Pauli propagation, detector mapping) is done once during construction. Changing noise is cheap -- it just updates probability fields: - ```python catalog = fault_catalog(circuit) @@ -126,7 +125,6 @@ update existing decoders or plans. The returned object is sequence-like: - ```python print(len(catalog)) print(catalog[0]) @@ -419,7 +417,6 @@ catalog.with_noise(&noise); Iterate locations and alternatives: - ```rust for loc in &catalog.locations { println!( @@ -446,7 +443,6 @@ for loc in &catalog.locations { Iterate configurations: - ```rust for event in catalog.fault_configurations(2) { println!( diff --git a/docs/user-guide/hugr-simulation.md b/docs/user-guide/hugr-simulation.md index 1c2373722..da6d53b12 100644 --- a/docs/user-guide/hugr-simulation.md +++ b/docs/user-guide/hugr-simulation.md @@ -423,11 +423,11 @@ Add realistic noise to your Guppy simulations: # Custom noise model noise = ( GeneralNoiseModelBuilder() - .with_prep_probability(0.001) - .with_p1_probability(0.0001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.03) + .with_p_prep(0.001) + .with_p1(0.0001) + .with_p2(0.01) + .with_p_meas_0(0.02) + .with_p_meas_1(0.03) ) results = sim(Guppy(noisy_bell)).qubits(2).quantum(state_vector()).noise(noise).run(1000) diff --git a/docs/user-guide/inferred-guppy-dem.md b/docs/user-guide/inferred-guppy-dem.md new file mode 100644 index 000000000..ceddbfe60 --- /dev/null +++ b/docs/user-guide/inferred-guppy-dem.md @@ -0,0 +1,390 @@ +# Inferring a DEM from Guppy Outputs + +Use `infer_guppy_dem_annotations` when a Guppy program already emits raw +physical measurements, detector bits, and logical-observable bits with +`result()`, but does not separately expose the measurement parities that +define those detectors and observables. The program does not need to be +edited: PECOS infers the parities, binds them to the runtime QIS trace, and +builds the detector error model (DEM) with native PECOS fault propagation. +Stim is not used. + +## Choose the right entry point + +| What the application already has | Use | +| --- | --- | +| Raw measurements plus detector and observable **values** computed by Guppy | `infer_guppy_dem_annotations` (this guide) | +| Audited detector and observable **definitions** using records, measurement IDs, or scalar result tags | [`DetectorErrorModel.from_guppy`](dem-from-guppy.md) | +| An annotated `TickCircuit` | `DetectorErrorModel.from_circuit` | + +The inferred-output workflow is especially useful for generated Guppy that +collects measurements into arrays or computes round-to-round parities inside +the program. + +## Program contract + +Before calling the tool, check all of the following: + +1. Every physical measurement is emitted exactly once through one or more + `result(raw_tag, ...)` calls. Do not omit initialization, syndrome, flag, + postselection, or final-readout measurements. +2. Detector and observable outputs are Boolean XOR parities of those raw + measurements. AND, OR, nonlinear expressions, constant-one offsets, and + outputs independent of every measurement are rejected. +3. Measurement values may affect classical parity calculations, but must not + change the quantum gate schedule. Measurement-dependent quantum branches + and repeated-until-success loops need a different analysis. +4. The supplied `num_qubits` is large enough to run the program through the + selected Selene runtime. + +Tag names are case-sensitive. The defaults are `"raw measurements"`, +`"DETECTOR"`, and `"obs"`; all are configurable. Repeated calls with the same +tag are concatenated in execution order. An array-valued call contributes its +elements in array order. + +## Quick start + +This two-measurement example is the smallest complete workflow. Strict +provenance determines the physical identity of each raw output automatically. + + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def parity_readout() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + + # Emit each physical result exactly once under the raw tag. + result("raw measurements", m0) + result("raw measurements", m1) + + # These are computed values, not additional measurements. + result("DETECTOR", m0 ^ m1) + result("obs", m0) + + +inferred = infer_guppy_dem_annotations( + parity_readout, + num_qubits=2, + seed=7, +) + +assert inferred.raw_measurement_ids == (0, 1) +assert inferred.detector_supports == ((0, 1),) +assert inferred.observable_supports == ((0,),) +assert inferred.raw_binding in { + "runtime_result_ids", + "probe_correlated_result_ids", +} + +dem = inferred.build_dem( + p1=0.001, + p2=0.005, + p_meas=0.005, + p_prep=0.001, +) +assert dem.num_detectors == 1 +assert dem.num_observables == 1 +print(dem.to_string()) +``` + +The two accepted `raw_binding` values are both identity-preserving. A compiler +may retain a directly tagged measurement ID, or it may erase the ID while +duplicating the value into raw and computed outputs; strict probe correlation +handles either lowering. + +The returned supports contain QIS `MeasId` values. PECOS writes equivalent +`meas_ids` entries into `inferred.detectors_json` and +`inferred.observables_json`, attaches them to `inferred.circuit`, and passes +the annotated circuit to `DetectorErrorModel.from_circuit` when `build_dem` +is called. + +For an existing program, the integration itself is only this call: + + +```python +inferred = infer_guppy_dem_annotations( + existing_guppy_program, + num_qubits=program_qubit_count, + raw_tag="raw measurements", + detector_tag="DETECTOR", + observable_tags=("obs",), +) +dem = inferred.build_dem(p1=0.001, p2=0.005, p_meas=0.005, p_prep=0.001) +``` + +No separate trace-capture call is required. The function runs the coin-toss +probes and captures the QIS trace internally. + +## Example: rounds and aggregate arrays + +Real memory experiments commonly keep earlier syndromes, emit measurements in +arrays, and form final-boundary detectors from the last syndrome and data +readout. This two-data-qubit repetition memory demonstrates that pattern with +four physical measurements. + + +```python +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import cx, measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def repetition_memory() -> None: + d0, d1 = qubit(), qubit() + + a0 = qubit() + cx(d0, a0) + cx(d1, a0) + s0 = measure(a0) + result("DETECTOR", s0) + + a1 = qubit() + cx(d0, a1) + cx(d1, a1) + s1 = measure(a1) + result("DETECTOR", s0 ^ s1) + + m0, m1 = measure(d0), measure(d1) + result("DETECTOR", s1 ^ m0 ^ m1) + result("obs", m0) + + # Raw arrays can be emitted after the parities have been computed. + result("raw measurements", array(s0, s1)) + result("raw measurements", array(m0, m1)) + + +inferred = infer_guppy_dem_annotations( + repetition_memory, + num_qubits=4, + probe_shots=64, + provenance_shots=32, + validation_rows=16, + seed=11, +) + +assert inferred.raw_measurement_ids == (0, 1, 2, 3) +assert inferred.detector_supports == ( + (0,), + (0, 1), + (1, 2, 3), +) +assert inferred.observable_supports == ((2,),) +assert inferred.raw_binding == "probe_correlated_result_ids" + +dem = inferred.build_dem(p1=0.001, p2=0.005, p_meas=0.005, p_prep=0.001) +assert dem.num_detectors == 3 +assert dem.num_observables == 1 +``` + +Array indexing, copying, and aggregation can erase element-level result IDs in +the compiled path. In strict mode, PECOS recovers them by correlating each raw +output column with result-ID-keyed physical outcomes over independent +coin-toss traces. `probe_correlated_result_ids` records that stronger binding. + +## Example: raw outputs in a different order + +Do not assume raw-array order is physical measurement order. Strict provenance +tracks the identity of each element even when an array is reordered. + + +```python +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def reordered_readout() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + m2 = measure(qubit()) + result("DETECTOR", m2 ^ m0) + result("obs", m1) + result("raw measurements", array(m2, m0, m1)) + + +inferred = infer_guppy_dem_annotations( + reordered_readout, + num_qubits=3, + probe_shots=32, + provenance_shots=24, + validation_rows=8, + seed=13, +) + +# Array order is m2, m0, m1; the IDs preserve physical identity. +assert inferred.raw_measurement_ids == (2, 0, 1) +assert inferred.detector_supports == ((2, 0),) +assert inferred.observable_supports == ((1,),) +assert inferred.raw_binding == "probe_correlated_result_ids" +``` + +This distinction matters whenever an intermediate array is assembled in an +order that differs from the runtime measurement record. + +## Example: custom tags and several observables + +Pass every logical-result tag in the desired DEM observable order. An +array-valued observable expands into one DEM observable per element. + + +```python +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import measure, qubit + +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def tagged_readout() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + m2 = measure(qubit()) + result("physical", array(m0, m1, m2)) + result("events", m0 ^ m1) + result("logical_z", m0) + result("logical_x", array(m1, m2)) + + +inferred = infer_guppy_dem_annotations( + tagged_readout, + num_qubits=3, + raw_tag="physical", + detector_tag="events", + observable_tags=("logical_z", "logical_x"), + probe_shots=32, + provenance_shots=24, + validation_rows=8, + seed=17, +) + +assert inferred.detector_supports == ((0, 1),) +assert inferred.observable_supports == ((0,), (1,), (2,)) +assert inferred.observable_labels == ( + ("logical_z", 0), + ("logical_x", 0), + ("logical_x", 1), +) +``` + +## Measurement identity modes + +Leave `require_raw_provenance=True`, the default, whenever possible: + +| `raw_binding` | Meaning | +| --- | --- | +| `runtime_result_ids` | Every raw output retained its QIS measurement ID directly. | +| `probe_correlated_result_ids` | PECOS recovered a unique complete ID mapping from independent probe signatures. | +| `assumed_canonical_result_order` | Strict identity was disabled and PECOS used positional order. | + +Correlation fails loudly if the quantum schedule changes, a raw element is a +computed value instead of a direct measurement, a measurement is omitted or +duplicated, or two physical signatures collide. Increasing +`provenance_shots` resolves a rare signature collision; it cannot repair an +incomplete or computed raw record. + +The weak fallback is explicit: + + +```python +inferred = infer_guppy_dem_annotations( + program, + num_qubits=7, + require_raw_provenance=False, +) +assert inferred.raw_binding == "assumed_canonical_result_order" +``` + +Use it only when the application independently guarantees that the +concatenated raw values occur exactly once each in physical measurement order. +It checks the measurement count, but it cannot detect a permutation. + +## Parameters and outputs + +`infer_guppy_dem_annotations` accepts: + +| Argument | Default | Purpose | +| --- | --- | --- | +| `program` | required | Compiled or compilable Guppy entry point. | +| `num_qubits` | required keyword | Runtime qubit capacity. | +| `raw_tag` | `"raw measurements"` | Tag containing every physical measurement exactly once. | +| `detector_tag` | `"DETECTOR"` | Tag containing all computed detection-event bits. | +| `observable_tags` | `("obs",)` | Logical result tags, in DEM observable order. | +| `probe_shots` | `256` | Coin-toss rows used to infer and validate affine parities. | +| `provenance_shots` | `32` | Rows used to correlate array elements with QIS IDs. | +| `validation_rows` | `32` | Probe rows reserved for parity validation. | +| `seed` | `0` | Reproducible trace and probe seed. | +| `runtime` | `None` | Selene runtime selection forwarded to PECOS. | +| `require_raw_provenance` | `True` | Require a complete identity-preserving raw-measurement binding. | + +The result is an `InferredGuppyDemAnnotations` with: + +| Attribute | Contents | +| --- | --- | +| `circuit` | Runtime-lowered `TickCircuit` with detector and observable metadata attached. | +| `detectors_json`, `observables_json` | Serialized definitions using QIS `meas_ids`. | +| `raw_measurement_ids` | One physical ID per raw output element, in emitted order. | +| `detector_supports`, `observable_supports` | Inferred parity supports expressed as physical IDs. | +| `observable_labels` | `(tag, element_index)` for each DEM observable. | +| `raw_binding` | Identity mode from the table above. | +| `probe_shots` | Number of parity-inference rows used. | +| `build_dem(**noise)` | Build a PECOS `DetectorErrorModel` from the annotated circuit. | + +`probe_shots` must provide at least one row per raw measurement, one affine +constant column, and the requested validation rows. For larger experiments, +increase it if PECOS reports insufficient GF(2) rank. + +## Failure guide + +| Error or symptom | Meaning | Action | +| --- | --- | --- | +| `missing required tag(s)` | A configured tag was never emitted. | Check spelling and case, or pass the matching tag arguments. | +| `must expose every physical measurement exactly once` | The raw stream omitted or duplicated a measurement. | Include initialization, syndrome, postselection, and final-readout measurements exactly once. | +| `not a direct physical measurement` | A raw element is computed from measurements. | Emit the original measurement value; keep computed values under detector/observable tags. | +| `signatures are ambiguous` | Too few provenance probes caused an identity collision. | Increase `provenance_shots` or change `seed`. | +| `not affine` or `constant-one` | An output is not a representable XOR parity. | Replace nonlinear/offset post-processing with explicit parity outputs, or provide audited static definitions. | +| `quantum operation schedule changed` | A measurement changed which quantum operations ran. | Build separate justified models per static path; do not use a single inferred DEM. | +| Native fault propagation rejects a gate such as `T` | The traced circuit is outside PECOS's Pauli/Clifford propagation support. | Supply a separately justified Clifford model or use a different analysis. | + +## Soundness and leakage boundaries + +The prototype establishes two empirical facts: + +1. Emitted detector and observable bits fit unique affine GF(2) parities of + the raw physical measurements, including independent validation rows. +2. The raw measurements bind to one runtime-lowered QIS circuit trace. + +It is an empirical certificate, not a compiler proof over every possible +classical-control path. The probability of accidental probe-signature +collisions decreases exponentially with the number of probes. + +The QIS tracer safely preserves leakage-aware measurement outcomes `0`, `1`, +and `2`, including Guppy's `is_leaked()` check. Parity inference itself is +Boolean and `build_dem` performs Pauli fault propagation, so the returned DEM +represents the accepted no-leakage path. It does **not** model leakage rate, +postselection probability, or rejected-shot behavior. A leakage check that +changes later quantum operations also violates the static-schedule contract. + +Repeated-until-success loops are outside this workflow because different +attempt counts produce different quantum schedules. Non-Clifford protocols, +including magic-state preparation or cultivation containing native `T` gates, +may have detector parities that can be inferred, but PECOS will reject DEM +construction unless the traced circuit has a separately justified supported +fault-propagation model. + +When definitions are already known rather than computed only as outputs, use +the audited typed workflow in [Detector Error Models from Guppy +Programs](dem-from-guppy.md). diff --git a/docs/user-guide/noise-model-builders.md b/docs/user-guide/noise-model-builders.md index 02eaf751b..7df79fed9 100644 --- a/docs/user-guide/noise-model-builders.md +++ b/docs/user-guide/noise-model-builders.md @@ -28,8 +28,8 @@ measure q -> c; noise = ( GeneralNoiseModelBuilder() .with_seed(42) # Reproducible randomness - .with_p1_probability(0.001) # Single-qubit gate error - .with_p2_probability(0.01) + .with_p1(0.001) # Single-qubit gate error + .with_p2(0.01) ) # Two-qubit gate error # Use with sim() @@ -46,12 +46,12 @@ The `GeneralNoiseModelBuilder` provides methods to configure all aspects of quan noise = ( GeneralNoiseModelBuilder() # Gate errors - .with_p1_probability(0.001) # Single-qubit gate error - .with_p2_probability(0.01) # Two-qubit gate error + .with_p1(0.001) # Single-qubit gate error + .with_p2(0.01) # Two-qubit gate error # State preparation and measurement - .with_prep_probability(0.0005) # State preparation error - .with_meas_0_probability(0.002) # Measurement 0→1 flip - .with_meas_1_probability(0.003) + .with_p_prep(0.0005) # State preparation error + .with_p_meas_0(0.002) # Measurement 0→1 flip + .with_p_meas_1(0.003) ) # Measurement 1→0 flip ``` @@ -61,16 +61,10 @@ The builder supports both "total" and "average" error probabilities: ```python # Average probability (recommended for physical intuition) -noise = ( - GeneralNoiseModelBuilder() - .with_average_p1_probability(0.001) # Converted to total internally - .with_average_p2_probability(0.01) -) +noise = GeneralNoiseModelBuilder().with_average_p1(0.001).with_average_p2(0.01) # Converted to total internally # Total probability (used internally by the engine) -noise = ( - GeneralNoiseModelBuilder().with_p1_probability(0.00133).with_p2_probability(0.0133) # Total for single-qubit -) # Total for two-qubit +noise = GeneralNoiseModelBuilder().with_p1(0.00133).with_p2(0.0133) # Total for single-qubit # Total for two-qubit ``` **Note**: Average probabilities are more intuitive as they represent the actual error rate per gate. Total probabilities include a conversion factor based on the number of Pauli operators. @@ -120,8 +114,8 @@ Make specific gates ideal (no noise): ```python noise = ( GeneralNoiseModelBuilder() - .with_p1_probability(0.001) - .with_p2_probability(0.01) + .with_p1(0.001) + .with_p2(0.01) # Single gate .with_noiseless_gate("H") # Multiple gates @@ -134,13 +128,69 @@ noise = ( ### Idle Locations `Idle` gates are timing markers by default. They do not silently inherit -single-qubit gate noise from `p1` or `with_p1_probability(...)`. +single-qubit gate noise from `p1` or `with_p1(...)`. + +Configure idle decoherence with any combination of these independent families: + +- `with_p_idle_linear(rate, model)` samples one linear-rate event from a + normalized X/Y/Z/L distribution. +- `with_p_idle_sin_squared(rate, model)` independently samples each X/Y/Z/L + mechanism with `sin²(rate * multiplier * duration)`. Its rate is radians per + time unit and its multipliers are intentionally unnormalized because each + axis has its own rate. +- `with_p_idle_coherent(rate, model)` deterministically applies RX/RY/RZ with + angle `rate * multiplier * duration`. Its rate is radians per time unit, with + no `2*pi` or coherent-to-incoherent conversion. Its model is also + intentionally unnormalized: the values are relative generator-rate + multipliers, not probabilities. Omitting the Python model uses + `{"RX": 1.0, "RY": 1.0, "RZ": 1.0}`. + +The unpaired legacy idle setters have been removed. Migrate them as follows: + +| Removed setter | Replacement | +|---|---| +| `with_p_idle_linear_rate(r)` | `with_p_idle_linear(r, model)`; use the symmetric `{"X": 1/3, "Y": 1/3, "Z": 1/3}` model if no model was previously set | +| `with_p_idle_linear_model(m)` | `with_p_idle_linear(r, m)`; the rate and normalized model are now configured together | +| `with_p_idle_quadratic_rate(r)` | `with_p_idle_sin_squared(r * PI, {"Z": 1.0})` | +| `with_p_idle_quadratic_coherent(true)` | `with_p_idle_coherent(rate, model)`; choose the coherent family instead of switching another law's mode | +| `with_p_idle_quadratic_coherent(false)` | `with_p_idle_sin_squared(rate, model)`; choose the stochastic family directly | +| `with_p_idle_coherent_to_incoherent_factor(f)` | No replacement; the factor only modified the removed quadratic-rate path | +| `with_average_p_idle_linear_rate(r)` / `with_average_p_idle_quadratic_rate(r)` | No replacement; a gate-channel average-error conversion is not duration independent for a rate-times-duration law | + +The old quadratic rate was in cycles per time and was converted before it +reached the runtime. Family rates are in radians per time and receive no such +conversion. At the removed path's default factor of `1.0`, the exact migration +is: + +```text +with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0}) +``` + +Copying `r` directly into the family setter changes the channel by a factor of +pi. + +Coherent evolution is not sampled and consumes no RNG draws. Whether it can be +consumed depends on the downstream consumer: the standard DEM builder rejects +coherent idle noise, the EEG route in `exp/pecos-eeg` represents it with an RZ +generator, and a simulator applies it only when it has a rotation executor. +PECOS #437 tracks the case where a missing executor silently dropped rotations. -This is intentional: adding an idle location changes circuit timing, while -adding idle noise changes the physical noise model. To model idle decoherence, -use an API that explicitly attaches idle noise or an explicit channel to idle -locations. This keeps scheduling changes from accidentally changing the noise -model. +To add the same kind of idle-noise site to both qubits after every two-qubit +gate, set its duration with `with_idle_after_2q(...)`: + +```python +noise = ( + GeneralNoiseModelBuilder().with_p_idle_linear(0.01, {"X": 1 / 3, "Y": 1 / 3, "Z": 1 / 3}).with_idle_after_2q(1.0) +) +``` + +The duration only chooses where and how long idling occurs. It is not a +standalone probability: all configured linear, sine-squared, and coherent idle +families apply at these sites just as they do at a +scheduled `Idle` gate. A duration of `0.0` disables the after-two-qubit sites. +Consequently, code that previously used `with_p2_idle(0.01)` without a linear +idle rate now produces no after-2q idle noise; the equivalent configuration is +`with_p_idle_linear(0.01, {"X": 1/3, "Y": 1/3, "Z": 1/3}).with_idle_after_2q(1.0)`. ## Common Noise Model Examples @@ -151,12 +201,7 @@ Simple uniform noise on all operations: ```python # Uniform depolarizing noise noise = ( - GeneralNoiseModelBuilder() - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_prep_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001) + GeneralNoiseModelBuilder().with_p1(0.001).with_p2(0.01).with_p_prep(0.001).with_p_meas_0(0.001).with_p_meas_1(0.001) ) ``` @@ -169,12 +214,12 @@ noise = ( GeneralNoiseModelBuilder() .with_seed(42) # Gate errors (two-qubit gates are typically 10x worse) - .with_average_p1_probability(0.0001) # 0.01% single-qubit error - .with_average_p2_probability(0.001) # 0.1% two-qubit error + .with_average_p1(0.0001) # 0.01% single-qubit error + .with_average_p2(0.001) # 0.1% two-qubit error # State prep and measurement (often dominant errors) - .with_prep_probability(0.001) # 0.1% prep error - .with_meas_0_probability(0.01) # 1% false positive - .with_meas_1_probability(0.005) + .with_p_prep(0.001) # 0.1% prep error + .with_p_meas_0(0.01) # 1% false positive + .with_p_meas_1(0.005) ) # 0.5% false negative ``` @@ -187,14 +232,14 @@ noise = ( GeneralNoiseModelBuilder() .with_seed(42) # Excellent single-qubit gates - .with_average_p1_probability(0.00001) # 0.001% error + .with_average_p1(0.00001) # 0.001% error # Two-qubit gates are the limiting factor - .with_average_p2_probability(0.003) # 0.3% error + .with_average_p2(0.003) # 0.3% error # State preparation - .with_prep_probability(0.001) # 0.1% error + .with_p_prep(0.001) # 0.1% error # Asymmetric measurement (bright/dark state detection) - .with_meas_0_probability(0.001) # Dark state error - .with_meas_1_probability(0.005) + .with_p_meas_0(0.001) # Dark state error + .with_p_meas_1(0.005) ) # Bright state error (higher) ``` @@ -206,7 +251,7 @@ Model with biased errors (e.g., more phase errors than bit flips): noise = ( GeneralNoiseModelBuilder() # Biased single-qubit errors - .with_average_p1_probability(0.001) + .with_average_p1(0.001) .with_p1_pauli_model( { "X": 0.1, # 10% bit flips @@ -215,7 +260,7 @@ noise = ( } ) # Biased two-qubit errors - .with_average_p2_probability(0.01) + .with_average_p2(0.01) .with_p2_pauli_model( { "IZ": 0.3, # 30% phase on second qubit @@ -259,9 +304,9 @@ noise = ( # Make Hadamard gates perfect .with_noiseless_gate("H") # State preparation - .with_prep_probability(0.001) + .with_p_prep(0.001) # Single-qubit gates with biased errors - .with_average_p1_probability(0.0001) + .with_average_p1(0.0001) .with_p1_pauli_model( { "X": 0.2, @@ -270,10 +315,10 @@ noise = ( } ) # Two-qubit gates - .with_average_p2_probability(0.001) + .with_average_p2(0.001) # Asymmetric measurement - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.005) + .with_p_meas_0(0.002) + .with_p_meas_1(0.005) ) # Run simulation @@ -314,11 +359,11 @@ simple = depolarizing_noise().with_uniform_probability(0.001) # Equivalent with GeneralNoiseModelBuilder builder = ( GeneralNoiseModelBuilder() - .with_p1_probability(0.001) - .with_p2_probability(0.001) - .with_prep_probability(0.001) - .with_meas_0_probability(0.001) - .with_meas_1_probability(0.001) + .with_p1(0.001) + .with_p2(0.001) + .with_p_prep(0.001) + .with_p_meas_0(0.001) + .with_p_meas_1(0.001) ) # Builder advantages: diff --git a/docs/user-guide/qasm-simulation.md b/docs/user-guide/qasm-simulation.md index 4b8adaf03..fa187bd1c 100644 --- a/docs/user-guide/qasm-simulation.md +++ b/docs/user-guide/qasm-simulation.md @@ -232,10 +232,10 @@ Real quantum computers are noisy. PECOS helps you understand how noise affects y # Custom depolarizing per operation type ( depolarizing_noise() - .with_prep_probability(0.001) # State preparation error - .with_meas_probability(0.002) # Measurement error - .with_p1_probability(0.003) # Single-qubit gate error - .with_p2_probability(0.004) # Two-qubit gate error + .with_p_prep(0.001) # State preparation error + .with_p_meas(0.002) # Measurement error + .with_p1(0.003) # Single-qubit gate error + .with_p2(0.004) # Two-qubit gate error ) # Biased depolarizing (asymmetric error distribution) @@ -256,10 +256,10 @@ Real quantum computers are noisy. PECOS helps you understand how noise affects y // Custom depolarizing per operation type let _custom = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) // State preparation error - .with_meas_probability(0.002) // Measurement error - .with_p1_probability(0.003) // Single-qubit gate error - .with_p2_probability(0.004); // Two-qubit gate error + .with_p_prep(0.001) // State preparation error + .with_p_meas(0.002) // Measurement error + .with_p1(0.003) // Single-qubit gate error + .with_p2(0.004); // Two-qubit gate error // Biased depolarizing (asymmetric error distribution) let _biased = BiasedDepolarizingNoiseModel::builder() @@ -278,11 +278,11 @@ For research or to match specific hardware characteristics, you can create detai # Direct builder usage noise = ( GeneralNoiseModelBuilder() - .with_prep_probability(0.001) # State prep error - .with_meas_0_probability(0.005) # Measurement error |0> → |1> - .with_meas_1_probability(0.01) # Measurement error |1> → |0> - .with_p1_probability(0.0001) # Single-qubit gate error - .with_p2_probability(0.01) # Two-qubit gate error + .with_p_prep(0.001) # State prep error + .with_p_meas_0(0.005) # Measurement error |0> → |1> + .with_p_meas_1(0.01) # Measurement error |1> → |0> + .with_p1(0.0001) # Single-qubit gate error + .with_p2(0.01) # Two-qubit gate error .with_seed(42) # Deterministic noise ) ``` @@ -291,14 +291,21 @@ For research or to match specific hardware characteristics, you can create detai ```rust use pecos::noise::GeneralNoiseModelBuilder; + use std::collections::BTreeMap; + let idle_model = BTreeMap::from([ + ("X".to_string(), 1.0 / 3.0), + ("Y".to_string(), 1.0 / 3.0), + ("Z".to_string(), 1.0 / 3.0), + ]); let noise = GeneralNoiseModelBuilder::new() - .with_prep_probability(0.001) // State prep error - .with_meas_0_probability(0.005) // Measurement error |0> → |1> - .with_meas_1_probability(0.01) // Measurement error |1> → |0> - .with_p1_probability(0.0001) // Single-qubit gate error - .with_p2_probability(0.01) // Two-qubit gate error - .with_p_idle_linear_rate(0.0001) // Idle noise rate + .with_p_prep(0.001) // State prep error + .with_p_meas_0(0.005) // Measurement error |0> → |1> + .with_p_meas_1(0.01) // Measurement error |1> → |0> + .with_p1(0.0001) // Single-qubit gate error + .with_p2(0.01) // Two-qubit gate error + .with_p_idle_linear(0.0001, &idle_model) // Idle noise rate + .with_idle_after_2q(1.0) // Idle duration after two-qubit gates .with_seed(42); // Deterministic noise // Use with sim() @@ -525,11 +532,11 @@ Here's how to simulate a GHZ state with realistic noise: # Create advanced noise model with builder noise = ( GeneralNoiseModelBuilder() - .with_prep_probability(0.001) # 0.1% state prep error - .with_p1_probability(0.0001) # 0.01% single-qubit gate error - .with_p2_probability(0.01) # 1% two-qubit gate error - .with_meas_0_probability(0.02) # 2% false positive rate - .with_meas_1_probability(0.03) # 3% false negative rate + .with_p_prep(0.001) # 0.1% state prep error + .with_p1(0.0001) # 0.01% single-qubit gate error + .with_p2(0.01) # 1% two-qubit gate error + .with_p_meas_0(0.02) # 2% false positive rate + .with_p_meas_1(0.03) # 3% false negative rate .with_seed(12345) # Deterministic noise ) @@ -560,11 +567,11 @@ Here's how to simulate a GHZ state with realistic noise: // Create advanced noise model with builder let noise = GeneralNoiseModelBuilder::new() - .with_prep_probability(0.001) // 0.1% state prep error - .with_p1_probability(0.0001) // 0.01% single-qubit gate error - .with_p2_probability(0.01) // 1% two-qubit gate error - .with_meas_0_probability(0.02) // 2% false positive rate - .with_meas_1_probability(0.03) // 3% false negative rate + .with_p_prep(0.001) // 0.1% state prep error + .with_p1(0.0001) // 0.01% single-qubit gate error + .with_p2(0.01) // 1% two-qubit gate error + .with_p_meas_0(0.02) // 2% false positive rate + .with_p_meas_1(0.03) // 3% false negative rate .with_seed(12345); // Deterministic noise // Run simulation diff --git a/docs/workflows/guppy-dem-decoding.md b/docs/workflows/guppy-dem-decoding.md new file mode 100644 index 000000000..275264c02 --- /dev/null +++ b/docs/workflows/guppy-dem-decoding.md @@ -0,0 +1,367 @@ +# Decode a Guppy QEC experiment with idle noise + +Use this workflow when you have a Guppy QEC experiment and want to estimate its +decoded logical error rate under circuit-level gate and idle noise. The example +is a hand-written three-qubit repetition-code memory, small enough to read in +full: everything here applies unchanged to larger hand-written programs. + +The stages are: + +1. Define the code in Guppy +2. Define detectors and observables +3. Generate the DEM with gate and idle noise +4. Sample — from the DEM, or by simulating the program +5. Decode the samples and compute logical error rates + +Each stage builds on the previous one; the code blocks form a single script when +read in order. + +## 1. Define the code in Guppy + +The program prepares three data qubits in the logical `|0>` state, extracts the +two parity checks twice with fresh ancillas, and reads out the data qubits in +the Z basis. Every measurement that a detector will reference is tagged with +`result(...)`, so detectors can be written by name instead of by counting +positions. + +Two constraints shape the program: quantum control flow must be static, so the +rounds are written out rather than looped, and the circuit must be Clifford. +Ancillas are freshly allocated each round because `measure()` consumes its +qubit. For larger codes, the built-in generators in +[QEC with Guppy](../user-guide/qec-guppy.md) produce this structure for you. + +```python +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit + + +@guppy +def rep_code_memory() -> None: + # Data qubits, prepared in the logical |0> state. + d0, d1, d2 = qubit(), qubit(), qubit() + + # Round 0: the two Z-parity checks, (d0, d1) and (d1, d2). + a0, a1 = qubit(), qubit() + cx(d0, a0) + cx(d1, a0) + cx(d1, a1) + cx(d2, a1) + result("s0_r0", measure(a0)) + result("s1_r0", measure(a1)) + + # Round 1: same checks, fresh ancillas (measure() consumes its qubit). + b0, b1 = qubit(), qubit() + cx(d0, b0) + cx(d1, b0) + cx(d1, b1) + cx(d2, b1) + result("s0_r1", measure(b0)) + result("s1_r1", measure(b1)) + + # Final data readout in the Z basis. + result("m0", measure(d0)) + result("m1", measure(d1)) + result("m2", measure(d2)) +``` + +## 2. Define detectors and observables + +A detector is the **parity** of the measurements it references, chosen so that +it is deterministic in the absence of noise: + +- `D0`, `D1` — the first-round checks, deterministic because the data qubits + start in `|000>`. +- `D2`, `D3` — each second-round check compared against the same check in the + first round. +- `D4`, `D5` — each second-round check compared against the corresponding parity + of the final data readout. + +The observable is the logical Z value, which for this code is any single data +qubit measurement. + +A bare string names a tagged measurement. `rec[-k]` refers to one by position +in the canonical Guppy measurement stream, as in Stim, and +`result_ref("tag", occurrence=...)` is the explicit form when you need its +extra selectors. + +Detectors and observables are not named: each one's DEM label is its position in +the list, so `detectors[0]` is `D0` and `observables[0]` is `L0`. That is the +identity the decoders and the DEM text use. A tag that no `result()` call emits is a hard error, so +mistyped names fail loudly rather than silently dropping a detector term; in +larger programs you can also define each tag once as a module-level constant +and use it in both places, passing it to Guppy as `result(comptime(TAG), ...)`. + + +```python +from pecos.qec import Detector, Observable + +detectors = [ + Detector("s0_r0"), + Detector("s1_r0"), + Detector("s0_r0", "s0_r1"), + Detector("s1_r0", "s1_r1"), + Detector("s0_r1", "m0", "m1"), + Detector("s1_r1", "m1", "m2"), +] +observables = [Observable("m0")] +``` + +## 3. Generate the DEM with gate and idle noise + +`with_idle_after_2q(1.0)` inserts an idle of that duration on both qubits after +every two-qubit gate; traced identity-like gates are stripped first by default, +so runtime-emitted idles are not double-counted. + +That default matters because the trace need not be idle-free. `with_runtime(...)` +selects the Selene runtime plugin that lowers and unrolls the Guppy program into +the QIS trace this TickCircuit is built from — so the runtime does not decorate +the trace, it produces it. A runtime that models timing emits its own idle gates +as part of that lowering, reflecting real scheduling rather than the uniform +convention inserted here. Setting `with_idle_after_2q(...)` therefore +implies stripping first, so the two conventions cannot stack. To keep a runtime's +own idle placement instead, simply omit `with_idle_after_2q(...)` — stripping is +off unless insertion asked for it — and the idle-noise families apply to whatever +idles the runtime emitted. `with_strip_traced_idles(...)` overrides that pairing +in either direction when you want it stated explicitly. + +The linear family uses a custom Z-biased distribution, keeping smaller X and Y +memory errors while making dephasing dominant; its weights are an additive +probability distribution and must sum to 1. The sine-squared family uses Z only, +because the sine-law dephasing remnant is Z by nature — its default is symmetric +across X, Y, and Z, so the single-axis choice is spelled out explicitly. See +[Idle Noise](../user-guide/dem-from-guppy.md#idle-noise) for the full family and +model semantics. + +`DetectorErrorModel.builder()` configures the run through chained setters and +returns the DEM together with the audit trail and the result-column evaluator +used in stage 4b. A `NoiseParameters` instance carries the entire noise +configuration as one argument. + +The one-call forms `DetectorErrorModel.from_guppy(...)` and +`build_dem_from_guppy(...)` remain available and run this same pipeline; they +take the noise settings as individual keyword arguments instead. + + +```python +from pecos import NoiseParameters +from pecos.qec import DetectorErrorModel + +noise = ( + NoiseParameters() + .with_p1(0.002) + .with_p2(0.02) + .with_p_meas(0.02) + .with_p_prep(0.02) + .with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5}) + .with_p_idle_sin_squared(0.03, {"Z": 1.0}) +) + +dem_build = ( + DetectorErrorModel.builder() + .with_program(rep_code_memory) + .with_qubits(7) + .with_detectors(detectors) + .with_observables(observables) + .with_noise(noise) + .with_idle_after_2q(1.0) + .build() +) +dem = dem_build.dem + +assert dem.num_detectors == 6 +assert dem.num_observables == 1 +print(f"detectors: {dem.num_detectors}, mechanisms: {dem.to_string().count('error(')}") +``` + +The DEM has several text forms, one per decoder appetite. `to_string()` returns +Stim-format text with raw hyperedges, which BP+OSD and Tesseract consume +directly. PyMatching requires a graph-like model, so it gets the terminal +projection from `to_string_terminal_graphlike_decomposed()`; the source-informed +`to_string_source_graphlike_decomposed()` form is used for Tesseract below. + + +```python +raw_text = dem.to_string() +terminal_graphlike_text = dem.to_string_terminal_graphlike_decomposed() +source_graphlike_text = dem.to_string_source_graphlike_decomposed() + +assert all("error(" in text for text in (raw_text, terminal_graphlike_text, source_graphlike_text)) +``` + +## 4a. Sample the DEM + +`to_sampler()` draws detector events and observable flips directly from the +error model, without simulating the circuit. `get_syndrome()` returns one shot's +detector bits and `get_observable_flips()` the actual logical flips that shot +incurred — the ground truth that decoder predictions are scored against. + + +```python +sampler = dem.to_sampler() +batch = sampler.sample_batch(2000, seed=1) + +assert batch.num_shots == 2000 +for shot in range(2): + syndrome = batch.get_syndrome(shot) + observable_mask = batch.get_observable_flips(shot).mask + assert len(syndrome) == dem.num_detectors + print(f"shot {shot}: syndrome={syndrome}, observable_mask={observable_mask}") +``` + +## 4b. Or generate shots by simulating the program + +Instead of sampling the error model, you can execute the Guppy program itself +under a noisy simulator and score those shots against the same DEM. +`dem_build.evaluate_result_columns()` maps the run's tagged result columns into +the same (detector events, observable flips) pairs a DEM sample carries, so +either source can feed the decoders. + +The gate noise below mirrors stage 3, including the idle families. With the +default runtime, which emits no idle gates of its own, `with_idle_after_2q` +adds an idle site on each two-qubit gate operand, the same placement the DEM +pass uses. + +The idle families take the same rates and the same model dictionaries on both +sides, so stage 3's settings carry over verbatim -- no unit conversion. Each +family is named for its own law, so there is no mode flag to set either. + +The simulator still samples one linear event and then picks an axis, while the +DEM emits independent per-axis mechanisms; the DEM builder converts between the +two so both describe the same Pauli channel. + +There are two ways to place idle sites after two-qubit gates, and they are +**alternatives, not steps**. Using both double-counts: + +- `general_noise().with_idle_after_2q(d)` -- the noise model applies idle faults + at each two-qubit gate's operands as it decorates the stream. This is what the + run below uses. +- `TickCircuit.insert_idle_after_two_qubit_gates(d)` -- a circuit pass that + inserts real `Idle` gates, which the noise model then treats like any other + idle. + +A caveat if you supply a runtime plugin via `with_runtime(...)`. Unlike the DEM +builder, a noise model cannot remove gates -- it only decorates a gate stream. So +a runtime that emits its own `Idle` gates gets idle noise applied to those *and* +at the after-2q sites, double-counting where the DEM counts once. Lowering the +program yourself lets you strip first, exactly as the DEM builder does: + + +```python +from pecos.tracing import trace_program_to_tick_circuit + +# The QIS trace lowers and unrolls the Guppy program; pass runtime=... to select +# a Selene runtime plugin, which may schedule idles of its own. +tick_circuit = trace_program_to_tick_circuit(rep_code_memory, 7) + +# remove_identity() drops everything that is identity by effect: I, Idle, and +# zero-angle rotations. That clears runtime-emitted idles so only one convention +# survives -- insertion is then this pass OR with_idle_after_2q, never both. +tick_circuit.remove_identity() +``` + +`sim()` does not yet accept a `TickCircuit` (PECOS #444), so today this is how to +inspect the lowered circuit rather than a path into the simulator. The run below +uses the default runtime, which emits no idles, so `with_idle_after_2q` is the +only convention in play and nothing is double-counted. + + +```python +from pecos import general_noise, selene_engine, sim, stabilizer + +# The same gate and idle noise the DEM was built with. +noise = ( + general_noise() + .with_p1(0.002) + .with_p2(0.02) + .with_p_meas(0.02) + .with_p_prep(0.02) + .with_p_idle_linear(0.01, {"X": 0.25, "Y": 0.25, "Z": 0.5}) + .with_p_idle_sin_squared(0.03, {"Z": 1.0}) + .with_idle_after_2q(1.0) +) + +results = sim(rep_code_memory).classical(selene_engine()).quantum(stabilizer()).qubits(7).noise(noise).seed(42).run(500) + +columns = results.to_shot_map().to_dict() +sim_shots = dem_build.evaluate_result_columns(columns) + +assert len(sim_shots) == 500 +``` + +## 5. Decode the samples and compute logical error rates + +Each decoder is constructed from the DEM text form it accepts, then asked for a +prediction per shot. A shot counts as a logical error when the predicted +observable flip disagrees with the flip the sample actually carried. + +The result types reconcile their underlying shapes through `observable_flips`. +Its sequence interface exposes per-observable booleans, while `.mask` exposes +the same flips as an arbitrary-precision integer. + + +```python +from pecos.decoders import BpOsdDecoder, ObservableFlips, PyMatchingDecoder, TesseractDecoder + +pymatching = PyMatchingDecoder.from_dem(terminal_graphlike_text) +tesseract = TesseractDecoder.from_dem(source_graphlike_text, preset="fast", pqlimit=50_000) +bp_osd = BpOsdDecoder.from_dem(raw_text, max_iter=10, osd_order=1) + +pymatching_errors = 0 +tesseract_errors = 0 +bp_osd_errors = 0 + +for shot in range(batch.num_shots): + syndrome = batch.get_syndrome(shot) + actual = batch.get_observable_flips(shot) + + pymatching_errors += pymatching.decode_syndrome(syndrome).observable_flips != actual + tesseract_errors += tesseract.decode_syndrome(syndrome).observable_flips != actual + bp_osd_errors += bp_osd.decode_syndrome(syndrome).observable_flips != actual + +shots = batch.num_shots +assert 0 < pymatching_errors < shots +assert 0 < tesseract_errors < shots +assert 0 < bp_osd_errors < shots + +print("DEM-sampled shots") +print(f"pymatching {pymatching_errors:5} {pymatching_errors / shots:.4%}") +print(f"tesseract {tesseract_errors:5} {tesseract_errors / shots:.4%}") +print(f"bp_osd {bp_osd_errors:5} {bp_osd_errors / shots:.4%}") +``` + +With one observable, any-observable and per-observable error rates coincide. +With several, `predicted != actual` counts any-observable failures, while +`predicted[i] != actual[i]` counts failures for observable `i`; say which rate +you mean. + +The simulated shots decode the same way, against the same decoders: + + +```python +sim_errors = 0 +for syndrome, observable_mask in sim_shots: + predicted = pymatching.decode_syndrome(syndrome).observable_flips + actual = ObservableFlips.from_mask(observable_mask, dem.num_observables) + sim_errors += predicted != actual + +print(f"simulated shots, pymatching: {sim_errors}/{len(sim_shots)}") +``` + +When you only need the count, `batch.decode_count(dem_text, "pymatching")` runs +this same loop natively and returns the number of mismatches. + +At this noise level the three decoders land within about a percentage point of +each other on this code; the gaps between decoders widen with code distance and +with genuinely hyperedge-like noise, which is where BP+OSD and Tesseract consume +the raw model rather than a graph-like projection. + +## Where to go next + +- [Detector Error Models from Guppy](../user-guide/dem-from-guppy.md) explains + metadata references, idle-noise models, and DEM representations in detail. +- [QEC with Guppy](../user-guide/qec-guppy.md) covers the built-in QEC program + generators for larger codes. +- [Decoders](../user-guide/decoders.md) describes the available decoder APIs. +- [Runtime QIS Tracing](../user-guide/runtime-qis-tracing.md) explains how PECOS + captures the runtime-lowered gate stream used to build this DEM. diff --git a/examples/Dusting off color code code.ipynb b/examples/Dusting off color code code.ipynb index 6f6916fed..f8af0cb3f 100644 --- a/examples/Dusting off color code code.ipynb +++ b/examples/Dusting off color code code.ipynb @@ -955,10 +955,10 @@ "source": [ "# Create noise model using builder\n", "noise = (DepolarizingNoiseModelBuilder()\n", - " .with_prep_probability(0.003)\n", - " .with_meas_probability(0.003)\n", - " .with_p1_probability(0.003)\n", - " .with_p2_probability(0.003))\n", + " .with_p_prep(0.003)\n", + " .with_p_meas(0.003)\n", + " .with_p1(0.003)\n", + " .with_p2(0.003))\n", "\n", "data = (\n", " qasm_engine()\n", diff --git a/examples/python_examples/noise_builder_example.py b/examples/python_examples/noise_builder_example.py index cb44637e7..47aa4ac46 100755 --- a/examples/python_examples/noise_builder_example.py +++ b/examples/python_examples/noise_builder_example.py @@ -27,7 +27,7 @@ def simple_noise_example() -> None: """ # Simple uniform noise - noise = GeneralNoiseModelBuilder().with_seed(42).with_p1_probability(0.001).with_p2_probability(0.01) + noise = GeneralNoiseModelBuilder().with_seed(42).with_p1(0.001).with_p2(0.01) results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) results_dict = results.to_dict() @@ -58,12 +58,12 @@ def hardware_realistic_noise() -> None: GeneralNoiseModelBuilder() .with_seed(42) # Gate errors (two-qubit much worse) - .with_average_p1_probability(0.0001) # 0.01% - .with_average_p2_probability(0.001) # 0.1% + .with_average_p1(0.0001) # 0.01% + .with_average_p2(0.001) # 0.1% # Measurement is often the dominant error - .with_prep_probability(0.001) - .with_meas_0_probability(0.01) # 1% false positive - .with_meas_1_probability(0.005) + .with_p_prep(0.001) + .with_p_meas_0(0.01) # 1% false positive + .with_p_meas_1(0.005) ) # 0.5% false negative results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) @@ -99,7 +99,7 @@ def biased_noise_example() -> None: noise = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_average_p1_probability(0.01) # Higher error for visibility + .with_average_p1(0.01) # Higher error for visibility .with_p1_pauli_model( { "X": 0.1, # 10% bit flips @@ -136,12 +136,15 @@ def ion_trap_noise() -> None: GeneralNoiseModelBuilder() .with_seed(42) # Excellent single-qubit gates - .with_average_p1_probability(0.00001) # 0.001% error + .with_average_p1(0.00001) # 0.001% error # Two-qubit gates are limiting factor - .with_average_p2_probability(0.003) # 0.3% error + .with_average_p2(0.003) # 0.3% error + # Apply configured idle noise for one time unit after each two-qubit gate + .with_p_idle_linear(0.0001, {"X": 1 / 3, "Y": 1 / 3, "Z": 1 / 3}) + .with_idle_after_2q(1.0) # Asymmetric measurement - .with_meas_0_probability(0.001) # Dark state error - .with_meas_1_probability(0.005) + .with_p_meas_0(0.001) # Dark state error + .with_p_meas_1(0.005) ) # Bright state error results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) @@ -171,8 +174,8 @@ def noiseless_gates_example() -> None: noise = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.01) # High error for visibility - .with_p2_probability(0.01) + .with_p1(0.01) # High error for visibility + .with_p2(0.01) .with_noiseless_gate("H") ) # H gates have no error @@ -200,22 +203,17 @@ def scaled_noise_example() -> None: # Base noise model base_noise = ( - GeneralNoiseModelBuilder() - .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + GeneralNoiseModelBuilder().with_seed(42).with_p1(0.001).with_p2(0.01).with_p_meas_0(0.002).with_p_meas_1(0.002) ) # Same model scaled up 3x scaled_noise = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) .with_scale(3.0) ) # Triple all error rates! @@ -262,9 +260,9 @@ def full_noise_model_example() -> None: # Make Hadamard noiseless .with_noiseless_gate("h") # State preparation - .with_prep_probability(0.001) + .with_p_prep(0.001) # Single-qubit with custom Pauli - .with_average_p1_probability(0.0001) + .with_average_p1(0.0001) .with_p1_pauli_model( { "X": 0.2, @@ -273,10 +271,10 @@ def full_noise_model_example() -> None: }, ) # Two-qubit gates - .with_average_p2_probability(0.001) + .with_average_p2(0.001) # Measurement errors - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.005) + .with_p_meas_0(0.002) + .with_p_meas_1(0.005) ) results = qasm_engine().program(QasmProgram.from_string(qasm)).to_sim().noise(noise).run(1000) diff --git a/examples/surface/decoder_comparison.py b/examples/surface/decoder_comparison.py index a53efc71f..6dc903a24 100644 --- a/examples/surface/decoder_comparison.py +++ b/examples/surface/decoder_comparison.py @@ -31,7 +31,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters @dataclass @@ -69,7 +69,7 @@ class ComparisonPoint: def _build_sampler( distance: int, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str, circuit_source: str, ) -> tuple: @@ -172,7 +172,7 @@ def run_comparison( p_prep_scale: float, ) -> list[ComparisonPoint]: """Run the full comparison and return results.""" - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters points: list[ComparisonPoint] = [] total_configs = len(distances) * len(error_rates) @@ -182,7 +182,7 @@ def run_comparison( num_rounds = 2 * distance for p in error_rates: config_idx += 1 - noise = NoiseModel( + noise = NoiseParameters( p1=p * p1_scale, p2=p, p_meas=p * p_meas_scale, diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 799da3ac2..ead3721f6 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -377,7 +377,7 @@ def tesseract_predictions(dem_text: str, detection_events: np.ndarray, *, beam: det_beam=beam, ) results = decoder.decode_batch([row.tolist() for row in detection_events]) - return np.array([int(result.observables_mask & 1) for result in results], dtype=np.uint8) + return np.array([int(result.observable_flips[0]) for result in results], dtype=np.uint8) def pymatching_predictions(dem_text: str, detection_events: np.ndarray, *, correlated: bool) -> np.ndarray: @@ -532,7 +532,7 @@ def run_case( pair_analysis_max_effects: int, ) -> CaseResult: from pecos._traced_circuit import normalize_traced_tick_circuit - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler from pecos.qec.surface.circuit_builder import ( generate_dem_from_tick_circuit_via_stim, ) @@ -542,7 +542,7 @@ def run_case( ) patch = SurfacePatch.create(distance=distance) - noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise = NoiseParameters(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) noise_args = { "p1": noise.p1, "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, diff --git a/examples/surface/dem_method_ler_comparison.py b/examples/surface/dem_method_ler_comparison.py index e313764fd..3a570254a 100644 --- a/examples/surface/dem_method_ler_comparison.py +++ b/examples/surface/dem_method_ler_comparison.py @@ -131,12 +131,12 @@ def generate_dems( Returns list of (method_name, raw_dem, decomposed_dem_or_None). decomposed_dem is None when the method cannot produce a graphlike DEM. """ - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder results = [] - noise = NoiseModel( + noise = NoiseParameters( p1=noise_params.get("p1", 0.0), p2=noise_params.get("p2", 0.0), p_meas=noise_params.get("p_meas", 0.0), diff --git a/examples/surface/generate_data.py b/examples/surface/generate_data.py index 9a2ca6dc7..e0078e77d 100644 --- a/examples/surface/generate_data.py +++ b/examples/surface/generate_data.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters # -- Data model --------------------------------------------------------------- @@ -97,7 +97,7 @@ def _decoder_base_name(name: str) -> str: def _build_sampler( distance: int, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str, circuit_source: str, ) -> tuple: @@ -159,7 +159,7 @@ def generate( duration_multipliers: list[float], ) -> DataShard: """Run the full data generation and return a shard.""" - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters config = { "distances": distances, @@ -200,7 +200,7 @@ def generate( for d in distances: for p in error_rates: - noise = NoiseModel( + noise = NoiseParameters( p1=p * p1_scale, p2=p, p_meas=p * p_meas_scale, diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index 98745d728..e6878c097 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -103,7 +103,7 @@ def build_case( variants: list[str], ) -> BenchmarkResult: from pecos._traced_circuit import normalize_traced_tick_circuit - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler from pecos.qec.surface.circuit_builder import ( generate_dem_from_tick_circuit_via_stim, ) @@ -118,7 +118,7 @@ def build_case( ) setup_timings: list[TimedValue] = [] patch = SurfacePatch.create(distance=distance) - noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise = NoiseParameters(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) noise_args = { "p1": noise.p1, "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, diff --git a/examples/surface/ml_lookup_decoder.py b/examples/surface/ml_lookup_decoder.py index 8329a0d1c..9f866d104 100644 --- a/examples/surface/ml_lookup_decoder.py +++ b/examples/surface/ml_lookup_decoder.py @@ -30,7 +30,7 @@ def build_lookup_table(batch, num_detectors: int) -> dict[tuple[int, ...], int]: for i in range(batch.num_shots): syn = batch.get_syndrome(i) - obs = batch.get_observable_mask(i) + obs = batch.get_observable_flips(i).mask # Convert syndrome to tuple of fired detector indices fired = tuple(d for d in range(min(num_detectors, len(syn))) if syn[d]) @@ -50,7 +50,7 @@ def decode_with_lookup(batch, table: dict, num_detectors: int) -> tuple[int, int errors = 0 for i in range(batch.num_shots): syn = batch.get_syndrome(i) - obs_true = batch.get_observable_mask(i) + obs_true = batch.get_observable_flips(i).mask fired = tuple(d for d in range(min(num_detectors, len(syn))) if syn[d]) predicted = table.get(fired, 0) # default: no correction @@ -205,10 +205,10 @@ def main(): ler_lookup = errors_lookup / n # Compare with pymatching - from pecos.qec.surface import NoiseModel + from pecos.qec.surface import NoiseParameters from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder - noise_obj = NoiseModel( + noise_obj = NoiseParameters( p1=noise_params["p1"], p2=noise_params["p2"], p_meas=noise_params["p_meas"], diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index c138e98d0..6fc3de401 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -283,13 +283,9 @@ def _backend_runtime_label(sample_backend: str, native_circuit_source: str = "ab raise ValueError(msg) -def _predicted_observable_flip(result: object) -> int: +def _predicted_observable_flip(result: Any) -> int: """Extract the predicted logical observable flip from a DEM decoder result.""" - observables_mask = getattr(result, "observables_mask", None) - if observables_mask is not None: - return int(observables_mask & 1) - correction = getattr(result, "correction", []) - return int(correction[0]) if len(correction) > 0 else 0 + return int(result.observable_flips[0]) def _format_rate(value: float | None) -> str: @@ -556,7 +552,7 @@ def _noise_model_description(args: argparse.Namespace) -> str: sim_noise_model = getattr(args, "sim_noise_model", "depolarizing") base = f"p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" if sim_noise_model == "general": - return f"general_noise runtime ({base}, leak2depolar=True, p_idle_coherent=False)" + return f"general_noise runtime ({base}, leak2depolar=True)" return f"depolarizing runtime ({base})" @@ -588,15 +584,9 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int return PyMatchingDecoder.from_dem(dem_str) -def _decode_one_shot(dem_decoder: object, events_flat: list[int]) -> object: - """Decode one shot using whichever DEM decoder was created. - - Tesseract.decode() wants sparse indices; decode_syndrome() accepts dense vectors. - PyMatching.decode() accepts dense vectors directly. - """ - if hasattr(dem_decoder, "decode_syndrome"): - return dem_decoder.decode_syndrome(events_flat) - return dem_decoder.decode(events_flat) +def _decode_one_shot(dem_decoder: Any, events_flat: list[int]) -> object: + """Decode one dense syndrome using whichever DEM decoder was created.""" + return dem_decoder.decode_syndrome(events_flat) def _decode_all_shots( @@ -639,7 +629,7 @@ def _decode_all_shots( batch_results = dem_decoder.decode_batch(syndromes) num_errors = 0 for shot_idx, result in enumerate(batch_results): - predicted_flip = int(result.observables_mask & 1) + predicted_flip = int(result.observable_flips[0]) num_errors += int(predicted_flip != true_flips[shot_idx]) return num_errors @@ -669,11 +659,11 @@ def _decoder_runtime( p_prep_scale: float = 0.5, ) -> _DecoderRuntime: """Build and cache the expensive native decoder-side objects once.""" - from pecos.qec.surface import NoiseModel, SurfaceDecoder + from pecos.qec.surface import NoiseParameters, SurfaceDecoder basis = basis.upper() patch = _surface_patch(distance) - noise = NoiseModel( + noise = NoiseParameters( p1=physical_error_rate * p1_scale, p2=physical_error_rate, p_meas=physical_error_rate * p_meas_scale, @@ -954,15 +944,13 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] backend_start = time.perf_counter() noise_start = time.perf_counter() if sim_noise_model == "general": - use_coherent_idle = False noise_model = ( pecos.general_noise() - .with_prep_probability(physical_error_rate * p_prep_scale) - .with_meas_probability(physical_error_rate * p_meas_scale) - .with_p1_probability(physical_error_rate * p1_scale) - .with_p2_probability(physical_error_rate) + .with_p_prep(physical_error_rate * p_prep_scale) + .with_p_meas(physical_error_rate * p_meas_scale) + .with_p1(physical_error_rate * p1_scale) + .with_p2(physical_error_rate) .with_leakage_scale(0.0) - .with_p_idle_coherent(use_coherent_idle) .with_seed(seed) ) elif sim_noise_model == "depolarizing": @@ -3724,10 +3712,7 @@ def _parse_args() -> argparse.Namespace: "--sim-noise-model", choices=["depolarizing", "general"], default="depolarizing", - help=( - "Runtime noise model used by --sample-backend sim. The 'general' " - "option sets leak2depolar=True and p_idle_coherent=False." - ), + help=("Runtime noise model used by --sample-backend sim. The 'general' option sets leak2depolar=True."), ) parser.add_argument( "--dem-mode", diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index a050d4f09..04e6d0678 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -24,7 +24,7 @@ from dem_decomposition_diagnostics import compare_raw_dems, dem_stats from pecos._traced_circuit import normalize_traced_tick_circuit -from pecos.qec.surface import NoiseModel, OpType, SurfacePatch, build_surface_code_circuit +from pecos.qec.surface import NoiseParameters, OpType, SurfacePatch, build_surface_code_circuit from pecos.qec.surface.circuit_builder import ( _analyze_szz_forward_flow, generate_dem_from_tick_circuit_via_stim, @@ -315,7 +315,7 @@ def _dem_report( p: float, p1_ratio: float, ) -> DemReport: - noise = NoiseModel(p1=p / p1_ratio, p2=p, p_prep=p / 3.0, p_meas=p / 3.0) + noise = NoiseParameters(p1=p / p1_ratio, p2=p, p_prep=p / 3.0, p_meas=p / 3.0) noise_args = { "p1": noise.p1, "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, diff --git a/examples/surface/validate_dem_generators.py b/examples/surface/validate_dem_generators.py index af8fb6765..35103aa32 100644 --- a/examples/surface/validate_dem_generators.py +++ b/examples/surface/validate_dem_generators.py @@ -86,7 +86,10 @@ def build_circuit(distance, rounds, basis, circuit_source="abstract", *, fill_id # Optional passes applied to all circuits: if fill_idle: - # Insert Idle(1) after 2q gates (for idle_rz noise modeling) + # Insert Idle(1) after 2q gates: gives the DEM duration-based idle + # attachment points mirroring the sim's after-2q coherent channel + # placement (the sim idle_rz channel reacts to 2q gates directly, + # not to Idle gates). tc.insert_idle_after_two_qubit_gates(1.0) # Fill remaining inactive qubits with Idle gates tc.fill_idle_gates() diff --git a/examples/surface_code_experiments.ipynb b/examples/surface_code_experiments.ipynb index dd70d582d..53c2bfd10 100644 --- a/examples/surface_code_experiments.ipynb +++ b/examples/surface_code_experiments.ipynb @@ -45,7 +45,7 @@ "import numpy as np\n", "from pecos.compilation_pipeline import compile_guppy_to_hugr\n", "from pecos.guppy_gen.surface import get_num_qubits, make_surface_code\n", - "from pecos.qec.surface import NoiseModel, SurfaceDecoder, SurfacePatch, plot_surface_code\n", + "from pecos.qec.surface import NoiseParameters, SurfaceDecoder, SurfacePatch, plot_surface_code\n", "from selene_sim import DepolarizingErrorModel, IdealErrorModel, SimpleRuntime, Stim, build" ] }, @@ -542,7 +542,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " # Simulate once, decode with all decoders\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", @@ -744,7 +744,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", "\n", diff --git a/examples/surface_code_noisy_decoding.ipynb b/examples/surface_code_noisy_decoding.ipynb index 117e217b3..dd1fa193b 100644 --- a/examples/surface_code_noisy_decoding.ipynb +++ b/examples/surface_code_noisy_decoding.ipynb @@ -45,7 +45,7 @@ "from pecos.compilation_pipeline import compile_guppy_to_hugr\n", "from pecos.guppy_gen.surface import get_num_qubits, make_surface_code\n", "from pecos.qec.surface import (\n", - " NoiseModel,\n", + " NoiseParameters,\n", " SurfaceDecoder,\n", " SurfacePatch,\n", " plot_surface_code,\n", @@ -101,7 +101,7 @@ } }, "outputs": [], - "source": "from typing import Any\n\n\ndef get_logical_qubits(distance: int, basis: str) -> tuple:\n \"\"\"Get qubits in the logical operator.\"\"\"\n patch = SurfacePatch.create(distance=distance)\n if basis == \"Z\":\n return patch.geometry.logical_z.data_qubits\n return patch.geometry.logical_x.data_qubits\n\n\ndef run_memory_experiment(\n distance: int,\n num_rounds: int,\n num_shots: int,\n basis: str,\n error_model: Any,\n *,\n decode: bool = False,\n decoder_type: str = \"pymatching\",\n) -> dict:\n \"\"\"Run memory experiment and compute logical error rate.\n\n For Z-basis: prepare |0_L>, measure in Z basis, check logical Z parity.\n For X-basis: prepare |+_L>, measure in X basis, check logical X parity.\n\n Args:\n distance: Code distance\n num_rounds: Number of syndrome extraction rounds\n num_shots: Number of shots to run\n basis: 'Z' or 'X' basis\n error_model: Selene error model (IdealErrorModel or DepolarizingErrorModel)\n decode: If True, use decoding to correct errors\n decoder_type: Decoder backend ('pymatching', 'fusion_blossom', 'bp_osd', 'bp_lsd', 'union_find', 'tesseract')\n\n Returns:\n Dictionary with experiment results\n \"\"\"\n patch = SurfacePatch.create(distance=distance)\n logical_qubits = get_logical_qubits(distance, basis)\n\n # Create decoder if needed\n decoder = None\n if decode:\n # Extract noise parameters from error model\n noise = NoiseModel(\n p1=getattr(error_model, \"p_1q\", 0.01),\n p2=getattr(error_model, \"p_2q\", 0.01),\n p_meas=getattr(error_model, \"p_meas\", 0.01),\n p_prep=getattr(error_model, \"p_init\", 0.01),\n )\n decoder = SurfaceDecoder(patch, num_rounds=num_rounds, noise=noise, decoder_type=decoder_type)\n\n # Build circuit\n num_qubits = get_num_qubits(distance)\n prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis)\n hugr_bytes = compile_guppy_to_hugr(prog)\n instance = build(hugr_bytes, name=f\"surface_d{distance}\")\n\n # Run\n num_logical_errors = 0\n num_raw_errors = 0\n\n for shot_results in instance.run_shots(\n simulator=Stim(),\n n_qubits=num_qubits,\n n_shots=num_shots,\n error_model=error_model,\n runtime=SimpleRuntime(),\n n_processes=1,\n ):\n # Collect all syndromes properly (multiple entries per key)\n synx_list = []\n synz_list = []\n final = None\n\n for name, values in shot_results:\n vals = list(values)\n if name == \"synx\":\n synx_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"synz\":\n synz_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"final\":\n final = vals\n\n if final is None:\n continue\n\n # Raw parity check (no decoding)\n raw_parity = sum(final[q] for q in logical_qubits) % 2\n if raw_parity != 0:\n num_raw_errors += 1\n\n if decode and decoder is not None:\n final_arr = np.array(final, dtype=np.uint8)\n\n # Decode based on basis\n if basis == \"Z\":\n is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final_arr)\n else:\n is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final_arr)\n\n if is_error:\n num_logical_errors += 1\n else:\n # No decoding - use raw parity\n if raw_parity != 0:\n num_logical_errors += 1\n\n return {\n \"distance\": distance,\n \"num_shots\": num_shots,\n \"num_logical_errors\": num_logical_errors,\n \"num_raw_errors\": num_raw_errors,\n \"logical_error_rate\": num_logical_errors / num_shots,\n \"raw_error_rate\": num_raw_errors / num_shots,\n \"decoded\": decode,\n \"decoder_type\": decoder_type if decode else None,\n }" + "source": "from typing import Any\n\n\ndef get_logical_qubits(distance: int, basis: str) -> tuple:\n \"\"\"Get qubits in the logical operator.\"\"\"\n patch = SurfacePatch.create(distance=distance)\n if basis == \"Z\":\n return patch.geometry.logical_z.data_qubits\n return patch.geometry.logical_x.data_qubits\n\n\ndef run_memory_experiment(\n distance: int,\n num_rounds: int,\n num_shots: int,\n basis: str,\n error_model: Any,\n *,\n decode: bool = False,\n decoder_type: str = \"pymatching\",\n) -> dict:\n \"\"\"Run memory experiment and compute logical error rate.\n\n For Z-basis: prepare |0_L>, measure in Z basis, check logical Z parity.\n For X-basis: prepare |+_L>, measure in X basis, check logical X parity.\n\n Args:\n distance: Code distance\n num_rounds: Number of syndrome extraction rounds\n num_shots: Number of shots to run\n basis: 'Z' or 'X' basis\n error_model: Selene error model (IdealErrorModel or DepolarizingErrorModel)\n decode: If True, use decoding to correct errors\n decoder_type: Decoder backend ('pymatching', 'fusion_blossom', 'bp_osd', 'bp_lsd', 'union_find', 'tesseract')\n\n Returns:\n Dictionary with experiment results\n \"\"\"\n patch = SurfacePatch.create(distance=distance)\n logical_qubits = get_logical_qubits(distance, basis)\n\n # Create decoder if needed\n decoder = None\n if decode:\n # Extract noise parameters from error model\n noise = NoiseParameters(\n p1=getattr(error_model, \"p_1q\", 0.01),\n p2=getattr(error_model, \"p_2q\", 0.01),\n p_meas=getattr(error_model, \"p_meas\", 0.01),\n p_prep=getattr(error_model, \"p_init\", 0.01),\n )\n decoder = SurfaceDecoder(patch, num_rounds=num_rounds, noise=noise, decoder_type=decoder_type)\n\n # Build circuit\n num_qubits = get_num_qubits(distance)\n prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis)\n hugr_bytes = compile_guppy_to_hugr(prog)\n instance = build(hugr_bytes, name=f\"surface_d{distance}\")\n\n # Run\n num_logical_errors = 0\n num_raw_errors = 0\n\n for shot_results in instance.run_shots(\n simulator=Stim(),\n n_qubits=num_qubits,\n n_shots=num_shots,\n error_model=error_model,\n runtime=SimpleRuntime(),\n n_processes=1,\n ):\n # Collect all syndromes properly (multiple entries per key)\n synx_list = []\n synz_list = []\n final = None\n\n for name, values in shot_results:\n vals = list(values)\n if name == \"synx\":\n synx_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"synz\":\n synz_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"final\":\n final = vals\n\n if final is None:\n continue\n\n # Raw parity check (no decoding)\n raw_parity = sum(final[q] for q in logical_qubits) % 2\n if raw_parity != 0:\n num_raw_errors += 1\n\n if decode and decoder is not None:\n final_arr = np.array(final, dtype=np.uint8)\n\n # Decode based on basis\n if basis == \"Z\":\n is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final_arr)\n else:\n is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final_arr)\n\n if is_error:\n num_logical_errors += 1\n else:\n # No decoding - use raw parity\n if raw_parity != 0:\n num_logical_errors += 1\n\n return {\n \"distance\": distance,\n \"num_shots\": num_shots,\n \"num_logical_errors\": num_logical_errors,\n \"num_raw_errors\": num_raw_errors,\n \"logical_error_rate\": num_logical_errors / num_shots,\n \"raw_error_rate\": num_raw_errors / num_shots,\n \"decoded\": decode,\n \"decoder_type\": decoder_type if decode else None,\n }" }, { "cell_type": "markdown", @@ -1094,7 +1094,7 @@ "\n", "# Create a decoder configuration\n", "patch = SurfacePatch.create(distance=3)\n", - "noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001)\n", + "noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001)\n", "\n", "# Generate DEM using PECOS native pipeline\n", "tc = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=\"Z\")\n", @@ -1150,8 +1150,8 @@ "\n", "Tesseract decoder: TesseractDecoder(detectors=120, errors=1679, observables=1)\n", "\n", - "Empty syndrome decode: observables_mask=0, cost=0.0\n", - "Detectors [0,8] fired: observables_mask=0, cost=7.4810164852089\n" + "Empty syndrome decode: observable_flips.mask=0, cost=0.0\n", + "Detectors [0,8] fired: observable_flips.mask=0, cost=7.4810164852089\n" ] } ], @@ -1188,12 +1188,12 @@ "# - External tools: Save to file and load\n", "\n", "# Example decode with empty syndrome (no errors)\n", - "result = tesseract.decode([]) # No detectors fired\n", - "print(f\"Empty syndrome decode: observables_mask={result.observables_mask}, cost={result.cost}\")\n", + "result = tesseract.decode_from_defects([]) # No detectors fired\n", + "print(f\"Empty syndrome decode: observable_flips.mask={result.observable_flips.mask}, cost={result.cost}\")\n", "\n", "# Example decode with some detection events\n", - "result = tesseract.decode([0, 8]) # Detectors 0 and 8 fired\n", - "print(f\"Detectors [0,8] fired: observables_mask={result.observables_mask}, cost={result.cost}\")" + "result = tesseract.decode_from_defects([0, 8]) # Detectors 0 and 8 fired\n", + "print(f\"Detectors [0,8] fired: observable_flips.mask={result.observable_flips.mask}, cost={result.cost}\")" ] }, { @@ -1646,7 +1646,7 @@ "\n", "**Noise model**:\n", "```python\n", - "noise = NoiseModel(\n", + "noise = NoiseParameters(\n", " p1=0.001, # Single-qubit gate error rate\n", " p2=0.01, # Two-qubit gate error rate\n", " p_meas=0.01, # Measurement error rate\n", diff --git a/examples/surface_code_threshold.ipynb b/examples/surface_code_threshold.ipynb index 22c52c270..80fe96a8c 100644 --- a/examples/surface_code_threshold.ipynb +++ b/examples/surface_code_threshold.ipynb @@ -46,7 +46,7 @@ "from pecos.guppy_gen.surface import get_num_qubits, make_surface_code\n", "from pecos.misc.threshold_curve import func, func6, threshold_fit\n", "from pecos.qec import DagFaultAnalyzer, DemBuilder\n", - "from pecos.qec.surface import NoiseModel, SurfaceDecoder, SurfacePatch\n", + "from pecos.qec.surface import NoiseParameters, SurfaceDecoder, SurfacePatch\n", "from pecos.qec.surface.circuit_builder import _extract_measurement_order, generate_tick_circuit_from_patch\n", "from pecos.qec.surface.decode import build_stim_circuit_from_patch\n", "from selene_sim import DepolarizingErrorModel, SimpleRuntime, Stim, build" @@ -438,7 +438,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " t0 = time.time()\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", @@ -487,7 +487,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " t0 = time.time()\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", diff --git a/examples/surface_code_thresholds.ipynb b/examples/surface_code_thresholds.ipynb index 2f5050c2e..a5e28938a 100644 --- a/examples/surface_code_thresholds.ipynb +++ b/examples/surface_code_thresholds.ipynb @@ -60,12 +60,12 @@ "# For Stim-based sampling (fast)\n", "import stim\n", "from pecos.qec.surface import (\n", - " NoiseModel,\n", + " NoiseParameters,\n", " SurfacePatch,\n", " generate_surface_code_dem,\n", ")\n", "from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder\n", - "from pecos_rslib.decoders import PyMatchingDecoder, TesseractDecoder" + "from pecos_rslib.decoders import BpOsdDecoder, FusionBlossomDecoder, PyMatchingDecoder, TesseractDecoder" ] }, { @@ -177,19 +177,19 @@ " true_flip = observable_flips[i, 0] if observable_flips.shape[1] > 0 else 0\n", "\n", " if decoder_type == \"pymatching\":\n", - " result = decoder.decode(events.astype(np.uint8).tolist())\n", - " predicted_flip = result.correction[0] if len(result.correction) > 0 else 0\n", + " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", + " predicted_flip = result.observable_flips[0] if len(result.observable_flips) > 0 else 0\n", " elif decoder_type == \"fusion_blossom\":\n", - " result = decoder.decode(events.astype(np.uint8).tolist())\n", - " predicted_flip = result.correction[0] if len(result.correction) > 0 else 0\n", + " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", + " predicted_flip = result.observable_flips[0] if len(result.observable_flips) > 0 else 0\n", " decoder.clear()\n", " elif decoder_type == \"tesseract\":\n", " detection_indices = [j for j, v in enumerate(events) if v]\n", - " result = decoder.decode(detection_indices)\n", - " predicted_flip = result.observables_mask & 1\n", + " result = decoder.decode_from_defects(detection_indices)\n", + " predicted_flip = result.observable_flips.mask & 1\n", " elif decoder_type == \"bp_osd\":\n", - " result = decoder.decode(events.astype(np.uint8).tolist())\n", - " predicted_flip = result.decoding[0] if len(result.decoding) > 0 else 0\n", + " result = decoder.decode_syndrome(events.astype(np.uint8).tolist())\n", + " predicted_flip = result.observable_flips.mask & 1\n", " else:\n", " msg = f\"Unknown decoder type: {decoder_type}\"\n", " raise ValueError(msg)\n", @@ -215,13 +215,10 @@ " return PyMatchingDecoder.from_dem(dem_string)\n", "\n", " if decoder_type == \"fusion_blossom\":\n", - " # FusionBlossom doesn't have from_dem, use PyMatching as fallback\n", - " return PyMatchingDecoder.from_dem(dem_string)\n", + " return FusionBlossomDecoder.from_dem(dem_string)\n", "\n", " if decoder_type == \"bp_osd\":\n", - " # BP+OSD doesn't directly support DEM format\n", - " msg = \"BP+OSD from DEM not yet implemented\"\n", - " raise NotImplementedError(msg)\n", + " return BpOsdDecoder.from_dem(dem_string)\n", "\n", " msg = f\"Unknown decoder type: {decoder_type}\"\n", " raise ValueError(msg)" @@ -339,7 +336,7 @@ "def generate_code_capacity_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a code-capacity DEM (data errors only, perfect measurements).\"\"\"\n", " # Use phenomenological DEM with p_meas=0\n", - " noise = NoiseModel(p1=0, p2=p, p_meas=0, p_prep=0)\n", + " noise = NoiseParameters(p1=0, p2=p, p_meas=0, p_prep=0)\n", " return generate_surface_code_dem(patch, num_rounds=1, noise=noise, stab_type=\"Z\")\n", "\n", "# Test DEM generation\n", @@ -424,7 +421,7 @@ "source": [ "def generate_phenomenological_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a phenomenological DEM (data + measurement errors).\"\"\"\n", - " noise = NoiseModel(p1=0, p2=p, p_meas=p, p_prep=0)\n", + " noise = NoiseParameters(p1=0, p2=p, p_meas=p, p_prep=0)\n", " return generate_surface_code_dem(patch, num_rounds=num_rounds, noise=noise, stab_type=\"Z\")\n", "\n", "# Test DEM generation\n", @@ -510,7 +507,7 @@ "def generate_circuit_level_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a circuit-level DEM using PECOS native fault propagation.\"\"\"\n", " # Use same error rate for all noise sources (standard depolarizing)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", + " noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p)\n", " return generate_circuit_level_dem_from_builder(patch, num_rounds=num_rounds, noise=noise, basis=\"Z\")\n", "\n", "# Test DEM generation\n", diff --git a/exp/pecos-neo/benches/hot_path.rs b/exp/pecos-neo/benches/hot_path.rs index f11053cbd..e44a5f964 100644 --- a/exp/pecos-neo/benches/hot_path.rs +++ b/exp/pecos-neo/benches/hot_path.rs @@ -648,8 +648,8 @@ fn bench_monte_carlo_comparison(c: &mut Criterion) { // Setup: parse QASM and create noise model (not timed) let engine = QASMEngine::from_str(bell_qasm).unwrap(); let noise = GeneralNoiseModel::builder() - .with_average_p1_probability(0.001) - .with_average_p2_probability(0.01) + .with_average_p1(0.001) + .with_average_p2(0.01) .build(); (engine, noise) }, diff --git a/exp/pecos-neo/docs/design/noise-composite.md b/exp/pecos-neo/docs/design/noise-composite.md index a62f62ea6..e156b14f3 100644 --- a/exp/pecos-neo/docs/design/noise-composite.md +++ b/exp/pecos-neo/docs/design/noise-composite.md @@ -194,7 +194,7 @@ Similar to single-qubit, with additions: - Angle-dependent probability: `prob_fn(|gate| p2_angle_rate(gate.angle()))` - Skip if ANY qubit leaked - Two-qubit Pauli model -- Optional idle noise after +- Optional operand-local idle duration after the gate, owned by `IdleChannel` ```rust let tq_noise = seq([ @@ -208,11 +208,13 @@ let tq_noise = seq([ ]) ) ), - // Idle noise always applies (regardless of fault) - prob(p2_idle, idle_pauli()), ]); ``` +Idle policy is not part of the two-qubit primitive. A separate `IdleChannel` +subscribes to both explicit idle events and two-qubit `AfterGate` events, applying +the same configured linear and quadratic mechanisms for the requested duration. + ### Measurement Noise (Tricky Case #1) **Why it's tricky:** Operates on outcomes, not gates. Outcome-dependent. Leaked qubits force outcome before flip noise applies. diff --git a/exp/pecos-neo/docs/dev/noise.md b/exp/pecos-neo/docs/dev/noise.md index 26917c1aa..4d50919b2 100644 --- a/exp/pecos-neo/docs/dev/noise.md +++ b/exp/pecos-neo/docs/dev/noise.md @@ -63,8 +63,9 @@ CompositeNoiseModelBuilder::new() // Angle-dependent scaling (for RZZ, etc.) .with_p2_angle_scaling(AngleScaling::Quadratic) - // Idle error during two-qubit gate - .with_p2_idle_rate(0.001) + // Apply configured idle noise for one time unit after each two-qubit gate + .with_p_idle_linear(0.001) + .with_idle_after_2q(1.0) // Custom two-qubit Pauli model .with_p2_pauli_model(TwoQubitPauliWeights { ... }) diff --git a/exp/pecos-neo/docs/user-guides/noise-channels.md b/exp/pecos-neo/docs/user-guides/noise-channels.md index 9e1909e9d..8d5baa466 100644 --- a/exp/pecos-neo/docs/user-guides/noise-channels.md +++ b/exp/pecos-neo/docs/user-guides/noise-channels.md @@ -82,9 +82,14 @@ T1/T2 decay during idle periods. Rate scales with duration. IdleChannel::linear(0.0001) // Rate per time unit .with_linear_depolarizing() // Uniform X/Y/Z -IdleChannel::from_t1_t2(50e-6, 30e-6) // Physical T1/T2 times +// T1=50us and total T2=30us when one abstract time unit is 1ns +IdleChannel::from_t1_t2(50_000.0, 30_000.0) ``` +`from_t1_t2` uses the first-order Pauli twirl, requires total `T2 <= 2 * T1`, and is valid for +idle durations much shorter than both coherence times. Use +`ComposableNoiseModel::with_idle_t1_t2` with a `TimeScale` when supplying physical seconds. + ## Specialized Channels For more specific noise scenarios. diff --git a/exp/pecos-neo/examples/noise_models.rs b/exp/pecos-neo/examples/noise_models.rs index d936ee7a3..ff4c04ccf 100644 --- a/exp/pecos-neo/examples/noise_models.rs +++ b/exp/pecos-neo/examples/noise_models.rs @@ -256,13 +256,13 @@ fn example_builder_api() { fn example_idle_noise() { println!("--- Idle Noise (T1/T2 Decoherence) ---"); - // Circuit: prep |+⟩, idle, then H to detect Z errors, measure - // Z errors during idle will flip the measurement outcome + // Circuit: prep |+⟩, idle, then H to detect Pauli-twirled Y/Z errors, measure + // Y/Z errors during idle will flip the measurement outcome let commands = CommandBuilder::new() .pz(&[0]) .h(&[0]) // Prepare |+⟩ .idle(&[0], 1000) // Idle for 1000 time units - .h(&[0]) // Convert Z errors to bit flips + .h(&[0]) // Convert Y/Z phase changes to measurement flips .mz(&[0]) .build(); @@ -285,7 +285,7 @@ fn example_idle_noise() { state.reset(); let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); if outcomes.get_bit(QubitId(0)).unwrap_or(false) { - errors += 1; // Z error during idle caused bit flip + errors += 1; // Y/Z error during idle caused bit flip } } @@ -295,7 +295,7 @@ fn example_idle_noise() { " Measured decoherence error rate: {:.1}%", error_rate * 100.0 ); - println!(" (Linear/T1 contribution expected: ~10%)"); + println!(" (First-order total-T2 coherence error expected: ~10%)"); println!(); } diff --git a/exp/pecos-neo/src/extensible/batch.rs b/exp/pecos-neo/src/extensible/batch.rs index bada20e53..dae356765 100644 --- a/exp/pecos-neo/src/extensible/batch.rs +++ b/exp/pecos-neo/src/extensible/batch.rs @@ -290,7 +290,9 @@ impl BatchedCircuit { (Batch::OutputResult { results }, ResolvedOp::OutputResult { result }) => { results.push(*result); } - _ => {} // Should not happen if can_extend is correct + _ => unreachable!( + "BatchedCircuit invariant violated: can_extend accepted an incompatible operation" + ), } } @@ -345,6 +347,11 @@ pub trait BatchExecutor { ); /// Execute the full batched circuit. + /// + /// # Panics + /// + /// Panics if the default executor encounters a multi-angle operation it + /// cannot represent instead of silently dropping it. fn execute_batched(&mut self, circuit: &BatchedCircuit) -> Self::MeasurementResults where Self::MeasurementResults: Default, @@ -371,8 +378,14 @@ pub trait BatchExecutor { self.execute_single_qubit(*gate_id, &[qubits[0]]); } else if qubits.len() == 2 && angles.is_empty() { self.execute_two_qubit(*gate_id, &[(qubits[0], qubits[1])]); + } else { + panic!( + "BatchExecutor cannot execute multi-angle gate {gate_id:?} with \ + {} qubit(s) and {} angle(s)", + qubits.len(), + angles.len() + ); } - // Other cases would need more specific handling } } Batch::Prep { basis, qubits } => { @@ -512,6 +525,65 @@ mod tests { use super::super::gates; use super::*; + struct NoopExecutor; + + impl SimpleExecutor for NoopExecutor { + type MeasurementResults = Vec; + + fn execute_gate(&mut self, _gate_id: GateId, _qubits: &[QubitId], _angles: &[Angle64]) {} + + fn execute_prep(&mut self, _basis: super::super::PrepBasis, _qubit: QubitId) {} + + fn execute_measure( + &mut self, + _basis: super::super::MeasBasis, + _qubit: QubitId, + _result: super::super::ResultId, + _results: &mut Self::MeasurementResults, + ) { + } + + fn get_result( + &self, + _result: super::super::ResultId, + _results: &Self::MeasurementResults, + ) -> bool { + false + } + } + + #[test] + #[should_panic(expected = "BatchedCircuit invariant violated")] + fn incompatible_extension_panics() { + let mut batch = Batch::SingleQubit { + gate_id: gates::H, + qubits: vec![QubitId(0)], + }; + let prep = ResolvedOp::Prep { + qubit: QubitId(1), + basis: super::super::PrepBasis::Z, + }; + + BatchedCircuit::extend_batch(&mut batch, &prep); + } + + #[test] + #[should_panic(expected = "BatchExecutor cannot execute multi-angle gate")] + fn unsupported_multi_angle_batch_panics() { + let circuit = BatchedCircuit { + batches: vec![Batch::MultiAngle { + gate_id: gates::U, + ops: vec![( + smallvec::smallvec![QubitId(0)], + smallvec::smallvec![Angle64::ZERO, Angle64::ZERO], + )], + }], + result_count: 0, + }; + + let _ = NoopExecutor.execute_batched(&circuit); + } + #[test] fn test_batch_from_resolved_groups_same_gates() { let resolved = ResolvedCircuit::new(vec![ diff --git a/exp/pecos-neo/src/noise.rs b/exp/pecos-neo/src/noise.rs index 4bd490a01..49da95097 100644 --- a/exp/pecos-neo/src/noise.rs +++ b/exp/pecos-neo/src/noise.rs @@ -536,6 +536,33 @@ pub enum NoiseResponse { Multiple(Vec), } +/// A gate-execution capability required by a configured noise mechanism. +/// +/// Noise channels use this metadata to let runners reject incompatible +/// configurations before the first shot. The runtime gate dispatcher remains +/// a defensive backstop for custom channels that do not declare a requirement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NoiseGateRequirement { + /// Gate type the noise mechanism can inject. + pub gate_type: GateType, + /// Setter or constructor that enabled the mechanism. + pub configured_by: &'static str, + /// Concrete configuration change that makes the pairing valid. + pub fix: &'static str, +} + +impl NoiseGateRequirement { + /// Create a gate-execution requirement for a noise mechanism. + #[must_use] + pub const fn new(gate_type: GateType, configured_by: &'static str, fix: &'static str) -> Self { + Self { + gate_type, + configured_by, + fix, + } + } +} + impl NoiseResponse { /// Create a response that injects a single gate. #[must_use] @@ -661,6 +688,17 @@ pub trait NoiseChannel: Send + Sync { 0 } + /// Report injected gates whose runner support must be validated. + /// + /// The default is empty for channels that emit no gates or only gates every + /// target runner supports. Custom channels that can emit rotations or other + /// runner-dependent gates should override this so mismatches fail during + /// configuration; runtime dispatch will panic if an undeclared unsupported + /// gate is reached. + fn gate_requirements(&self) -> SmallVec<[NoiseGateRequirement; 2]> { + SmallVec::new() + } + /// Clone this channel into a boxed trait object. /// /// Required for cloning `ComposableNoiseModel` to support parallel execution. diff --git a/exp/pecos-neo/src/noise/builder.rs b/exp/pecos-neo/src/noise/builder.rs index 3fe42baf7..406292267 100644 --- a/exp/pecos-neo/src/noise/builder.rs +++ b/exp/pecos-neo/src/noise/builder.rs @@ -137,7 +137,7 @@ pub struct NoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights, p2_pauli_weights: TwoQubitPauliWeights, p2_seepage_prob: f64, - p2_idle: f64, + idle_after_2q: f64, // Measurement p_meas_0: f64, @@ -201,7 +201,7 @@ impl NoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights::uniform_pauli(), p2_pauli_weights: TwoQubitPauliWeights::uniform(), p2_seepage_prob: 0.0, - p2_idle: 0.0, + idle_after_2q: 0.0, // Measurement p_meas_0: 0.0, @@ -270,6 +270,16 @@ impl NoiseModelBuilder { self } + /// Set the duration of the idle-noise site applied after each two-qubit gate. + /// + /// A duration of zero disables these sites. Nonzero sites receive all + /// configured linear and quadratic idle mechanisms. + #[must_use] + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = duration; + self + } + /// Set symmetric measurement error probability. #[must_use] pub fn with_measurement_error(mut self, p_meas: f64) -> Self { @@ -541,7 +551,6 @@ impl NoiseModelBuilder { self.p2_emission_ratio, self.p2_emission_weights, self.p2_seepage_prob, - self.p2_idle, ); model = model.add_channel(channel); } @@ -573,16 +582,26 @@ impl NoiseModelBuilder { } // Add idle channel - if self.p_idle_linear_rate > 0.0 { - let mut channel = IdleChannel::linear(self.p_idle_linear_rate); - if self.p_idle_coherent { - channel = channel - .with_coherent_dephasing(true) - .with_coherent_to_incoherent_factor(self.p_idle_coherent_factor); - } else { - channel = channel.with_linear_weights(self.p_idle_linear_weights); - } - model = model.add_channel(channel); + if self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate > 0.0 + || self.idle_after_2q > 0.0 + { + let channel = IdleChannel { + linear_rate: self.p_idle_linear_rate, + linear_weights: self.p_idle_linear_weights, + sin_squared_rate: 0.0, + sin_squared_model: std::collections::BTreeMap::new(), + quadratic_rate: self.p_idle_quadratic_rate, + coherent_dephasing: self.p_idle_coherent, + coherent_to_incoherent_factor: self.p_idle_coherent_factor, + idle_after_2q: self.idle_after_2q, + }; + model = model.add_channel_configured_by( + channel, + "NoiseModelBuilder::with_coherent_idle(..)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to a \ + stochastic idle family by removing with_coherent_idle(..)", + ); } // Add leakage channel (if scale differs from default of 1.0) @@ -606,10 +625,22 @@ mod tests { use super::*; use crate::command::CommandBuilder; use crate::noise::composite::prelude::*; + use crate::noise::{NoiseEvent, NoiseResponse}; use crate::runner::CircuitRunner; use pecos_core::QubitId; + use pecos_random::PecosRng; use pecos_simulators::SparseStab; + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + #[test] fn test_simple_depolarizing() { let model = NoiseModelBuilder::new().with_depolarizing(0.1, 0.2).build(); @@ -647,6 +678,114 @@ mod tests { assert!(model.channel_count() >= 1); } + #[test] + fn after_2q_builder_routes_quadratic_noise_without_a_two_qubit_channel() { + let mut model = NoiseModelBuilder::new() + .with_idle_noise(0.0, std::f64::consts::PI) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(47))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); + } + + #[test] + fn idle_builder_routes_weights_and_quadratic_rate_in_coherent_mode() { + let mut builder = NoiseModelBuilder::new() + .with_idle_noise(1.0, 0.75) + .with_coherent_idle(1.0) + .with_idle_after_2q(2.0); + builder.p_idle_linear_weights = crate::noise::PauliWeights::custom(1.0, 0.0, 0.0); + let mut model = builder.build(); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(53))); + let x_count = gates + .iter() + .filter(|gate| gate.gate_type == GateType::X) + .count(); + let rz_angles = gates + .iter() + .filter(|gate| gate.gate_type == GateType::RZ) + .map(|gate| gate.angles[0].to_radians()) + .collect::>(); + + assert_eq!(x_count, 2); + assert_eq!(rz_angles.len(), 2); + assert!(rz_angles.iter().all(|angle| (*angle - 1.5).abs() < 1e-15)); + } + + #[test] + fn idle_builder_routes_the_incoherent_conversion_factor() { + let mut builder = NoiseModelBuilder::new() + .with_idle_noise(0.0, std::f64::consts::FRAC_PI_2) + .with_idle_after_2q(1.0); + builder.p_idle_coherent_factor = 2.0; + let mut model = builder.build(); + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(59))); + assert_eq!(gates.len(), qubits.len()); + } + + #[test] + fn after_2q_duration_alone_still_builds_the_idle_policy_channel() { + let mut model = NoiseModelBuilder::new().with_idle_after_2q(1.0).build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + assert!( + model + .emit(&event, &mut PecosRng::seed_from_u64(61)) + .is_none() + ); + } + + #[test] + fn quadratic_only_configuration_builds_the_idle_channel() { + let mut model = NoiseModelBuilder::new() + .with_idle_noise(0.0, std::f64::consts::PI) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: 1_u64.into(), + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(67))); + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::Z); + } + #[test] fn test_composed_vs_simple_parity() { // Build the same noise model two ways and verify similar behavior diff --git a/exp/pecos-neo/src/noise/composer.rs b/exp/pecos-neo/src/noise/composer.rs index a7ec9cd4b..8e983cad6 100644 --- a/exp/pecos-neo/src/noise/composer.rs +++ b/exp/pecos-neo/src/noise/composer.rs @@ -22,7 +22,8 @@ use super::context::NoiseContext; use super::idle::IdleChannel; use super::plugin::{ContextObserver, EventHandler, NoiseModelConfig, NoisePlugin}; -use super::{NoiseChannel, NoiseEvent, NoiseResponse}; +use super::{NoiseChannel, NoiseEvent, NoiseGateRequirement, NoiseResponse}; +use crate::command::GateType; use pecos_core::{QubitId, TimeScale}; use pecos_random::PecosRng; @@ -59,6 +60,9 @@ pub struct ComposableNoiseModel { /// Noise channels that produce noise responses. channels: Vec>, + /// Runner capabilities required by configured gate-injection mechanisms. + gate_requirements: Vec, + /// Observers that react to context state changes. observers: Vec>, @@ -76,6 +80,7 @@ impl std::fmt::Debug for ComposableNoiseModel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ComposableNoiseModel") .field("time_scale", &self.time_scale) + .field("gate_requirements", &self.gate_requirements) .field("event_handler_count", &self.event_handlers.len()) .field( "event_handler_names", @@ -109,6 +114,7 @@ impl ComposableNoiseModel { Self { event_handlers: Vec::new(), channels: Vec::new(), + gate_requirements: Vec::new(), observers: Vec::new(), context: NoiseContext::new(), time_scale: None, @@ -186,6 +192,12 @@ impl ComposableNoiseModel { // Transfer registered components from config to model self.event_handlers.extend(config.event_handlers); + self.gate_requirements.extend( + config + .channels + .iter() + .flat_map(|channel| channel.gate_requirements()), + ); self.channels.extend(config.channels); self.observers.extend(config.observers); @@ -201,6 +213,26 @@ impl ComposableNoiseModel { /// For plugin-based configuration, use `add_plugin()` instead. #[must_use] pub fn add_channel(mut self, channel: impl NoiseChannel + 'static) -> Self { + self.gate_requirements.extend(channel.gate_requirements()); + self.channels.push(Box::new(channel)); + self + } + + /// Add a channel while recording the builder setter that configured it. + pub(crate) fn add_channel_configured_by( + mut self, + channel: impl NoiseChannel + 'static, + configured_by: &'static str, + fix: &'static str, + ) -> Self { + self.gate_requirements + .extend(channel.gate_requirements().into_iter().map(|requirement| { + NoiseGateRequirement { + configured_by, + fix, + ..requirement + } + })); self.channels.push(Box::new(channel)); self } @@ -211,10 +243,53 @@ impl ComposableNoiseModel { /// or other source. For most cases, use [`Self::add_channel`] instead. #[must_use] pub fn add_boxed_channel(mut self, channel: Box) -> Self { + self.gate_requirements.extend(channel.gate_requirements()); self.channels.push(channel); self } + /// Validate gate-injection requirements against a runner configuration. + /// + /// # Errors + /// + /// Returns a diagnostic naming the configuring setter, incompatible runner, + /// and concrete fix when an injected gate is unsupported. + pub(crate) fn validate_runner_gate_support( + &self, + runner: &str, + has_rotation_support: bool, + ) -> Result<(), String> { + for requirement in &self.gate_requirements { + let gate_type = requirement.gate_type; + if supports_clifford_noise_gate(gate_type) + || (has_rotation_support && supports_rotation_noise_gate(gate_type)) + { + continue; + } + + let needs_rotation = supports_rotation_noise_gate(gate_type); + let fix = if runner == "ImportanceSamplingRunner" && needs_rotation { + "switch to a stochastic noise mechanism (for coherent idle configuration, use \ + the stochastic idle family with with_p_idle_coherent(false)); \ + ImportanceSamplingRunner does not provide a rotation executor" + } else { + requirement.fix + }; + let limitation = if needs_rotation { + "has no rotation executor and cannot represent that noise gate" + } else { + "cannot execute that injected gate with any supported executor" + }; + return Err(format!( + "{} configures a noise mechanism that can inject {gate_type:?}, but {runner} \ + {limitation}; {fix}.", + requirement.configured_by + )); + } + + Ok(()) + } + /// Add an event handler directly to the model. /// /// For plugin-based configuration, use `add_plugin()` instead. @@ -235,14 +310,17 @@ impl ComposableNoiseModel { /// Add an idle channel with T1/T2 times in physical units. /// - /// Requires `with_time_scale()` to be called first. + /// Requires `with_time_scale()` to be called first. T2 is total transverse coherence time, + /// not pure-dephasing Tphi. The first-order Pauli-twirl mapping, physical bound, validity + /// domain, and numerical compatibility note are documented by [`IdleChannel::from_t1_t2`]. /// /// # Arguments /// * `t1_seconds` - T1 relaxation time in seconds - /// * `t2_seconds` - T2 dephasing time in seconds + /// * `t2_seconds` - Total T2 transverse coherence time in seconds /// /// # Panics - /// Panics if `with_time_scale()` has not been called. + /// Panics if `with_time_scale()` has not been called, if either time is non-finite or not + /// greater than zero, or if `t2_seconds > 2 * t1_seconds`. /// /// # Example /// ``` @@ -258,10 +336,7 @@ impl ComposableNoiseModel { let scale = self .time_scale .expect("with_time_scale() must be called before with_idle_t1_t2()"); - // Convert physical times to time units - let t1_units = scale.from_seconds(t1_seconds).as_f64(); - let t2_units = scale.from_seconds(t2_seconds).as_f64(); - let channel = IdleChannel::from_t1_t2(t1_units, t2_units); + let channel = IdleChannel::from_t1_t2_seconds(t1_seconds, t2_seconds, scale); self.add_channel(channel) } @@ -476,6 +551,53 @@ impl ComposableNoiseModel { } } +fn supports_clifford_noise_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::I + | GateType::X + | GateType::Y + | GateType::Z + | GateType::H + | GateType::F + | GateType::Fdg + | GateType::SX + | GateType::SXdg + | GateType::SY + | GateType::SYdg + | GateType::SZ + | GateType::SZdg + | GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SWAP + ) +} + +fn supports_rotation_noise_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::T + | GateType::Tdg + | GateType::RX + | GateType::RY + | GateType::RZ + | GateType::U + | GateType::R1XY + | GateType::CRZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ + | GateType::CCX + ) +} + // ============================================================================ // From implementations for ergonomic noise model construction // ============================================================================ @@ -485,6 +607,7 @@ impl Clone for ComposableNoiseModel { Self { event_handlers: self.event_handlers.iter().map(|h| h.clone_box()).collect(), channels: self.channels.iter().map(|c| c.clone_box()).collect(), + gate_requirements: self.gate_requirements.clone(), observers: self.observers.iter().map(|o| o.clone_box()).collect(), context: self.context.clone(), time_scale: self.time_scale, diff --git a/exp/pecos-neo/src/noise/composite/action.rs b/exp/pecos-neo/src/noise/composite/action.rs index 3b04a24b1..5e8723bea 100644 --- a/exp/pecos-neo/src/noise/composite/action.rs +++ b/exp/pecos-neo/src/noise/composite/action.rs @@ -17,7 +17,7 @@ use super::response::CompositeResponse; use crate::command::{GateCommand, GateType}; -use crate::noise::NoiseContext; +use crate::noise::{NoiseContext, NoiseGateRequirement}; use pecos_core::QubitId; use pecos_random::PecosRng; use rand::RngExt; @@ -38,6 +38,23 @@ pub trait GateAction: Send + Sync { /// Human-readable name for visualization. fn name(&self) -> &'static str; + + /// Runner capabilities required by gates this action can inject. + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + smallvec::SmallVec::new() + } +} + +pub(super) fn injected_gate_requirement( + gate_type: GateType, + configured_by: &'static str, +) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + smallvec![NoiseGateRequirement::new( + gate_type, + configured_by, + "supply a rotation executor with CircuitRunner::rotations() when the gate is a supported \ + rotation, or replace the gate-injection action with a stochastic Pauli action", + )] } /// No-op action - does nothing. @@ -168,6 +185,10 @@ impl GateAction for Inject { fn name(&self) -> &'static str { "inject" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(self.gate_type, "Inject::new(..)") + } } /// Pauli weights for random Pauli sampling. @@ -1099,6 +1120,10 @@ impl GateAction for InjectCoherentRZ { fn name(&self) -> &'static str { "coherent_rz" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(GateType::RZ, "InjectCoherentRZ::new(..)") + } } // --- Amplitude Damping (T1 Relaxation) --- @@ -1288,6 +1313,10 @@ impl GateAction for CoherentRotation { fn name(&self) -> &'static str { "coherent_rotation" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(self.gate_type, "CoherentRotation::new(..)") + } } /// Over-rotation error: adds a fraction of the gate's angle as extra rotation. @@ -1361,6 +1390,10 @@ impl GateAction for OverRotation { fn name(&self) -> &'static str { "over_rotation" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + injected_gate_requirement(self.gate_type, "OverRotation::new(..)") + } } // --- Correlated Phase Errors (ZZ Dephasing) --- @@ -1431,6 +1464,15 @@ impl GateAction for ZZDephasing { fn name(&self) -> &'static str { "zz_dephasing" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = injected_gate_requirement(GateType::RZ, "ZZDephasing::new(..)"); + requirements.extend(injected_gate_requirement( + GateType::RZZ, + "ZZDephasing::new(..)", + )); + requirements + } } /// ZZ dephasing with rate (angle = rate * duration). @@ -1481,6 +1523,16 @@ impl GateAction for ZZDephasingRate { fn name(&self) -> &'static str { "zz_dephasing_rate" } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = + injected_gate_requirement(GateType::RZ, "ZZDephasing::from_rate(..)"); + requirements.extend(injected_gate_requirement( + GateType::RZZ, + "ZZDephasing::from_rate(..)", + )); + requirements + } } // --- Preparation Errors --- diff --git a/exp/pecos-neo/src/noise/composite/builder.rs b/exp/pecos-neo/src/noise/composite/builder.rs index 0bb936ae9..0bd9a0acf 100644 --- a/exp/pecos-neo/src/noise/composite/builder.rs +++ b/exp/pecos-neo/src/noise/composite/builder.rs @@ -43,7 +43,7 @@ use super::prelude::*; use crate::command::GateType; use crate::noise::two_qubit::AngleScaling; use crate::noise::{ - ComposableNoiseModel, CrosstalkTransitions, SingleQubitEmissionWeights, + ComposableNoiseModel, CrosstalkTransitions, IdleChannel, SingleQubitEmissionWeights, TwoQubitEmissionWeights, TwoQubitPauliWeights, }; use pecos_core::TimeScale; @@ -122,7 +122,7 @@ pub struct CompositeNoiseModelBuilder { p2_pauli_model: Option, p2_emission_model: Option, p2_angle_scaling: Option, - p2_idle_rate: f64, + idle_after_2q: f64, // Preparation parameters p_prep: f64, @@ -177,7 +177,7 @@ impl Default for CompositeNoiseModelBuilder { p2_pauli_model: None, p2_emission_model: None, p2_angle_scaling: None, - p2_idle_rate: 0.0, + idle_after_2q: 0.0, p_prep: 0.0, p_prep_leak_ratio: 0.0, p_prep_crosstalk: 0.0, @@ -395,13 +395,13 @@ impl CompositeNoiseModelBuilder { self } - /// Set idle noise rate after two-qubit gates. + /// Set the duration of the idle-noise site applied after each two-qubit gate. /// - /// This applies additional stochastic noise after each two-qubit gate, - /// modeling the fact that two-qubit gates often take longer. + /// A duration of zero disables these sites. Nonzero sites receive all + /// configured linear and quadratic idle mechanisms. #[must_use] - pub fn with_p2_idle(mut self, rate: f64) -> Self { - self.p2_idle_rate = validate_probability(rate, "p2_idle"); + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = validate_rate(duration, "idle_after_2q"); self } @@ -576,7 +576,8 @@ impl CompositeNoiseModelBuilder { /// Set the quadratic idle noise rate (T2-like dephasing). /// - /// The error probability follows: `p = sin(rate * duration)^2` + /// The error probability follows: `p = sin(rate * duration / 2)^2`, + /// the exact Pauli twirl of the coherent RZ rotation. /// /// This models coherent dephasing converted to stochastic errors. /// Use `with_p_idle_coherent(true)` to use actual coherent RZ rotations instead. @@ -593,7 +594,7 @@ impl CompositeNoiseModelBuilder { /// such as frequency offsets in physical systems. /// /// When `false` (default), dephasing is modeled as stochastic Z errors - /// with probability `sin(rate * duration)^2`. + /// with probability `sin(rate * duration / 2)^2`. #[must_use] pub fn with_p_idle_coherent(mut self, coherent: bool) -> Self { self.p_idle_coherent = coherent; @@ -706,15 +707,16 @@ impl CompositeNoiseModelBuilder { /// Set T1/T2 relaxation times in physical units (seconds). /// - /// This is a convenience method that converts physical T1/T2 times to - /// the internal rate parameters based on the configured time scale. - /// - /// - T1 (amplitude damping): Sets linear idle noise rate = 1/T1 - /// - T2 (dephasing): Sets quadratic idle noise rate = 1/T2^2 + /// This is a convenience method that converts physical T1/T2 times to the internal rate + /// parameters based on the configured time scale. T2 is total transverse coherence time, not + /// pure-dephasing Tphi. The first-order Pauli-twirl mapping, physical bound, validity domain, + /// and numerical compatibility note are documented by [`IdleChannel::from_t1_t2`]. The + /// convenience configures the linear family and leaves the quadratic family unused. /// /// # Panics /// - /// Panics if `with_time_scale()` has not been called first. + /// Panics if `with_time_scale()` has not been called first, if either time is non-finite or not + /// greater than zero, or if `t2_seconds > 2 * t1_seconds`. /// /// # Example /// @@ -733,13 +735,14 @@ impl CompositeNoiseModelBuilder { .time_scale .expect("with_time_scale() must be called before with_idle_t1_t2()"); - // Convert physical times to time units - let t1_units = scale.from_seconds(t1_seconds).as_f64(); - let t2_units = scale.from_seconds(t2_seconds).as_f64(); - - // Set rates: linear_rate = 1/T1, quadratic_rate = 1/T2^2 - self.p_idle_linear_rate = 1.0 / t1_units.max(1.0); - self.p_idle_quadratic_rate = 1.0 / (t2_units * t2_units).max(1.0); + let channel = IdleChannel::from_t1_t2_seconds(t1_seconds, t2_seconds, scale); + self.p_idle_linear_rate = channel.linear_rate; + self.p_idle_linear_pauli_weights = Some(PauliWeights::custom( + channel.linear_weights.x, + channel.linear_weights.y, + channel.linear_weights.z, + )); + self.p_idle_quadratic_rate = channel.quadratic_rate; self } @@ -793,8 +796,8 @@ impl CompositeNoiseModelBuilder { model = model.add_channel(channel); } - // Two-qubit gate noise (including p2_idle) - if self.p2 > 0.0 || self.p2_idle_rate > 0.0 { + // Two-qubit gate noise + if self.p2 > 0.0 { let tq_noise = self.build_two_qubit_noise(); let channel = CompositeChannelBuilder::two_qubit("flow_tq", tq_noise); model = model.add_channel(channel); @@ -853,11 +856,37 @@ impl CompositeNoiseModelBuilder { model = model.add_channel(channel); } - // Idle noise (T1/T2) - if self.p_idle_linear_rate > 0.0 || self.p_idle_quadratic_rate > 0.0 { - let idle_noise = self.build_idle_noise(); - let channel = CompositeChannelBuilder::idle("flow_idle", idle_noise); - model = model.add_channel(channel); + // Idle noise (T1/T2 and operand-local sites after two-qubit gates) + if self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate > 0.0 + || self.idle_after_2q > 0.0 + { + let composite_weights = self + .p_idle_linear_pauli_weights + .unwrap_or_else(PauliWeights::uniform) + .normalized(); + let total = composite_weights.x + composite_weights.y + composite_weights.z; + let linear_weights = crate::noise::PauliWeights::custom( + composite_weights.x / total, + composite_weights.y / total, + composite_weights.z / total, + ); + let channel = IdleChannel { + linear_rate: self.p_idle_linear_rate, + linear_weights, + sin_squared_rate: 0.0, + sin_squared_model: std::collections::BTreeMap::new(), + quadratic_rate: self.p_idle_quadratic_rate, + coherent_dephasing: self.p_idle_coherent, + coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, + idle_after_2q: self.idle_after_2q, + }; + model = model.add_channel_configured_by( + channel, + "CompositeNoiseModelBuilder::with_p_idle_coherent(true)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to the \ + stochastic idle family with with_p_idle_coherent(false)", + ); } // Before-gate channel for skip logic (if leakage is enabled) @@ -983,18 +1012,10 @@ impl CompositeNoiseModelBuilder { /// When angle scaling is configured, uses `prob_fn` for dynamic probability. /// Otherwise, uses constant `prob`. fn build_two_qubit_noise(&self) -> BoxSeq { - // Check if we need angle-dependent probability - let main_noise = if let Some(scaling) = self.p2_angle_scaling { + if let Some(scaling) = self.p2_angle_scaling { self.build_two_qubit_noise_angle_scaled(scaling) } else { self.build_two_qubit_noise_constant() - }; - - // Add idle noise after the gate if configured (memory sweeping) - if self.p2_idle_rate > 0.0 { - seq![main_noise, prob(self.p2_idle_rate, inject_z()),] - } else { - main_noise } } @@ -1196,74 +1217,29 @@ impl CompositeNoiseModelBuilder { ] } } - - /// Build idle noise primitive (T1/T2). - fn build_idle_noise(&self) -> BoxSeq { - use super::action::InjectCoherentRZ; - use super::primitive::{ProbLinear, ProbQuadratic}; - - // T1 uses custom Pauli weights if specified, otherwise uniform - let make_t1_pauli = || match self.p_idle_linear_pauli_weights { - Some(w) => Pauli::new(w), - None => Pauli::uniform(), - }; - - // T2: either coherent RZ rotations or stochastic Z errors - let make_t2_stochastic = || { - ProbQuadratic::new(self.p_idle_quadratic_rate, inject_z()) - .with_factor(self.p_idle_coherent_to_incoherent_factor) - }; - - let make_t2_coherent = || InjectCoherentRZ::new(self.p_idle_quadratic_rate); - - match ( - self.p_idle_linear_rate > 0.0, - self.p_idle_quadratic_rate > 0.0, - self.p_idle_coherent, - ) { - (true, true, false) => { - // Both T1 (linear) and T2 (quadratic stochastic) noise - seq![ - ProbLinear::new(self.p_idle_linear_rate, make_t1_pauli()), - make_t2_stochastic(), - ] - } - (true, true, true) => { - // Both T1 (linear stochastic) and T2 (coherent RZ) noise - seq![ - ProbLinear::new(self.p_idle_linear_rate, make_t1_pauli()), - make_t2_coherent(), - ] - } - (true, false, _) => { - // Only T1 (linear) noise - seq![ProbLinear::new(self.p_idle_linear_rate, make_t1_pauli()),] - } - (false, true, false) => { - // Only T2 (quadratic stochastic) noise - seq![make_t2_stochastic(),] - } - (false, true, true) => { - // Only T2 (coherent RZ) noise - seq![make_t2_coherent(),] - } - (false, false, _) => { - // No idle noise (shouldn't reach here due to caller check) - seq![nothing(),] - } - } - } } #[cfg(test)] #[allow(clippy::cast_precision_loss)] // statistical tests use count as f64 mod tests { use super::*; - use crate::command::CommandBuilder; + use crate::command::{CommandBuilder, GateCommand}; + use crate::noise::{NoiseEvent, NoiseResponse}; use crate::runner::CircuitRunner; use pecos_core::QubitId; + use pecos_random::PecosRng; use pecos_simulators::SparseStab; + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + #[test] fn test_empty_builder() { let model = CompositeNoiseModelBuilder::new().build(); @@ -1495,112 +1471,54 @@ mod tests { } #[test] - fn test_p2_idle() { - let model = CompositeNoiseModelBuilder::new() - .with_p2(0.01) - .with_p2_idle(0.001) + fn after_2q_idle_uses_idle_channel_without_p2() { + let mut model = CompositeNoiseModelBuilder::new() + .with_p_idle_linear(1.0) + .with_p_idle_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0) .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); - // Should have TQ channel (p2_idle is integrated into the TQ noise) - assert_eq!(model.channel_count(), 1); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(73))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); } #[test] - fn test_p2_idle_primitive() { - use crate::noise::NoiseChannel; - use pecos_random::PecosRng; - - // Test that the primitive applies idle noise correctly - // Build the primitive directly - let tq_noise = seq![ - prob(0.0, pauli()), // No main error - prob(1.0, inject_z()), // 100% idle Z error - ]; - - let channel = CompositeChannelBuilder::two_qubit("test_idle", tq_noise); - - let mut ctx = crate::noise::NoiseContext::new(); - let mut rng = PecosRng::seed_from_u64(42); + fn quadratic_only_after_2q_noise_uses_the_idle_channel() { + let mut model = CompositeNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); - // Create a two-qubit gate event let qubits = [QubitId(0), QubitId(1)]; - let event = crate::noise::NoiseEvent::AfterGate { + let event = NoiseEvent::AfterGate { gate_type: GateType::CX, qubits: &qubits, angles: &[], gate_id: None, }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(79))); - // Check that the channel responds to this event - assert!( - channel.responds_to(&event), - "Channel should respond to AfterGate with 2 qubits" - ); - - // Apply and check that response is not None (gates were injected) - let response = channel.apply(&event, &mut ctx, &mut rng); - assert!( - !response.is_none(), - "With 100% idle error, should inject Z gates (response should not be None)" - ); + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); } #[test] - fn test_p2_idle_statistical() { - // Test that p2_idle works via the builder - // Use high p2 (which also applies p2_idle) to ensure the channel triggers - let commands = CommandBuilder::new() - .pz(&[0]) - .pz(&[1]) - .h(&[0]) // Make qubit 0 in superposition - .cx(&[(0, 1)]) // Two-qubit gate - .mz(&[0]) - .mz(&[1]) + fn after_2q_duration_without_rates_builds_only_the_idle_channel() { + let model = CompositeNoiseModelBuilder::new() + .with_idle_after_2q(2.0) .build(); - - let shots = 500; - let p2 = 0.5; // 50% gate error rate - - let mut errors_with = 0; - let mut errors_without = 0; - - for seed in 0..shots { - // With p2 error - let model_with = CompositeNoiseModelBuilder::new().with_p2(p2).build(); - let mut state = SparseStab::with_seed(2, seed); - let mut runner = CircuitRunner::::new() - .with_noise(model_with) - .with_seed(seed); - let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); - let q0 = outcomes.get(QubitId(0)).is_some_and(|o| o.outcome); - let q1 = outcomes.get(QubitId(1)).is_some_and(|o| o.outcome); - // Bell state: q0 != q1 indicates error - if q0 != q1 { - errors_with += 1; - } - - // Without noise - let model_without = CompositeNoiseModelBuilder::new().build(); - let mut state = SparseStab::with_seed(2, seed); - let mut runner = CircuitRunner::::new() - .with_noise(model_without) - .with_seed(seed); - let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); - let q0 = outcomes.get(QubitId(0)).is_some_and(|o| o.outcome); - let q1 = outcomes.get(QubitId(1)).is_some_and(|o| o.outcome); - if q0 != q1 { - errors_without += 1; - } - } - - // With 50% error, should see significantly more errors - let rate_with = f64::from(errors_with) / shots as f64; - let rate_without = f64::from(errors_without) / shots as f64; - - assert!( - rate_with > rate_without + 0.1, - "With p2={p2}, expected more errors ({rate_with}) than without ({rate_without})" - ); + assert_eq!(model.channel_names(), ["IdleChannel"]); } // Note: CompositeNoiseModelBuilder no longer implements Clone because it can hold @@ -2122,12 +2040,22 @@ mod tests { #[test] fn test_idle_t1_t2_configuration() { // T1=50us, T2=30us with nanosecond time units - let model = CompositeNoiseModelBuilder::new() + let builder = CompositeNoiseModelBuilder::new() .with_time_scale(TimeScale::NANOSECONDS) - .with_idle_t1_t2(50e-6, 30e-6) - .build(); + .with_idle_t1_t2(50e-6, 30e-6); + let expected = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + let actual_weights = builder + .p_idle_linear_pauli_weights + .expect("T1/T2 convenience must set linear Pauli weights"); + + assert!((builder.p_idle_linear_rate - expected.linear_rate).abs() < f64::EPSILON); + assert!((actual_weights.x - expected.linear_weights.x).abs() < f64::EPSILON); + assert!((actual_weights.y - expected.linear_weights.y).abs() < f64::EPSILON); + assert!((actual_weights.z - expected.linear_weights.z).abs() < f64::EPSILON); + assert!((builder.p_idle_quadratic_rate - expected.quadratic_rate).abs() < f64::EPSILON); // Should have created an idle channel + let model = builder.build(); assert_eq!(model.channel_count(), 1); // Should have time scale set assert!(model.time_scale().is_some()); diff --git a/exp/pecos-neo/src/noise/composite/channel.rs b/exp/pecos-neo/src/noise/composite/channel.rs index aeb3edc6f..2f66c2159 100644 --- a/exp/pecos-neo/src/noise/composite/channel.rs +++ b/exp/pecos-neo/src/noise/composite/channel.rs @@ -19,7 +19,7 @@ use super::Primitive; use super::batch::GeometricSampler; use super::response::CompositeResponse; -use crate::noise::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse}; +use crate::noise::{NoiseChannel, NoiseContext, NoiseEvent, NoiseGateRequirement, NoiseResponse}; use pecos_core::QubitId; use pecos_random::PecosRng; use smallvec::smallvec; @@ -436,6 +436,10 @@ impl NoiseChannel for CompositeChannel

{ self.priority } + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitive.gate_requirements() + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } @@ -907,6 +911,10 @@ impl NoiseChannel for BatchCompositeChannel

{ self.priority } + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitive.gate_requirements() + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } @@ -1155,6 +1163,10 @@ impl NoiseChannel for CompositeCrosstalkChannel< self.priority } + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitive.gate_requirements() + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } diff --git a/exp/pecos-neo/src/noise/composite/compiled.rs b/exp/pecos-neo/src/noise/composite/compiled.rs index dcfc9b013..35a0d9e94 100644 --- a/exp/pecos-neo/src/noise/composite/compiled.rs +++ b/exp/pecos-neo/src/noise/composite/compiled.rs @@ -34,7 +34,8 @@ use super::action::PauliWeights; use super::response::CompositeResponse; use crate::command::{GateCommand, GateType}; use crate::noise::{ - NoiseContext, SingleQubitEmissionWeights, TwoQubitEmissionWeights, TwoQubitPauliWeights, + NoiseContext, NoiseGateRequirement, SingleQubitEmissionWeights, TwoQubitEmissionWeights, + TwoQubitPauliWeights, }; use pecos_core::QubitId; use pecos_random::PecosRng; @@ -84,6 +85,16 @@ pub enum CompiledAction { } impl CompiledAction { + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + match self { + Self::Inject(gate_type) => { + super::action::injected_gate_requirement(*gate_type, "CompiledAction::Inject(..)") + } + Self::Custom(primitive) => primitive.gate_requirements(), + _ => smallvec::SmallVec::new(), + } + } + /// Apply this action. #[inline] pub fn apply( @@ -206,6 +217,32 @@ pub enum CompiledPrimitive { } impl CompiledPrimitive { + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + match self { + Self::Action(action) => action.gate_requirements(), + Self::Prob { inner, .. } => inner.gate_requirements(), + Self::When { + then_branch, + else_branch, + .. + } => { + let mut requirements = then_branch.gate_requirements(); + requirements.extend(else_branch.gate_requirements()); + requirements + } + Self::Sample { branches, .. } => branches + .iter() + .flat_map(|(_, primitive)| primitive.gate_requirements()) + .collect(), + Self::Seq(primitives) => primitives + .iter() + .flat_map(CompiledPrimitive::gate_requirements) + .collect(), + Self::Custom(primitive) => primitive.gate_requirements(), + Self::SkipIf(_) => smallvec::SmallVec::new(), + } + } + /// Apply this primitive. #[allow(clippy::missing_panics_doc)] // internal invariant: Sample always has branches #[inline] @@ -302,6 +339,10 @@ impl Primitive for CompiledPrimitive { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + CompiledPrimitive::gate_requirements(self) + } } // ============================================================================ diff --git a/exp/pecos-neo/src/noise/composite/primitive.rs b/exp/pecos-neo/src/noise/composite/primitive.rs index 3b7d24c98..ab566029b 100644 --- a/exp/pecos-neo/src/noise/composite/primitive.rs +++ b/exp/pecos-neo/src/noise/composite/primitive.rs @@ -20,7 +20,7 @@ use std::fmt::Write as _; use super::action::GateAction; use super::condition::Condition; use super::response::CompositeResponse; -use crate::noise::NoiseContext; +use crate::noise::{NoiseContext, NoiseGateRequirement}; use pecos_core::QubitId; use pecos_random::PecosRng; use rand::RngExt; @@ -44,6 +44,11 @@ pub trait Primitive: Send + Sync { /// Clone this primitive into a boxed trait object. fn clone_box(&self) -> Box; + /// Runner capabilities required by gates this primitive can inject. + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + smallvec::SmallVec::new() + } + /// Multi-line tree representation for debugging. /// /// Returns a tree-formatted string showing the structure of composed primitives. @@ -334,6 +339,12 @@ impl Primitive for TwoStage { stage2: self.stage2.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = self.stage1.gate_requirements(); + requirements.extend(self.stage2.gate_requirements()); + requirements + } } impl Primitive for Box { @@ -383,6 +394,10 @@ impl Primitive for Box { fn clone_box(&self) -> Box { (**self).clone_box() } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + (**self).gate_requirements() + } } // Implement Primitive for all GateActions @@ -403,6 +418,10 @@ impl Primitive for A { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + GateAction::gate_requirements(self) + } } /// Probability gate: with probability p, execute inner primitive. @@ -482,6 +501,10 @@ impl Primitive for Prob

{ inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Dynamic probability gate: compute probability from gate context. @@ -576,6 +599,10 @@ where inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Linear time-dependent probability: p = rate * duration. @@ -648,6 +675,10 @@ impl Primitive for ProbLinear

{ inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Quadratic time-dependent dephasing: p = sin(rate * duration)^2. @@ -739,6 +770,10 @@ impl Primitive for ProbQuadratic

{ inner: self.inner.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.inner.gate_requirements() + } } /// Conditional: if condition is true, execute `then_branch`, else `else_branch`. @@ -809,6 +844,12 @@ impl Primitive for W else_branch: self.else_branch.clone_box(), }) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + let mut requirements = self.then_branch.gate_requirements(); + requirements.extend(self.else_branch.gate_requirements()); + requirements + } } /// Weighted sample: choose one branch based on weights. @@ -914,6 +955,13 @@ impl Primitive for Sample

{ .collect(), )) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.branches + .iter() + .flat_map(|(_, primitive)| primitive.gate_requirements()) + .collect() + } } /// Sequential: execute all primitives in order, combine responses. @@ -988,6 +1036,13 @@ impl Primitive for Seq

{ self.primitives.iter().map(Primitive::clone_box).collect(), )) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitives + .iter() + .flat_map(Primitive::gate_requirements) + .collect() + } } /// Sequential execution of heterogeneous primitives using trait objects. @@ -1063,6 +1118,13 @@ impl Primitive for BoxSeq { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.primitives + .iter() + .flat_map(Primitive::gate_requirements) + .collect() + } } /// Early exit: if condition is true, return `SkipGate` response. @@ -1258,6 +1320,13 @@ impl Primitive for BoxSample { fn clone_box(&self) -> Box { Box::new(self.clone()) } + + fn gate_requirements(&self) -> smallvec::SmallVec<[NoiseGateRequirement; 2]> { + self.branches + .iter() + .flat_map(|(_, primitive)| primitive.gate_requirements()) + .collect() + } } /// Convenience functions for creating primitives. diff --git a/exp/pecos-neo/src/noise/general_builder.rs b/exp/pecos-neo/src/noise/general_builder.rs index 3e5394cf4..94c211a44 100644 --- a/exp/pecos-neo/src/noise/general_builder.rs +++ b/exp/pecos-neo/src/noise/general_builder.rs @@ -49,6 +49,7 @@ use super::{ }; use crate::command::GateType; use pecos_core::TimeScale; +use std::collections::BTreeMap; /// Builder for creating a noise model equivalent to `GeneralNoiseModel`. /// @@ -96,7 +97,7 @@ pub struct GeneralNoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights, p2_pauli_weights: TwoQubitPauliWeights, p2_seepage_prob: f64, - p2_idle: f64, + idle_after_2q: f64, // Measurement p_meas_0: f64, @@ -110,6 +111,8 @@ pub struct GeneralNoiseModelBuilder { p_idle_linear_rate: f64, p_idle_linear_weights: PauliWeights, p_idle_quadratic_rate: f64, + p_idle_quadratic_configured: bool, + p_idle_sin_squared: Option<(f64, BTreeMap)>, p_idle_coherent: bool, p_idle_coherent_to_incoherent_factor: f64, @@ -156,7 +159,7 @@ impl GeneralNoiseModelBuilder { p2_emission_weights: TwoQubitEmissionWeights::uniform_pauli(), p2_pauli_weights: TwoQubitPauliWeights::uniform(), p2_seepage_prob: 0.0, - p2_idle: 0.0, + idle_after_2q: 0.0, // Measurement p_meas_0: 0.0, @@ -170,6 +173,8 @@ impl GeneralNoiseModelBuilder { p_idle_linear_rate: 0.0, p_idle_linear_weights: PauliWeights::custom(0.0, 0.0, 1.0), // Z-only p_idle_quadratic_rate: 0.0, + p_idle_quadratic_configured: false, + p_idle_sin_squared: None, p_idle_coherent: false, p_idle_coherent_to_incoherent_factor: 1.0, @@ -328,10 +333,13 @@ impl GeneralNoiseModelBuilder { self } - /// Set idle noise rate applied after two-qubit gates. + /// Set the duration of the idle-noise site applied after each two-qubit gate. + /// + /// A duration of zero disables these sites. Nonzero sites receive all + /// configured linear and quadratic idle mechanisms. #[must_use] - pub fn with_p2_idle(mut self, rate: f64) -> Self { - self.p2_idle = rate; + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = duration; self } @@ -385,19 +393,39 @@ impl GeneralNoiseModelBuilder { // Idle noise parameters // ======================================================================== - /// Set the linear idle noise rate (per time unit). + /// Set the DEM-style linear idle-noise family. /// - /// The rate interpretation depends on your `TimeScale` configuration. - #[must_use] - pub fn with_p_idle_linear(mut self, rate: f64) -> Self { - self.p_idle_linear_rate = rate; - self - } - - /// Set the Pauli distribution for linear idle noise. + /// `rate` is the total event rate per time unit. For an idle of duration `d`, one event is + /// sampled with probability `rate * d`, then its X, Y, or Z axis is drawn from `model`. The + /// model must therefore be a normalized distribution: this linear family splits one total + /// rate across its axes. + /// + /// In contrast, [`Self::with_p_idle_sin_squared`] takes radians per time unit and an + /// unnormalized model because sine laws do not add linearly: each axis carries its own + /// independent rate. That setter applies no `2*pi` conversion and no + /// `coherent_to_incoherent_factor`, unlike [`Self::with_p_idle_quadratic`]. + /// + /// Neo's linear family stores its model in [`PauliWeights`], so it cannot represent the DEM's + /// L axis. An L key is rejected; use neo's [`LeakageChannel`] for linear leakage. The new + /// sine-squared family uses separate map storage and accepts X, Y, Z, and L. + /// + /// All neo idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. + /// + /// The linear sampling structure deliberately remains different from the DEM: neo emits at + /// most one linear event followed by a categorical axis choice, while the DEM emits independent + /// per-axis mechanisms. The difference is second order in the rates; this setter aligns the + /// units and axis alphabet that neo can represent, not that sampling structure. + /// + /// # Panics + /// + /// Panics if `rate` or a model value is not finite and non-negative, if the model is not + /// normalized, or if it contains a key other than X, Y, or Z. L is rejected with guidance to + /// use [`LeakageChannel`]. #[must_use] - pub fn with_p_idle_linear_weights(mut self, weights: PauliWeights) -> Self { - self.p_idle_linear_weights = weights; + pub fn with_p_idle_linear(mut self, rate: f64, model: &BTreeMap) -> Self { + self.p_idle_linear_rate = Self::validate_finite_non_negative(rate, "linear idling rate"); + self.p_idle_linear_weights = Self::validate_linear_model(model); self } @@ -407,6 +435,42 @@ impl GeneralNoiseModelBuilder { #[must_use] pub fn with_p_idle_quadratic(mut self, rate: f64) -> Self { self.p_idle_quadratic_rate = rate; + self.p_idle_quadratic_configured = true; + self + } + + /// Set the DEM-style stochastic sine-squared idle-noise family. + /// + /// `rate` is in radians per time unit. No `2*pi` conversion and no + /// `coherent_to_incoherent_factor` is applied, unlike [`Self::with_p_idle_quadratic`]. For each + /// axis P with multiplier `n_P` and an idle of duration `d`, neo independently samples + /// `P(P) = sin^2(rate * n_P * d)`. + /// + /// The model accepts X, Y, Z, and L and is intentionally unnormalized: sine laws do not add + /// linearly, so every axis carries its own independent rate. By comparison, + /// [`Self::with_p_idle_linear`] requires a normalized distribution because its one total + /// linear event rate is split across axes. + /// + /// Unlike the linear family's [`PauliWeights`] storage, this family has separate map storage + /// that can represent the DEM's L axis. Sine-family leakage is tracked by neo and enables its + /// [`LeakageChannel`]. + /// + /// The legacy quadratic spelling has a different unit contract and folds + /// `coherent_to_incoherent_factor` and the exact `sin^2(theta/2)` Pauli twirl into its + /// stochastic path; this setter is the direct radians-per-time-unit spelling. + /// + /// All neo idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. + /// + /// # Panics + /// + /// Panics if `rate` or a multiplier is not finite and non-negative, or if `model` contains a + /// key other than X, Y, Z, or L. + #[must_use] + pub fn with_p_idle_sin_squared(mut self, rate: f64, model: &BTreeMap) -> Self { + let rate = Self::validate_finite_non_negative(rate, "sine-squared idling rate"); + Self::validate_sine_model(model); + self.p_idle_sin_squared = Some((rate, model.clone())); self } @@ -479,23 +543,29 @@ impl GeneralNoiseModelBuilder { /// Set T1/T2 relaxation times in physical units (seconds). /// - /// Requires `with_time_scale()` to be called first. + /// Requires `with_time_scale()` to be called first. T2 is total transverse coherence time, + /// not pure-dephasing Tphi. This uses the first-order Pauli-twirl mapping documented by + /// [`IdleChannel::from_t1_t2`], including its `T2 <= 2 * T1` bound and small-duration validity + /// domain. It configures only the linear idle family; the quadratic rate is zero. + /// + /// This mapping changes the numerical rates and Pauli weights produced by this convenience + /// from earlier PECOS versions. It produces the same configuration callers can write with the + /// linear-family rate and weight setters. /// /// # Panics - /// Panics if `with_time_scale()` has not been called. + /// Panics if `with_time_scale()` has not been called, if either time is non-finite or not + /// greater than zero, or if `t2_seconds > 2 * t1_seconds`. #[must_use] pub fn with_idle_t1_t2(mut self, t1_seconds: f64, t2_seconds: f64) -> Self { let scale = self .time_scale .expect("with_time_scale() must be called before with_idle_t1_t2()"); - // Convert physical times to time units - let t1_units = scale.from_seconds(t1_seconds).as_f64(); - let t2_units = scale.from_seconds(t2_seconds).as_f64(); - - // Set rates: linear_rate = 1/T1, quadratic_rate = 1/T2^2 - self.p_idle_linear_rate = 1.0 / t1_units.max(1.0); - self.p_idle_quadratic_rate = 1.0 / (t2_units * t2_units).max(1.0); + let channel = IdleChannel::from_t1_t2_seconds(t1_seconds, t2_seconds, scale); + self.p_idle_linear_rate = channel.linear_rate; + self.p_idle_linear_weights = channel.linear_weights; + self.p_idle_quadratic_rate = channel.quadratic_rate; + self.p_idle_quadratic_configured = false; self } @@ -503,6 +573,86 @@ impl GeneralNoiseModelBuilder { // Build // ======================================================================== + /// Validate that a value is finite and non-negative. + fn validate_finite_non_negative(value: f64, name: &str) -> f64 { + assert!( + value.is_finite() && value >= 0.0, + "{name} must be finite and non-negative, got {value}" + ); + value + } + + /// Validate and convert a normalized X/Y/Z linear-family model. + fn validate_linear_model(model: &BTreeMap) -> PauliWeights { + const NORMALIZATION_TOLERANCE: f64 = 1e-5; + + let mut x = 0.0; + let mut y = 0.0; + let mut z = 0.0; + for (axis, weight) in model { + match axis.as_str() { + "X" => x = *weight, + "Y" => y = *weight, + "Z" => z = *weight, + "L" => panic!( + "neo's idle linear family cannot represent leakage; use neo's \ + LeakageChannel for linear leakage" + ), + _ => panic!("p_idle_linear model has invalid key '{axis}'; expected X, Y, Z, or L"), + } + Self::validate_finite_non_negative( + *weight, + &format!("p_idle_linear weight for '{axis}'"), + ); + } + + let total = x + y + z; + assert!( + total.is_finite() && (total - 1.0).abs() <= NORMALIZATION_TOLERANCE, + "p_idle_linear model weights must sum to 1.0 within tolerance \ + {NORMALIZATION_TOLERANCE}, got {total}" + ); + PauliWeights::custom(x / total, y / total, z / total) + } + + /// Validate an unnormalized sine-family multiplier model. + fn validate_sine_model(model: &BTreeMap) { + for (axis, multiplier) in model { + assert!( + matches!(axis.as_str(), "X" | "Y" | "Z" | "L"), + "p_idle_sin_squared model has invalid key '{axis}'; expected X, Y, Z, or L" + ); + Self::validate_finite_non_negative( + *multiplier, + &format!("p_idle_sin_squared multiplier for '{axis}'"), + ); + } + } + + /// Validate combinations whose interpretation would otherwise depend on silent precedence. + /// + /// # Errors + /// + /// Returns a description of the conflicting spellings and their incompatible semantics. + pub fn validate_configuration(&self) -> Result<(), &'static str> { + if self.p_idle_sin_squared.is_some() && self.p_idle_quadratic_configured { + return Err( + "with_p_idle_sin_squared cannot be combined with with_p_idle_quadratic: \ + the spellings use different units; with_p_idle_sin_squared uses radians per \ + time unit with no conversion, while with_p_idle_quadratic uses the legacy \ + quadratic-rate units and applies coherent_to_incoherent_factor", + ); + } + if self.p_idle_sin_squared.is_some() && self.p_idle_coherent { + return Err( + "with_p_idle_sin_squared cannot be combined with with_p_idle_coherent(true): \ + with_p_idle_sin_squared is stochastic by definition, while \ + with_p_idle_coherent(true) selects the legacy coherent path", + ); + } + Ok(()) + } + /// Check if any configured parameters can cause leakage. fn has_leakage_potential(&self) -> bool { self.p_prep_leak_ratio > 0.0 @@ -512,13 +662,31 @@ impl GeneralNoiseModelBuilder { .p_meas_crosstalk_transitions .as_ref() .is_some_and(|t| t.from_0_leak > 0.0 || t.from_1_leak > 0.0) + || self + .p_idle_sin_squared + .as_ref() + .is_some_and(|(rate, model)| { + *rate > 0.0 && model.get("L").is_some_and(|multiplier| *multiplier > 0.0) + }) } /// Build the configured noise model. /// /// Returns a [`ComposableNoiseModel`] with all the configured channels. + /// + /// # Panics + /// + /// Panics if sine-squared idle noise is combined with the legacy quadratic or coherent idle + /// path. #[must_use] pub fn build(self) -> ComposableNoiseModel { + self.validate_configuration() + .unwrap_or_else(|message| panic!("{message}")); + + let (p_idle_sin_squared_rate, p_idle_sin_squared_model) = self + .p_idle_sin_squared + .clone() + .unwrap_or_else(|| (0.0, BTreeMap::new())); let mut model = ComposableNoiseModel::new().add_plugin(&CorePlugin); // Set time scale if configured @@ -564,7 +732,6 @@ impl GeneralNoiseModelBuilder { self.p2_emission_ratio, self.p2_emission_weights, self.p2_seepage_prob, - self.p2_idle, ); model = model.add_channel(channel); } @@ -594,15 +761,27 @@ impl GeneralNoiseModelBuilder { } // Idle channel - if self.p_idle_linear_rate > 0.0 || self.p_idle_quadratic_rate > 0.0 { + if self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate > 0.0 + || p_idle_sin_squared_rate > 0.0 + || self.idle_after_2q > 0.0 + { let channel = IdleChannel { linear_rate: self.p_idle_linear_rate, linear_weights: self.p_idle_linear_weights, + sin_squared_rate: p_idle_sin_squared_rate, + sin_squared_model: p_idle_sin_squared_model, quadratic_rate: self.p_idle_quadratic_rate, coherent_dephasing: self.p_idle_coherent, coherent_to_incoherent_factor: self.p_idle_coherent_to_incoherent_factor, + idle_after_2q: self.idle_after_2q, }; - model = model.add_channel(channel); + model = model.add_channel_configured_by( + channel, + "GeneralNoiseModelBuilder::with_p_idle_coherent(true)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to the \ + stochastic idle family with with_p_idle_coherent(false)", + ); } // Custom channels (composite or traditional) @@ -641,6 +820,30 @@ pub fn general_noise() -> GeneralNoiseModelBuilder { #[allow(clippy::cast_precision_loss)] // statistical tests use count as f64 mod tests { use super::*; + use crate::command::GateCommand; + use crate::noise::{NoiseEvent, NoiseResponse}; + use pecos_core::QubitId; + use pecos_random::PecosRng; + + fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = panic.downcast_ref::() { + message.clone() + } else if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else { + "non-string panic".to_string() + } + } + + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } #[test] fn test_empty_builder() { @@ -704,15 +907,232 @@ mod tests { assert_eq!(model.channel_count(), 2); } + #[test] + fn after_2q_idle_works_without_p2_or_a_two_qubit_channel() { + let linear_model = BTreeMap::from([("X".to_string(), 1.0)]); + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_linear(1.0, &linear_model) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(67))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); + } + + #[test] + fn quadratic_only_after_2q_configuration_builds_and_emits() { + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_idle_after_2q(1.0) + .build(); + assert_eq!(model.channel_names(), ["IdleChannel"]); + + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(71))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); + } + + #[test] + fn sine_family_reaches_after_2q_idle_sites() { + let sine_model = BTreeMap::from([("X".to_string(), 1.0)]); + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .with_idle_after_2q(1.0) + .build(); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(73))); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); + } + + #[test] + fn sine_multipliers_are_not_normalized_by_the_builder() { + let sine_model = BTreeMap::from([("X".to_string(), 1.0), ("Z".to_string(), 1.0)]); + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .build(); + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: 1.into(), + }; + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(79))); + + assert_eq!(gates.len(), 32); + assert!(gates[..16].iter().all(|gate| gate.gate_type == GateType::X)); + assert!(gates[16..].iter().all(|gate| gate.gate_type == GateType::Z)); + } + + #[test] + fn sine_family_accepts_leakage_and_enables_leakage_channel() { + let sine_model = BTreeMap::from([("L".to_string(), 1.0)]); + let mut model = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(std::f64::consts::FRAC_PI_2, &sine_model) + .build(); + assert_eq!(model.channel_names(), ["LeakageChannel", "IdleChannel"]); + + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: 1.into(), + }; + let response = model.emit(&event, &mut PecosRng::seed_from_u64(83)); + + assert!(matches!(response, NoiseResponse::MarkLeaked(_))); + assert!(model.context().is_leaked(QubitId(0))); + } + + #[test] + fn linear_family_rejects_unnormalized_model() { + let linear_model = BTreeMap::from([("X".to_string(), 1.0), ("Z".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &linear_model); + }) + .unwrap_err(); + + assert!(panic_message(panic.as_ref()).contains("must sum to 1.0")); + } + + #[test] + fn linear_family_rejects_leakage_with_neo_guidance() { + let linear_model = BTreeMap::from([("X".to_string(), 0.5), ("L".to_string(), 0.5)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &linear_model); + }) + .unwrap_err(); + let message = panic_message(panic.as_ref()); + + assert!(message.contains("neo's idle linear family cannot represent leakage")); + assert!(message.contains("neo's LeakageChannel")); + } + + #[test] + fn linear_family_rejects_invalid_rates_axes_and_weights() { + let normalized = BTreeMap::from([("X".to_string(), 1.0)]); + for rate in [f64::INFINITY, f64::NAN, -0.1] { + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(rate, &normalized); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + + let invalid_axis = BTreeMap::from([("A".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &invalid_axis); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("invalid key 'A'")); + + for invalid_weight in [f64::INFINITY, f64::NAN, -1.0] { + let invalid_model = BTreeMap::from([ + ("X".to_string(), invalid_weight), + ("Z".to_string(), 1.0 - invalid_weight), + ]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_linear(0.1, &invalid_model); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + } + + #[test] + fn sine_family_rejects_invalid_rates_axes_and_multipliers() { + let valid_model = BTreeMap::from([("X".to_string(), 1.0)]); + for rate in [f64::INFINITY, f64::NAN, -0.1] { + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_sin_squared(rate, &valid_model); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + + let invalid_axis = BTreeMap::from([("A".to_string(), 1.0)]); + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new().with_p_idle_sin_squared(0.1, &invalid_axis); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("invalid key 'A'")); + + for multiplier in [f64::INFINITY, f64::NAN, -1.0] { + let invalid_model = BTreeMap::from([("X".to_string(), multiplier)]); + let panic = std::panic::catch_unwind(|| { + let _ = + GeneralNoiseModelBuilder::new().with_p_idle_sin_squared(0.1, &invalid_model); + }) + .unwrap_err(); + assert!(panic_message(panic.as_ref()).contains("finite and non-negative")); + } + } + + #[test] + fn sine_family_conflicts_with_legacy_quadratic_spelling() { + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let builder = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(0.1) + .with_p_idle_sin_squared(0.1, &sine_model); + let error = builder.validate_configuration().unwrap_err(); + + assert!(error.contains("with_p_idle_sin_squared")); + assert!(error.contains("with_p_idle_quadratic")); + assert!(error.contains("different units")); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build())).is_err() + ); + } + + #[test] + fn sine_family_conflicts_with_coherent_legacy_path() { + let sine_model = BTreeMap::from([("Z".to_string(), 1.0)]); + let builder = GeneralNoiseModelBuilder::new() + .with_p_idle_sin_squared(0.1, &sine_model) + .with_p_idle_coherent(true); + let error = builder.validate_configuration().unwrap_err(); + + assert!(error.contains("with_p_idle_sin_squared")); + assert!(error.contains("with_p_idle_coherent(true)")); + assert!(error.contains("stochastic by definition")); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| builder.build())).is_err() + ); + } + #[test] fn test_full_configuration() { + let linear_model = BTreeMap::from([("Z".to_string(), 1.0)]); let model = GeneralNoiseModelBuilder::new() .with_p_prep(0.001) .with_p_prep_leak_ratio(0.1) // Enable leakage potential .with_p1(0.01) .with_p2(0.02) .with_p_meas(0.03, 0.04) - .with_p_idle_linear(0.0001) + .with_p_idle_linear(0.0001, &linear_model) .with_leakage_scale(1.0) .build(); @@ -771,15 +1191,48 @@ mod tests { #[test] fn test_idle_t1_t2_configuration() { // T1=50us, T2=30us with nanosecond time units - let model = GeneralNoiseModelBuilder::new() + let builder = GeneralNoiseModelBuilder::new() .with_time_scale(TimeScale::NANOSECONDS) - .with_idle_t1_t2(50e-6, 30e-6) - .build(); + .with_idle_t1_t2(50e-6, 30e-6); + let expected = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + + assert!((builder.p_idle_linear_rate - expected.linear_rate).abs() < f64::EPSILON); + assert!((builder.p_idle_linear_weights.x - expected.linear_weights.x).abs() < f64::EPSILON); + assert!((builder.p_idle_linear_weights.y - expected.linear_weights.y).abs() < f64::EPSILON); + assert!((builder.p_idle_linear_weights.z - expected.linear_weights.z).abs() < f64::EPSILON); + assert!((builder.p_idle_quadratic_rate - expected.quadratic_rate).abs() < f64::EPSILON); + assert!(!builder.p_idle_quadratic_configured); // Should have created an idle channel + let model = builder.build(); assert_eq!(model.channel_count(), 1); } + #[test] + fn idle_t1_t2_rejects_non_finite_seconds_before_time_scale_conversion() { + for (t1, t2, parameter) in [ + (f64::INFINITY, 1.0, "t1"), + (f64::NEG_INFINITY, 1.0, "t1"), + (f64::NAN, 1.0, "t1"), + (1.0, f64::INFINITY, "t2"), + (1.0, f64::NEG_INFINITY, "t2"), + (1.0, f64::NAN, "t2"), + ] { + let panic = std::panic::catch_unwind(|| { + let _ = GeneralNoiseModelBuilder::new() + .with_time_scale(TimeScale::NANOSECONDS) + .with_idle_t1_t2(t1, t2); + }) + .expect_err("non-finite physical time must panic"); + let message = panic_message(panic.as_ref()); + assert!(message.contains(parameter), "unexpected panic: {message}"); + assert!( + message.contains("finite and greater than zero"), + "unexpected panic: {message}" + ); + } + } + // ======================================================================== // Mixed Channel Tests // ======================================================================== diff --git a/exp/pecos-neo/src/noise/idle.rs b/exp/pecos-neo/src/noise/idle.rs index 761daf01a..0a5088271 100644 --- a/exp/pecos-neo/src/noise/idle.rs +++ b/exp/pecos-neo/src/noise/idle.rs @@ -19,7 +19,7 @@ //! ## When to use this vs `CompositeChannel` //! //! **Use `IdleChannel` when:** -//! - You want standard T1/T2 decay with linear/quadratic scaling +//! - You want the first-order T1/T2 Pauli-twirl convenience or other batched idle noise //! - Performance is critical (batched processing) //! //! **Use `CompositeChannel` when:** @@ -37,25 +37,31 @@ //! ## Noise Components //! //! - **Linear noise**: Stochastic errors with probability proportional to time. -//! Models T1-like relaxation. +//! Can model a first-order Pauli twirl of relaxation and dephasing. //! //! - **Quadratic noise**: Can be coherent (RZ rotations) or incoherent (stochastic Z). -//! Models T2-like dephasing. +//! Models phase rotation with an angle proportional to time. +//! +//! - **Sine-squared noise**: Independent stochastic X, Y, Z, or leakage events with +//! per-axis probability `sin(rate * multiplier * duration)^2`. //! //! ## Coherent vs Incoherent Dephasing //! //! - **Coherent**: Deterministic RZ rotation with angle = rate * duration. //! Represents systematic phase errors. //! -//! - **Incoherent**: Stochastic Z error with probability = sin(rate * duration)^2. -//! Represents random dephasing. +//! - **Incoherent**: Stochastic Z error with probability = sin(rate * duration / 2)^2. +//! This is the exact Pauli twirl of the coherent RZ rotation. -use super::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse, PauliWeights}; +use super::{ + NoiseChannel, NoiseContext, NoiseEvent, NoiseGateRequirement, NoiseResponse, PauliWeights, +}; use crate::command::{GateCommand, GateType}; -use pecos_core::{Angle64, TimeUnits}; +use pecos_core::{Angle64, TimeScale, TimeUnits}; use pecos_random::PecosRng; use rand::RngExt; use smallvec::SmallVec; +use std::collections::BTreeMap; /// Noise channel for idle time (memory errors). /// @@ -74,10 +80,20 @@ pub struct IdleChannel { /// or any custom distribution. pub linear_weights: PauliWeights, + /// DEM-style stochastic sine-squared idle rate in radians per time unit. + pub sin_squared_rate: f64, + + /// Unnormalized per-axis relative multipliers for the sine-squared idle family. + pub sin_squared_model: BTreeMap, + /// Error rate per time unit for quadratic (dephasing) noise. /// /// For coherent: angle = `quadratic_rate` * duration. - /// For incoherent: probability = sin(`quadratic_rate` * duration)^2. + /// For incoherent: probability = sin(`quadratic_rate` * duration / 2)^2. + /// + /// The factor of one half makes the incoherent model the exact Pauli twirl + /// of the coherent RZ rotation. This deliberately changes numerical results + /// from earlier versions for incoherent quadratic idle noise. pub quadratic_rate: f64, /// Whether to model quadratic dephasing coherently (RZ) or incoherently (stochastic Z). @@ -93,6 +109,13 @@ pub struct IdleChannel { /// Default is 1.0 (no adjustment). Values > 1.0 increase the effective /// incoherent dephasing rate. pub coherent_to_incoherent_factor: f64, + + /// Duration of the idle-noise site applied after a two-qubit gate. + /// + /// A duration of zero disables after-two-qubit idle sites. When enabled, + /// the same linear, quadratic, and sine-squared mechanisms used for + /// explicit idle events are applied to every distinct gate operand. + pub idle_after_2q: f64, } impl Default for IdleChannel { @@ -100,9 +123,12 @@ impl Default for IdleChannel { Self { linear_rate: 0.0, linear_weights: PauliWeights::custom(0.0, 0.0, 1.0), // Z-only by default + sin_squared_rate: 0.0, + sin_squared_model: BTreeMap::new(), quadratic_rate: 0.0, coherent_dephasing: false, coherent_to_incoherent_factor: 1.0, + idle_after_2q: 0.0, } } } @@ -119,25 +145,92 @@ impl IdleChannel { } } - /// Create an idle noise channel with T1/T2 parameters in abstract time units. + /// Create an idle noise channel from T1/T2 parameters in abstract time units. + /// + /// `t2` is the total transverse coherence time reported by device datasheets, not the pure + /// dephasing time Tphi. This convenience applies the first-order Pauli twirl of combined + /// amplitude damping and dephasing: + /// + /// ```text + /// rX = rY = 1 / (4 * T1) + /// rZ = 1 / (2 * T2) - 1 / (4 * T1) + /// ``` + /// + /// The channel's linear rate is `rX + rY + rZ`, with normalized X/Y/Z weights derived from + /// those rates. Its quadratic rate is zero: total-T2 dephasing is linear in duration to first + /// order and does not use the quadratic family. Equivalently, callers can configure the same + /// channel with [`Self::linear`] and [`Self::with_linear_weights`]. + /// + /// This approximation retains terms through first order in the idle duration `t`; it is valid + /// for `t` much smaller than both T1 and T2, where the resulting linear error probability is + /// also much smaller than one. Physical total T2 must satisfy `T2 <= 2 * T1` so that `rZ` is + /// non-negative. + /// + /// This mapping changes the numerical rates and Pauli weights produced by this convenience + /// from earlier PECOS versions, which used a Z-only `1/T1` linear rate and a `1/T2^2` + /// quadratic rate. /// /// # Arguments - /// * `t1` - T1 relaxation time in time units - /// * `t2` - T2 dephasing time in time units + /// * `t1` - T1 relaxation time in abstract time units + /// * `t2` - Total T2 transverse coherence time in the same units + /// + /// # Panics + /// + /// Panics if either time is non-finite or not greater than zero, or if `t2 > 2 * t1`. #[must_use] pub fn from_t1_t2(t1: f64, t2: f64) -> Self { - // Approximate error rate from T1/T2 - // This is a simplified model - let linear_rate = 1.0 / t1.max(1.0); - let quadratic_rate = 1.0 / (t2 * t2).max(1.0); + Self::validate_t1_t2(t1, t2); + + let rate_x = 1.0 / (4.0 * t1); + let rate_y = rate_x; + let rate_z = 1.0 / (2.0 * t2) - rate_x; + let linear_rate = rate_x + rate_y + rate_z; Self { linear_rate, - quadratic_rate, + linear_weights: PauliWeights::custom( + rate_x / linear_rate, + rate_y / linear_rate, + rate_z / linear_rate, + ), + quadratic_rate: 0.0, ..Default::default() } } + /// Convert physical seconds with a time scale, preserving the constructor's validation. + pub(crate) fn from_t1_t2_seconds(t1_seconds: f64, t2_seconds: f64, scale: TimeScale) -> Self { + // Validate before TimeScale rounds into its unsigned integer representation, which would + // otherwise erase the sign and non-finite state of some invalid inputs. + Self::validate_t1_t2(t1_seconds, t2_seconds); + let t1 = scale.from_seconds(t1_seconds).as_f64(); + let t2 = scale.from_seconds(t2_seconds).as_f64(); + Self::from_t1_t2(t1, t2) + } + + fn validate_t1_t2(t1: f64, t2: f64) { + assert!( + t1.is_finite(), + "t1 must be finite and greater than zero, got {t1}" + ); + assert!( + t1 > 0.0, + "t1 must be finite and greater than zero, got {t1}" + ); + assert!( + t2.is_finite(), + "t2 must be finite and greater than zero, got {t2}" + ); + assert!( + t2 > 0.0, + "t2 must be finite and greater than zero, got {t2}" + ); + assert!( + t2 <= 2.0 * t1, + "total transverse coherence time must satisfy t2 <= 2 * t1, got t1={t1} and t2={t2}" + ); + } + /// Set whether to use coherent dephasing. #[must_use] pub fn with_coherent_dephasing(mut self, coherent: bool) -> Self { @@ -174,57 +267,77 @@ impl IdleChannel { self } + /// Set the duration of the idle-noise site after each two-qubit gate. + /// + /// The duration uses the channel's abstract time units. A duration of zero + /// disables these sites. + #[must_use] + pub fn with_idle_after_2q(mut self, duration: f64) -> Self { + self.idle_after_2q = duration; + self + } + /// Calculate linear (stochastic) error probability for a given duration. - fn linear_probability(&self, duration: TimeUnits) -> f64 { - let t = duration.as_f64(); - (self.linear_rate * t).min(1.0) + fn linear_probability(&self, duration: f64) -> f64 { + (self.linear_rate * duration).min(1.0) } /// Calculate quadratic dephasing probability (for incoherent mode). /// - /// Applies the coherent-to-incoherent factor to compensate for - /// not modeling coherent phase accumulation. - fn quadratic_probability(&self, duration: TimeUnits) -> f64 { - let t = duration.as_f64(); - let effective_rate = self.quadratic_rate * self.coherent_to_incoherent_factor; - let angle = effective_rate * t; - angle.sin().powi(2) + /// Applies the coherent-to-incoherent factor as a multiplier on the rate, + /// then uses the exact Pauli-twirl probability `sin(effective_angle / 2)^2`. + /// This deliberately changes numerical results from earlier versions, + /// which omitted the factor of one half. + fn quadratic_probability(&self, duration: f64) -> f64 { + let effective_angle = self.quadratic_rate * self.coherent_to_incoherent_factor * duration; + (effective_angle / 2.0).sin().powi(2) } /// Calculate quadratic dephasing angle (for coherent mode). - fn quadratic_angle(&self, duration: TimeUnits) -> f64 { - let t = duration.as_f64(); - self.quadratic_rate * t + fn quadratic_angle(&self, duration: f64) -> f64 { + self.quadratic_rate * duration } -} -impl NoiseChannel for IdleChannel { - fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { - if self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0 { - return false; - } - matches!(event, NoiseEvent::IdleTime { .. }) + /// Calculate one axis's DEM-style sine-squared error probability. + fn sin_squared_probability(rate: f64, multiplier: f64, duration: f64) -> f64 { + (rate * multiplier * duration).sin().powi(2) } - fn apply( + /// Apply every configured idle mechanism for one duration. + fn apply_for_duration( &self, - event: &NoiseEvent<'_>, + qubits: &[pecos_core::QubitId], + duration: f64, ctx: &mut NoiseContext, rng: &mut PecosRng, ) -> NoiseResponse { - let NoiseEvent::IdleTime { qubits, duration } = event else { + if duration <= 0.0 + || (self.linear_rate <= 0.0 + && self.quadratic_rate <= 0.0 + && self.sin_squared_rate <= 0.0) + { return NoiseResponse::None; - }; + } + + // A batched two-qubit command can contain multiple pairs. Preserve the + // operand order while applying one idle site to each distinct qubit. + let mut unique_qubits = SmallVec::<[pecos_core::QubitId; 4]>::new(); + for &qubit in qubits { + if !unique_qubits.contains(&qubit) { + unique_qubits.push(qubit); + } + } let mut gates = SmallVec::new(); + let mut leaked = SmallVec::new(); // Fast path: check if any leakage exists at all let has_any_leakage = ctx.leaked_count() > 0; // Apply linear (stochastic) noise if self.linear_rate > 0.0 { - let p_linear = self.linear_probability(*duration); - for &qubit in *qubits { + let p_linear = self.linear_probability(duration); + for &qubit in &unique_qubits { // Skip leaked qubits (fast path skips check if no leakage exists) if (!has_any_leakage || !ctx.is_leaked(qubit)) && rng.random::() < p_linear { // Sample Pauli error from linear weights @@ -238,9 +351,9 @@ impl NoiseChannel for IdleChannel { if self.quadratic_rate > 0.0 { if self.coherent_dephasing { // Coherent dephasing: deterministic RZ rotation - let angle = self.quadratic_angle(*duration); + let angle = self.quadratic_angle(duration); if angle.abs() > f64::EPSILON { - for &qubit in *qubits { + for &qubit in &unique_qubits { // Skip leaked qubits (fast path skips check if no leakage exists) if !has_any_leakage || !ctx.is_leaked(qubit) { gates.push(GateCommand::rz(qubit, Angle64::from_radians(angle))); @@ -248,10 +361,10 @@ impl NoiseChannel for IdleChannel { } } } else { - // Incoherent dephasing: stochastic Z with sin^2 probability - let p_quad = self.quadratic_probability(*duration); + // Incoherent dephasing: stochastic Z with exact Pauli-twirl probability + let p_quad = self.quadratic_probability(duration); if p_quad > 0.0 { - for &qubit in *qubits { + for &qubit in &unique_qubits { // Skip leaked qubits (fast path skips check if no leakage exists) if (!has_any_leakage || !ctx.is_leaked(qubit)) && rng.random::() < p_quad @@ -263,17 +376,100 @@ impl NoiseChannel for IdleChannel { } } - if gates.is_empty() { + // Apply the DEM-style stochastic sine-squared family independently per axis. + if self.sin_squared_rate > 0.0 { + for axis in ["X", "Y", "Z", "L"] { + let Some(multiplier) = self.sin_squared_model.get(axis).copied() else { + continue; + }; + let probability = + Self::sin_squared_probability(self.sin_squared_rate, multiplier, duration); + if probability <= f64::EPSILON { + continue; + } + + for &qubit in &unique_qubits { + if (!has_any_leakage || !ctx.is_leaked(qubit)) + && rng.random::() < probability + { + match axis { + "X" => gates + .push(GateCommand::new(GateType::X, smallvec::smallvec![qubit])), + "Y" => gates + .push(GateCommand::new(GateType::Y, smallvec::smallvec![qubit])), + "Z" => gates + .push(GateCommand::new(GateType::Z, smallvec::smallvec![qubit])), + "L" => leaked.push(qubit), + _ => unreachable!("sine-family model was validated by the builder"), + } + } + } + } + } + + let response = if gates.is_empty() { NoiseResponse::None } else { NoiseResponse::inject_gates(gates) + }; + if leaked.is_empty() { + response + } else { + response.combine(NoiseResponse::MarkLeaked(leaked)) } } +} + +impl NoiseChannel for IdleChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + if self.linear_rate <= 0.0 && self.quadratic_rate <= 0.0 && self.sin_squared_rate <= 0.0 { + return false; + } + match event { + NoiseEvent::IdleTime { duration, .. } => *duration != TimeUnits::ZERO, + NoiseEvent::AfterGate { gate_type, .. } => { + self.idle_after_2q > 0.0 && gate_type.is_two_qubit() + } + _ => false, + } + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let (qubits, duration) = match event { + NoiseEvent::IdleTime { qubits, duration } => (*qubits, duration.as_f64()), + NoiseEvent::AfterGate { + gate_type, qubits, .. + } if self.idle_after_2q > 0.0 && gate_type.is_two_qubit() => { + (*qubits, self.idle_after_2q) + } + _ => return NoiseResponse::None, + }; + + self.apply_for_duration(qubits, duration, ctx, rng) + } fn name(&self) -> &'static str { "IdleChannel" } + fn gate_requirements(&self) -> SmallVec<[NoiseGateRequirement; 2]> { + if self.coherent_dephasing && self.quadratic_rate > 0.0 { + smallvec::smallvec![NoiseGateRequirement::new( + GateType::RZ, + "IdleChannel::with_coherent_dephasing(true)", + "supply a rotation executor with CircuitRunner::rotations(), or switch to the \ + stochastic idle family with IdleChannel::with_coherent_dephasing(false)", + )] + } else { + SmallVec::new() + } + } + fn clone_box(&self) -> Box { Box::new(self.clone()) } @@ -284,6 +480,68 @@ mod tests { use super::*; use pecos_core::QubitId; + fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = panic.downcast_ref::() { + message.clone() + } else if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else { + "non-string panic".to_string() + } + } + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected {expected}, got {actual}" + ); + } + + fn assert_same_configuration(actual: &IdleChannel, expected: &IdleChannel) { + assert_close(actual.linear_rate, expected.linear_rate); + assert_close(actual.linear_weights.x, expected.linear_weights.x); + assert_close(actual.linear_weights.y, expected.linear_weights.y); + assert_close(actual.linear_weights.z, expected.linear_weights.z); + assert_close(actual.sin_squared_rate, expected.sin_squared_rate); + assert_eq!(actual.sin_squared_model, expected.sin_squared_model); + assert_close(actual.quadratic_rate, expected.quadratic_rate); + assert_eq!(actual.coherent_dephasing, expected.coherent_dephasing); + assert_close( + actual.coherent_to_incoherent_factor, + expected.coherent_to_incoherent_factor, + ); + assert_close(actual.idle_after_2q, expected.idle_after_2q); + } + + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + + fn collect_leaked(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::MarkLeaked(qubits) => qubits.into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_leaked).collect() + } + _ => Vec::new(), + } + } + + fn after_cx(qubits: &[QubitId]) -> NoiseEvent<'_> { + NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits, + angles: &[], + gate_id: None, + } + } + #[test] fn test_idle_error() { let channel = IdleChannel::linear(1.0); // 100% error per ns @@ -329,10 +587,104 @@ mod tests { let channel = IdleChannel::linear(0.001); // At 10ns: p = 0.001 * 10 = 0.01 - let p = channel.linear_probability(TimeUnits::new(10)); + let p = channel.linear_probability(TimeUnits::new(10).as_f64()); assert!((p - 0.01).abs() < 1e-10); } + #[test] + fn t1_t2_short_times_are_not_clamped() { + let half_unit_t1 = IdleChannel::from_t1_t2(0.5, 1.0); + let one_unit_t1 = IdleChannel::from_t1_t2(1.0, 2.0); + + assert_close(half_unit_t1.linear_rate, 1.0); + assert_close(one_unit_t1.linear_rate, 0.5); + assert_close(half_unit_t1.linear_rate, 2.0 * one_unit_t1.linear_rate); + assert_close(half_unit_t1.linear_weights.x, 0.5); + assert_close(half_unit_t1.linear_weights.y, 0.5); + assert_close(half_unit_t1.linear_weights.z, 0.0); + } + + #[test] + fn t1_t2_rejects_non_positive_and_non_finite_times_by_parameter() { + for t1 in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let panic = std::panic::catch_unwind(|| IdleChannel::from_t1_t2(t1, 1.0)) + .expect_err("invalid t1 must panic"); + let message = panic_message(panic.as_ref()); + assert!(message.contains("t1"), "unexpected panic: {message}"); + assert!( + message.contains("finite and greater than zero"), + "unexpected panic: {message}" + ); + } + + for t2 in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let panic = std::panic::catch_unwind(|| IdleChannel::from_t1_t2(1.0, t2)) + .expect_err("invalid t2 must panic"); + let message = panic_message(panic.as_ref()); + assert!(message.contains("t2"), "unexpected panic: {message}"); + assert!( + message.contains("finite and greater than zero"), + "unexpected panic: {message}" + ); + } + } + + #[test] + fn total_t2_physical_bound_is_enforced_and_inclusive() { + let boundary = IdleChannel::from_t1_t2(1.0, 2.0); + assert_close(boundary.linear_weights.z, 0.0); + + let panic = std::panic::catch_unwind(|| IdleChannel::from_t1_t2(1.0, 2.1)) + .expect_err("T2 above the physical bound must panic"); + let message = panic_message(panic.as_ref()); + assert!( + message.contains("t2 <= 2 * t1"), + "unexpected panic: {message}" + ); + assert!(message.contains("t1=1"), "unexpected panic: {message}"); + assert!(message.contains("t2=2.1"), "unexpected panic: {message}"); + } + + #[test] + fn t1_t2_sanity_values_match_first_order_pauli_twirl() { + let channel = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + + assert!((channel.linear_rate - 2.166_666_666_666_666_7e-5).abs() < f64::EPSILON); + assert!((channel.linear_weights.x - 3.0 / 13.0).abs() < f64::EPSILON); + assert!((channel.linear_weights.y - 3.0 / 13.0).abs() < f64::EPSILON); + assert!((channel.linear_weights.z - 7.0 / 13.0).abs() < f64::EPSILON); + assert_close(channel.quadratic_rate, 0.0); + } + + #[test] + fn t1_t2_uses_linear_t2_scaling_and_zero_quadratic_angle() { + let t2_30 = IdleChannel::from_t1_t2(50.0, 30.0); + let t2_60 = IdleChannel::from_t1_t2(50.0, 60.0); + + // theta_quadratic(t; T2) = 0. The first-order transverse Pauli error instead follows + // pY(t) + pZ(t) = t / (2 * T2). + assert_close(t2_30.quadratic_angle(3.0), 0.0); + assert_close(t2_30.quadratic_angle(6.0), 0.0); + assert_close(t2_60.quadratic_angle(3.0), 0.0); + + let transverse_probability = |channel: &IdleChannel, duration| { + channel.linear_probability(duration) + * (channel.linear_weights.y + channel.linear_weights.z) + }; + assert!((transverse_probability(&t2_30, 3.0) - 0.05).abs() < f64::EPSILON); + assert!((transverse_probability(&t2_30, 6.0) - 0.1).abs() < f64::EPSILON); + assert!((transverse_probability(&t2_60, 3.0) - 0.025).abs() < f64::EPSILON); + } + + #[test] + fn t1_t2_convenience_equals_hand_written_linear_family() { + let convenience = IdleChannel::from_t1_t2(50_000.0, 30_000.0); + let hand_written = IdleChannel::linear(2.166_666_666_666_666_7e-5) + .with_linear_weights(PauliWeights::custom(3.0 / 13.0, 3.0 / 13.0, 7.0 / 13.0)); + + assert_same_configuration(&convenience, &hand_written); + } + #[test] fn test_linear_with_custom_weights() { // X-biased linear noise @@ -404,9 +756,9 @@ mod tests { #[test] fn test_incoherent_dephasing() { - // pi/2 rad/ns -> sin^2(pi/2) = 1 + // pi rad/ns -> sin^2(pi/2) = 1 let channel = IdleChannel { - quadratic_rate: std::f64::consts::FRAC_PI_2, + quadratic_rate: std::f64::consts::PI, ..Default::default() }; @@ -433,10 +785,10 @@ mod tests { #[test] fn test_coherent_to_incoherent_factor() { - // With factor = 2.0 and rate = pi/4, effective rate = pi/2 + // With factor = 2.0 and rate = pi/2, effective angle = pi // sin^2(pi/2) = 1.0 -> always error let channel = IdleChannel { - quadratic_rate: std::f64::consts::FRAC_PI_4, + quadratic_rate: std::f64::consts::FRAC_PI_2, coherent_to_incoherent_factor: 2.0, ..Default::default() }; @@ -461,4 +813,411 @@ mod tests { panic!("Expected InjectGates response"); } } + + #[test] + fn sine_probability_matches_engines_numeric_value() { + let probability = IdleChannel::sin_squared_probability(0.03, 1.0, 10.0); + assert!((probability - 0.087_332_192_545_160_84).abs() < f64::EPSILON); + } + + #[test] + fn sine_application_uses_rate_multiplier_and_duration() { + let channel = IdleChannel { + sin_squared_rate: 0.03, + sin_squared_model: BTreeMap::from([("X".to_string(), 2.0)]), + ..Default::default() + }; + let qubits = std::array::from_fn::<_, 32, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(5), + }; + let expected_probability = 0.087_332_192_545_160_84; + let mut expected_rng = PecosRng::seed_from_u64(3); + let expected_qubits = qubits + .iter() + .copied() + .filter(|_| expected_rng.random::() < expected_probability) + .collect::>(); + let expected_next = expected_rng.random::(); + + let mut actual_rng = PecosRng::seed_from_u64(3); + let actual_gates = + collect_gates(channel.apply(&event, &mut NoiseContext::new(), &mut actual_rng)); + assert_eq!( + actual_gates + .iter() + .map(|gate| gate.qubits[0]) + .collect::>(), + expected_qubits + ); + assert!( + actual_gates + .iter() + .all(|gate| gate.gate_type == GateType::X) + ); + assert_eq!(actual_rng.random::(), expected_next); + } + + #[test] + fn x_weighted_sine_model_emits_x_not_z() { + let channel = IdleChannel { + sin_squared_rate: std::f64::consts::FRAC_PI_2, + sin_squared_model: BTreeMap::from([("X".to_string(), 1.0)]), + ..Default::default() + }; + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + let gates = collect_gates(channel.apply( + &event, + &mut NoiseContext::new(), + &mut PecosRng::seed_from_u64(5), + )); + + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::X); + } + + #[test] + fn sine_model_axes_are_independent_in_xyzl_order() { + let channel = IdleChannel { + sin_squared_rate: std::f64::consts::FRAC_PI_2, + sin_squared_model: BTreeMap::from([ + ("X".to_string(), 1.0), + ("Y".to_string(), 1.0), + ("Z".to_string(), 1.0), + ("L".to_string(), 1.0), + ]), + ..Default::default() + }; + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + let response = channel.apply( + &event, + &mut NoiseContext::new(), + &mut PecosRng::seed_from_u64(7), + ); + let gates = collect_gates(response.clone()); + + assert_eq!( + gates.iter().map(|gate| gate.gate_type).collect::>(), + [GateType::X, GateType::Y, GateType::Z] + ); + assert_eq!(collect_leaked(response), [QubitId(0)]); + } + + #[test] + fn after_2q_duration_scales_linear_noise() { + let qubits = std::array::from_fn::<_, 64, _>(QubitId); + let short = IdleChannel::linear(0.25).with_idle_after_2q(1.0); + let long = IdleChannel::linear(0.25).with_idle_after_2q(4.0); + + let mut short_rng = PecosRng::seed_from_u64(17); + let short_gates = collect_gates(short.apply( + &after_cx(&qubits), + &mut NoiseContext::new(), + &mut short_rng, + )); + + let mut long_rng = PecosRng::seed_from_u64(17); + let long_gates = + collect_gates(long.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut long_rng)); + + assert!(short_gates.len() < qubits.len()); + assert_eq!(long_gates.len(), qubits.len()); + } + + #[test] + fn quadratic_only_noise_reaches_after_2q_sites() { + let channel = IdleChannel { + quadratic_rate: std::f64::consts::PI, + idle_after_2q: 1.0, + ..Default::default() + }; + let qubits = [QubitId(0), QubitId(1)]; + let mut rng = PecosRng::seed_from_u64(8); + + let gates = + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::Z)); + } + + #[test] + fn linear_weights_are_honored_at_after_2q_sites() { + let channel = IdleChannel::linear(1.0) + .with_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0); + let qubits = [QubitId(0), QubitId(1)]; + let mut rng = PecosRng::seed_from_u64(23); + + let gates = + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)); + + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::X)); + } + + #[test] + fn batched_after_2q_idles_every_distinct_operand() { + let channel = IdleChannel::linear(1.0) + .with_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0); + let qubits = [QubitId(0), QubitId(1), QubitId(2), QubitId(3), QubitId(1)]; + let mut rng = PecosRng::seed_from_u64(29); + + let gates = + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)); + let affected = gates.iter().map(|gate| gate.qubits[0]).collect::>(); + + assert_eq!( + affected, + vec![QubitId(0), QubitId(1), QubitId(2), QubitId(3)] + ); + } + + #[test] + fn after_2q_channel_ignores_single_qubit_gates() { + let channel = IdleChannel::linear(1.0).with_idle_after_2q(1.0); + let qubits = [QubitId(0)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::H, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + assert!(!channel.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(31); + assert!( + channel + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(31); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + } + + #[test] + fn zero_duration_and_zero_rates_produce_nothing_without_rng_draws() { + let qubits = [QubitId(0), QubitId(1)]; + + let zero_duration = IdleChannel::linear(1.0); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::ZERO, + }; + assert!(!zero_duration.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(31); + assert!( + zero_duration + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(31); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let zero_after_2q_duration = IdleChannel::linear(1.0).with_idle_after_2q(0.0); + let event = after_cx(&qubits); + assert!(!zero_after_2q_duration.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(37); + assert!( + zero_after_2q_duration + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(37); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let zero_rates = IdleChannel::default().with_idle_after_2q(10.0); + let event = after_cx(&qubits); + assert!(!zero_rates.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(41); + assert!( + zero_rates + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(41); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let zero_sine_rate = IdleChannel { + sin_squared_model: BTreeMap::from([("X".to_string(), 1.0)]), + ..Default::default() + }; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + assert!(!zero_sine_rate.responds_to(&event)); + let mut actual_rng = PecosRng::seed_from_u64(43); + assert!( + zero_sine_rate + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(43); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + + let nonzero_sine_rate = IdleChannel { + sin_squared_rate: std::f64::consts::FRAC_PI_2, + sin_squared_model: BTreeMap::from([("X".to_string(), 1.0)]), + ..Default::default() + }; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::ZERO, + }; + let mut actual_rng = PecosRng::seed_from_u64(47); + assert!( + nonzero_sine_rate + .apply(&event, &mut NoiseContext::new(), &mut actual_rng) + .is_none() + ); + let mut expected_rng = PecosRng::seed_from_u64(47); + assert_eq!(actual_rng.random::(), expected_rng.random::()); + } + + #[test] + fn after_2q_noise_reproduces_exactly_for_the_same_seed() { + let channel = IdleChannel::linear(0.4) + .with_linear_depolarizing() + .with_idle_after_2q(2.0); + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + + let sample = || { + let mut rng = PecosRng::seed_from_u64(43); + collect_gates(channel.apply(&after_cx(&qubits), &mut NoiseContext::new(), &mut rng)) + }; + + assert_eq!(sample(), sample()); + } + + #[test] + fn sine_noise_reproduces_exactly_for_the_same_seed() { + let channel = IdleChannel { + sin_squared_rate: 0.6, + sin_squared_model: BTreeMap::from([ + ("X".to_string(), 0.5), + ("Y".to_string(), 0.75), + ("Z".to_string(), 1.0), + ]), + ..Default::default() + }; + let qubits = std::array::from_fn::<_, 16, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + + let sample = || { + collect_gates(channel.apply( + &event, + &mut NoiseContext::new(), + &mut PecosRng::seed_from_u64(53), + )) + }; + + let first = sample(); + assert!(!first.is_empty()); + assert_eq!(first, sample()); + } + + #[test] + fn incoherent_quadratic_probability_is_exact_twirl_of_coherent_angle() { + let theta = 1.0; + let incoherent = IdleChannel { + quadratic_rate: theta, + coherent_to_incoherent_factor: 1.0, + ..Default::default() + }; + let probability = incoherent.quadratic_probability(1.0); + assert!((probability - 0.229_848_847_065_930_15).abs() < 1e-15); + + let coherent = IdleChannel { + coherent_dephasing: true, + ..incoherent + }; + let qubits = [QubitId(0)]; + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(1), + }; + let mut rng = PecosRng::seed_from_u64(47); + let gates = collect_gates(coherent.apply(&event, &mut NoiseContext::new(), &mut rng)); + + assert_eq!(gates.len(), 1); + assert_eq!(gates[0].gate_type, GateType::RZ); + assert!((gates[0].angles[0].to_radians() - theta).abs() < 1e-15); + } + + #[test] + fn legacy_quadratic_paths_keep_their_pre_change_output_exactly() { + let qubits = std::array::from_fn::<_, 8, _>(QubitId); + let event = NoiseEvent::IdleTime { + qubits: &qubits, + duration: TimeUnits::new(2), + }; + let incoherent = IdleChannel { + quadratic_rate: 0.7, + coherent_to_incoherent_factor: 1.3, + ..Default::default() + }; + let mut incoherent_rng = PecosRng::seed_from_u64(424); + let incoherent_outputs = (0..4) + .map(|_| { + collect_gates(incoherent.apply( + &event, + &mut NoiseContext::new(), + &mut incoherent_rng, + )) + }) + .collect::>(); + let expected_incoherent_qubits: [&[usize]; 4] = [ + &[1, 3, 4, 5, 7], + &[0, 1, 3, 4, 5, 7], + &[0, 1, 4, 6, 7], + &[0, 1, 2, 4, 6, 7], + ]; + let expected_incoherent = expected_incoherent_qubits + .iter() + .map(|qubits| { + qubits + .iter() + .map(|&qubit| { + GateCommand::new(GateType::Z, smallvec::smallvec![QubitId(qubit)]) + }) + .collect::>() + }) + .collect::>(); + assert_eq!( + incoherent_outputs, expected_incoherent, + "the complete incoherent gate payload changed" + ); + assert_eq!(incoherent_rng.random::(), 13_820_570_602_603_389_690); + + let coherent = IdleChannel { + coherent_dephasing: true, + ..incoherent + }; + let mut coherent_rng = PecosRng::seed_from_u64(424); + let coherent_output = + collect_gates(coherent.apply(&event, &mut NoiseContext::new(), &mut coherent_rng)); + let expected_coherent = qubits + .iter() + .map(|&qubit| GateCommand::rz(qubit, Angle64::from_radians(1.4))) + .collect::>(); + assert_eq!( + coherent_output, expected_coherent, + "the complete coherent gate payload changed" + ); + assert_eq!(coherent_rng.random::(), 15_629_358_259_572_395_946); + } } diff --git a/exp/pecos-neo/src/noise/two_qubit.rs b/exp/pecos-neo/src/noise/two_qubit.rs index 857964ec1..513347305 100644 --- a/exp/pecos-neo/src/noise/two_qubit.rs +++ b/exp/pecos-neo/src/noise/two_qubit.rs @@ -282,16 +282,9 @@ pub struct TwoQubitChannel { /// Seepage probability for leaked qubits. pub seepage_probability: f64, - /// Idle noise rate applied after two-qubit gates. - /// - /// If non-zero, applies stochastic Z errors to involved qubits - /// after the gate (for memory sweeping). - pub idle_rate: f64, - // Precomputed probability thresholds for fast sampling seepage_threshold: u64, emission_threshold: u64, - idle_threshold: u64, } impl Default for TwoQubitChannel { @@ -303,10 +296,8 @@ impl Default for TwoQubitChannel { emission_ratio: 0.0, emission_weights: TwoQubitEmissionWeights::uniform_pauli(), seepage_probability: 0.0, - idle_rate: 0.0, seepage_threshold: 0, emission_threshold: 0, - idle_threshold: 0, } } } @@ -316,7 +307,6 @@ impl TwoQubitChannel { /// /// Precomputes probability thresholds for faster sampling. #[must_use] - #[allow(clippy::too_many_arguments)] pub fn new( error_probability: f64, angle_scaling: AngleScaling, @@ -324,7 +314,6 @@ impl TwoQubitChannel { emission_ratio: f64, emission_weights: TwoQubitEmissionWeights, seepage_probability: f64, - idle_rate: f64, ) -> Self { Self { error_probability, @@ -333,10 +322,8 @@ impl TwoQubitChannel { emission_ratio, emission_weights, seepage_probability, - idle_rate, seepage_threshold: PecosRng::probability_threshold(seepage_probability), emission_threshold: PecosRng::probability_threshold(emission_ratio), - idle_threshold: PecosRng::probability_threshold(idle_rate), } } @@ -393,16 +380,6 @@ impl TwoQubitChannel { self } - /// Set the idle noise rate applied after two-qubit gates. - /// - /// This models memory errors (T1/T2) that occur during the gate. - #[must_use] - pub fn with_idle_rate(mut self, rate: f64) -> Self { - self.idle_rate = rate; - self.idle_threshold = PecosRng::probability_threshold(rate); - self - } - /// Scale the error probability by a factor. /// /// This multiplies the current error probability by `scale`. @@ -661,19 +638,6 @@ impl TwoQubitChannel { response = response.combine(NoiseResponse::MarkUnleaked(unleaked)); } - // Apply idle noise after the gate (memory sweeping, using precomputed threshold) - if self.idle_rate > 0.0 { - let mut idle_gates = SmallVec::new(); - for &qubit in &[qubit0, qubit1] { - if !ctx.is_leaked(qubit) && rng.check_probability(self.idle_threshold) { - idle_gates.push(GateCommand::new(GateType::Z, smallvec::smallvec![qubit])); - } - } - if !idle_gates.is_empty() { - response = response.combine(NoiseResponse::inject_gates(idle_gates)); - } - } - response } } @@ -681,8 +645,19 @@ impl TwoQubitChannel { #[cfg(test)] mod tests { use super::*; + use crate::noise::{ComposableNoiseModel, IdleChannel, PauliWeights}; use pecos_core::QubitId; + fn collect_gates(response: NoiseResponse) -> Vec { + match response { + NoiseResponse::InjectGates(gates) => (*gates).into_vec(), + NoiseResponse::Multiple(responses) => { + responses.into_iter().flat_map(collect_gates).collect() + } + _ => Vec::new(), + } + } + #[test] fn test_depolarizing_channel() { let channel = TwoQubitChannel::depolarizing(1.0); @@ -722,6 +697,71 @@ mod tests { assert!(!channel.responds_to(&event)); } + #[test] + fn zero_error_probability_keeps_both_dispatch_guards() { + let channel = TwoQubitChannel::depolarizing(0.0); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + + assert!(!channel.responds_to(&event)); + let mut rng = PecosRng::seed_from_u64(5); + assert!( + channel + .try_apply(&event, &mut NoiseContext::new(), &mut rng) + .is_none() + ); + } + + #[test] + fn after_2q_idle_is_independent_of_the_gate_pauli_sample() { + let mut weights = [0.0; 15]; + weights[14] = 1.0; + let two_qubit = TwoQubitChannel::depolarizing(0.5) + .with_pauli_weights(TwoQubitPauliWeights::custom(weights)); + let idle = IdleChannel::linear(1.0) + .with_linear_weights(PauliWeights::custom(1.0, 0.0, 0.0)) + .with_idle_after_2q(1.0); + let qubits = [QubitId(0), QubitId(1)]; + let event = NoiseEvent::AfterGate { + gate_type: GateType::CX, + qubits: &qubits, + angles: &[], + gate_id: None, + }; + let mut model = ComposableNoiseModel::new() + .add_channel(two_qubit) + .add_channel(idle); + let mut saw_pauli_error = false; + let mut saw_no_pauli_error = false; + + for seed in 0..64 { + let gates = collect_gates(model.emit(&event, &mut PecosRng::seed_from_u64(seed))); + let x_count = gates + .iter() + .filter(|gate| gate.gate_type == GateType::X) + .count(); + let z_count = gates + .iter() + .filter(|gate| gate.gate_type == GateType::Z) + .count(); + + assert_eq!(x_count, 2, "idle missing for seed {seed}"); + match z_count { + 0 => saw_no_pauli_error = true, + 2 => saw_pauli_error = true, + _ => panic!("unexpected two-qubit Pauli response for seed {seed}: {gates:?}"), + } + } + + assert!(saw_pauli_error); + assert!(saw_no_pauli_error); + } + #[test] fn test_angle_scaling() { let linear = AngleScaling::linear(); diff --git a/exp/pecos-neo/src/program.rs b/exp/pecos-neo/src/program.rs index 2f5825169..0fee9af06 100644 --- a/exp/pecos-neo/src/program.rs +++ b/exp/pecos-neo/src/program.rs @@ -161,6 +161,11 @@ impl ProgramRunner { } /// Set the noise model. + /// + /// # Panics + /// + /// Panics during configuration if the model can inject rotations but the + /// underlying [`CircuitRunner`] has no rotation executor. #[must_use] pub fn with_noise(mut self, noise: ComposableNoiseModel) -> Self { self.runner = self.runner.with_noise(noise); diff --git a/exp/pecos-neo/src/runner.rs b/exp/pecos-neo/src/runner.rs index abe907840..ce31f63ad 100644 --- a/exp/pecos-neo/src/runner.rs +++ b/exp/pecos-neo/src/runner.rs @@ -794,8 +794,16 @@ impl CircuitRunner { /// Set the noise model. /// /// Gate definitions are automatically propagated to the noise model's context. + /// + /// # Panics + /// + /// Panics during configuration if the noise model declares a rotation-gate + /// emission but this runner has no rotation executor. #[must_use] pub fn with_noise(mut self, mut noise: ComposableNoiseModel) -> Self { + noise + .validate_runner_gate_support("CircuitRunner", self.rotation_executor.is_some()) + .unwrap_or_else(|message| panic!("{message}")); noise = noise.with_gate_definitions(self.definitions.clone()); self.noise = Some(noise); self @@ -1185,6 +1193,11 @@ impl CircuitRunner { /// Emits the event to the noise model, applies the response to state, /// and returns the response. Useful for idle noise between manually-applied /// gates, testing noise models, or custom execution loops. + /// + /// # Panics + /// + /// Panics if an undeclared noise mechanism injects a gate the runner cannot + /// execute. Declared requirements are rejected by [`Self::with_noise`]. pub fn apply_noise(&mut self, state: &mut S, event: &NoiseEvent<'_>) -> NoiseResponse { let Some(ref mut noise) = self.noise else { return NoiseResponse::None; @@ -2231,96 +2244,34 @@ impl CircuitRunner { /// Execute a noise gate (injected error). /// - /// Handles Pauli gates directly. For non-Pauli gates (rotations, Cliffords), - /// delegates to the rotation executor if available, otherwise skips. + /// # Panics + /// + /// Panics if neither the Clifford simulator nor the configured rotation + /// executor can execute the injected gate. Configuration validation should + /// make this unreachable for declared noise mechanisms. fn execute_noise_gate(&self, sim: &mut S, gate: &GateCommand) { let qubits = gate.qubits.as_slice(); - match gate.gate_type { - GateType::X => { - sim.x(qubits); - } - GateType::Y => { - sim.y(qubits); - } - GateType::Z => { - sim.z(qubits); - } - GateType::H => { - sim.h(qubits); - } - GateType::F => { - sim.f(qubits); - } - GateType::Fdg => { - sim.fdg(qubits); - } - GateType::SX => { - sim.sx(qubits); - } - GateType::SXdg => { - sim.sxdg(qubits); - } - GateType::SY => { - sim.sy(qubits); - } - GateType::SYdg => { - sim.sydg(qubits); - } - GateType::SZ => { - sim.sz(qubits); - } - GateType::SZdg => { - sim.szdg(qubits); - } - GateType::CX => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.cx(&pairs); - } - GateType::CY => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.cy(&pairs); - } - GateType::CZ => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.cz(&pairs); - } - GateType::SXX => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.sxx(&pairs); - } - GateType::SXXdg => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.sxxdg(&pairs); - } - GateType::SYY => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.syy(&pairs); - } - GateType::SYYdg => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.syydg(&pairs); - } - GateType::SZZ => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.szz(&pairs); - } - GateType::SZZdg => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.szzdg(&pairs); - } - GateType::SWAP => { - let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); - sim.swap(&pairs); - } - // Non-Clifford gates: delegate to rotation executor - other => { - if let Some(executor) = self.rotation_executor { - executor(sim, GateId::from(other), gate.angles.as_slice(), qubits); - } - // If no rotation executor, silently skip (noise channel injected - // a gate the simulator can't handle). - } - } + let arity = gate.gate_type.quantum_arity(); + assert!( + !qubits.is_empty() && qubits.len().is_multiple_of(arity), + "CircuitRunner invariant violated: injected noise gate {:?} has {} target(s), which \ + is not a nonzero multiple of its arity {arity}", + gate.gate_type, + qubits.len() + ); + let gate_id = GateId::from(gate.gate_type); + let executed = (gate.gate_type != GateType::Idle + && Self::try_execute_clifford(sim, gate_id, qubits)) + || self + .rotation_executor + .is_some_and(|executor| executor(sim, gate_id, gate.angles.as_slice(), qubits)); + + assert!( + executed, + "CircuitRunner invariant violated: injected noise gate {:?} could not be executed; \ + configuration validation should have rejected the emitting noise mechanism", + gate.gate_type + ); } } @@ -2505,6 +2456,7 @@ mod tests { use super::*; use crate::command::CommandBuilder; use crate::extensible::{GateCategory, GateSpec, OpBuilder, gates}; + use crate::noise::GeneralNoiseModelBuilder; use crate::noise::single_qubit::SingleQubitChannel; use num_complex::Complex64; use pecos_core::clifford::Clifford; @@ -2764,6 +2716,184 @@ mod tests { assert_eq!(outcomes.len(), 1); } + #[test] + fn coherent_idle_without_rotation_support_fails_during_configuration() { + let noise = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_p_idle_coherent(true) + .build(); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = CircuitRunner::::new().with_noise(noise); + })) + .expect_err("coherent idle noise must require rotation support"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("with_p_idle_coherent(true)"), "{message}"); + assert!(message.contains("CircuitRunner"), "{message}"); + assert!(message.contains("CircuitRunner::rotations()"), "{message}"); + assert!(message.contains("stochastic idle"), "{message}"); + } + + #[test] + fn every_coherent_idle_entry_point_names_its_configuring_setter() { + use crate::noise::composite::CompositeNoiseModelBuilder; + use crate::noise::{IdleChannel, NoiseModelBuilder}; + + let cases = [ + ( + "NoiseModelBuilder::with_coherent_idle(..)", + NoiseModelBuilder::new() + .with_idle_noise(0.0, 0.25) + .with_coherent_idle(1.0) + .build(), + ), + ( + "CompositeNoiseModelBuilder::with_p_idle_coherent(true)", + CompositeNoiseModelBuilder::new() + .with_p_idle_quadratic(0.25) + .with_p_idle_coherent(true) + .build(), + ), + ( + "IdleChannel::with_coherent_dephasing(true)", + ComposableNoiseModel::new().add_channel( + IdleChannel { + quadratic_rate: 0.25, + ..IdleChannel::default() + } + .with_coherent_dephasing(true), + ), + ), + ]; + + for (setter, noise) in cases { + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = CircuitRunner::::new().with_noise(noise); + })) + .expect_err("coherent idle noise must require rotation support"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains(setter), "{message}"); + assert!(message.contains("CircuitRunner::rotations()"), "{message}"); + } + } + + #[test] + fn coherent_idle_with_rotation_support_applies_rotation() { + let commands = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .idle(&[0], 1u64) + .h(&[0]) + .mz(&[0]) + .build(); + let noise = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(std::f64::consts::PI) + .with_p_idle_coherent(true) + .build(); + + let mut state = StateVec::with_seed(1, 42); + let mut runner = CircuitRunner::::rotations() + .with_noise(noise) + .with_seed(42); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + + let outcome = outcomes.get(QubitId(0)).unwrap(); + assert!(outcome.outcome, "RZ(pi) must turn |+> into |->"); + assert!(outcome.is_deterministic); + } + + #[test] + fn composite_coherent_rotation_is_validated_during_configuration() { + use crate::noise::composite::prelude::{CompositeChannelBuilder, coherent_rz, prob}; + + let noise = ComposableNoiseModel::new().add_channel(CompositeChannelBuilder::idle( + "coherent_idle", + prob(1.0, coherent_rz(0.25)), + )); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = CircuitRunner::::new().with_noise(noise); + })) + .expect_err("composite coherent rotation must require rotation support"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("InjectCoherentRZ::new(..)"), "{message}"); + assert!(message.contains("CircuitRunner"), "{message}"); + assert!(message.contains("CircuitRunner::rotations()"), "{message}"); + assert!(message.contains("stochastic Pauli"), "{message}"); + } + + #[test] + fn composite_unsupported_gate_is_rejected_with_or_without_rotations() { + use crate::noise::composite::prelude::{CompositeChannelBuilder, inject}; + + let noise = ComposableNoiseModel::new().add_channel(CompositeChannelBuilder::idle( + "unsupported_injection", + inject(GateType::PZ), + )); + + for with_rotations in [false, true] { + let runner = if with_rotations { + CircuitRunner::::rotations() + } else { + CircuitRunner::::new() + }; + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = runner.with_noise(noise.clone()); + })) + .expect_err("PZ cannot be executed as an injected noise gate"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("Inject::new(..)"), "{message}"); + assert!(message.contains("PZ"), "{message}"); + assert!(message.contains("CircuitRunner"), "{message}"); + assert!(message.contains("stochastic Pauli"), "{message}"); + } + } + + #[test] + #[should_panic(expected = "CircuitRunner invariant violated: injected noise gate PZ")] + fn unsupported_noise_gate_panics_in_circuit_runner() { + let mut state = SparseStab::with_seed(1, 42); + CircuitRunner::::new() + .execute_noise_gate(&mut state, &GateCommand::pz(QubitId(0))); + } + + #[test] + #[should_panic(expected = "CircuitRunner invariant violated: injected noise gate PZ")] + fn unsupported_noise_gate_panics_with_rotation_executor() { + let mut state = StateVec::with_seed(1, 42); + CircuitRunner::::rotations() + .execute_noise_gate(&mut state, &GateCommand::pz(QubitId(0))); + } + + #[test] + #[should_panic(expected = "CircuitRunner invariant violated: injected noise gate CX has 1")] + fn malformed_multi_qubit_noise_gate_panics_in_circuit_runner() { + let mut state = SparseStab::with_seed(1, 42); + CircuitRunner::::new().execute_noise_gate( + &mut state, + &GateCommand::new(GateType::CX, smallvec::smallvec![QubitId(0)]), + ); + } + #[test] fn test_with_gate_definitions() { use crate::extensible::{GateCategory, GateDefinitions}; diff --git a/exp/pecos-neo/src/sampling/importance_runner.rs b/exp/pecos-neo/src/sampling/importance_runner.rs index 419326a91..df9bdc812 100644 --- a/exp/pecos-neo/src/sampling/importance_runner.rs +++ b/exp/pecos-neo/src/sampling/importance_runner.rs @@ -202,8 +202,16 @@ impl ImportanceSamplingRunner { /// /// This noise model is used for structural noise effects (like leakage tracking). /// The error rates are overridden by the importance sampling configuration. + /// + /// # Panics + /// + /// Panics during configuration if the noise model declares a rotation-gate + /// emission, which this Clifford-only runner cannot execute. #[must_use] pub fn with_noise(mut self, noise: ComposableNoiseModel) -> Self { + noise + .validate_runner_gate_support("ImportanceSamplingRunner", false) + .unwrap_or_else(|message| panic!("{message}")); self.noise = Some(noise); self } @@ -289,6 +297,12 @@ impl ImportanceSamplingRunner { /// Run a single shot with importance sampling. /// /// Returns the measurement outcomes along with the importance weight. + /// + /// # Panics + /// + /// Panics if the circuit or an injected noise response contains a gate + /// that `ImportanceSamplingRunner` cannot execute. Declared noise + /// requirements are validated by [`Self::with_noise`]. pub fn run_shot(&mut self, commands: &CommandQueue) -> ImportanceSampledShot { // Reset for new shot self.weight = SampleWeight::one(); @@ -318,6 +332,10 @@ impl ImportanceSamplingRunner { /// /// **Performance**: Resets the simulator (8-12x faster than clone for large qubit counts) /// before running the circuit. + /// + /// # Panics + /// + /// Panics under the same conditions as [`Self::run_shot`]. pub fn run_shot_fresh(&mut self, commands: &CommandQueue) -> ImportanceSampledShot { // Reset simulator to |0⟩^n state (much faster than clone) self.simulator.reset(); @@ -364,7 +382,11 @@ impl ImportanceSamplingRunner { // Gate execution with importance-weighted noise _ => { - self.execute_clifford_gate(command); + assert!( + self.execute_clifford_gate(command), + "ImportanceSamplingRunner cannot execute circuit gate {:?}", + command.gate_type + ); self.apply_importance_sampled_gate_noise(command); } } @@ -590,21 +612,27 @@ impl ImportanceSamplingRunner { } /// Execute a noise gate. + /// + /// # Panics + /// + /// Panics if the simulator cannot execute the injected gate. Configuration + /// validation should make this unreachable for declared noise mechanisms. fn execute_noise_gate(&mut self, gate: &GateCommand) { - let qubits: Vec = gate.qubits.iter().copied().collect(); - - match gate.gate_type { - GateType::X => { - self.simulator.x(&qubits); - } - GateType::Y => { - self.simulator.y(&qubits); - } - GateType::Z => { - self.simulator.z(&qubits); - } - _ => {} - } + let arity = gate.gate_type.quantum_arity(); + assert!( + !gate.qubits.is_empty() && gate.qubits.len().is_multiple_of(arity), + "ImportanceSamplingRunner invariant violated: injected noise gate {:?} has {} \ + target(s), which is not a nonzero multiple of its arity {arity}", + gate.gate_type, + gate.qubits.len() + ); + assert!( + self.execute_clifford_gate(gate), + "ImportanceSamplingRunner invariant violated: injected noise gate {:?} could not be \ + executed; configuration validation should have rejected the emitting noise \ + mechanism", + gate.gate_type + ); } /// Execute Clifford gates. @@ -782,6 +810,11 @@ where /// 1. If deterministic (stabilizer eigenstate): return fixed outcome, no weight change /// 2. If non-deterministic (50/50): sample from biased proposal, force that outcome, /// update weight by P(outcome)/Q(outcome) = `0.5/bias_prob` + /// + /// # Panics + /// + /// Panics if the circuit or an injected noise response contains a gate + /// that `ImportanceSamplingRunner` cannot execute. pub fn run_shot_biased(&mut self, commands: &CommandQueue) -> ImportanceSampledShot { // Reset for new shot self.weight = SampleWeight::one(); @@ -843,7 +876,11 @@ where // Gate execution with importance-weighted noise (same as unbiased) _ => { - self.execute_clifford_gate(command); + assert!( + self.execute_clifford_gate(command), + "ImportanceSamplingRunner cannot execute circuit gate {:?}", + command.gate_type + ); self.apply_importance_sampled_gate_noise(command); } } @@ -885,9 +922,87 @@ where mod tests { use super::*; use crate::command::CommandBuilder; + use crate::noise::{NoiseChannel, NoiseContext}; use crate::sampling::weight::WeightedStatistics; use pecos_simulators::SparseStab; + #[derive(Clone)] + struct AfterPreparationGateChannel(GateType); + + impl NoiseChannel for AfterPreparationGateChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + matches!(event, NoiseEvent::AfterPreparation { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + _ctx: &mut NoiseContext, + _rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::AfterPreparation { qubits } = event else { + return NoiseResponse::None; + }; + NoiseResponse::inject_gate(GateCommand::new(self.0, smallvec::smallvec![qubits[0]])) + } + + fn name(&self) -> &'static str { + "AfterPreparationGateChannel" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + + #[derive(Clone)] + struct SeededPauliAfterPreparation; + + impl NoiseChannel for SeededPauliAfterPreparation { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + matches!(event, NoiseEvent::AfterPreparation { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + _ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::AfterPreparation { qubits } = event else { + return NoiseResponse::None; + }; + let gate_type = match rng.random_range(0..3) { + 0 => GateType::X, + 1 => GateType::Y, + _ => GateType::Z, + }; + NoiseResponse::inject_gate(GateCommand::new(gate_type, smallvec::smallvec![qubits[0]])) + } + + fn name(&self) -> &'static str { + "SeededPauliAfterPreparation" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + + fn outcome_bytes(outcomes: &MeasurementOutcomes) -> Vec { + outcomes + .iter() + .flat_map(|outcome| { + [ + u8::try_from(outcome.qubit.0).expect("test qubit fits in u8"), + u8::from(outcome.outcome), + u8::from(outcome.is_deterministic), + u8::from(outcome.is_leaked), + ] + }) + .collect() + } + #[test] fn test_importance_runner_basic() { let commands = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); @@ -937,6 +1052,115 @@ mod tests { assert!((result.weight.weight() - 1.0).abs() < 1e-10); } + #[test] + fn injected_h_reaches_importance_simulator() { + let commands = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let noise = + ComposableNoiseModel::new().add_channel(AfterPreparationGateChannel(GateType::H)); + let mut runner = ImportanceSamplingRunner::new(SparseStab::with_seed(1, 42)) + .with_noise(noise) + .with_seed(42); + + let result = runner.run_shot(&commands); + let outcome = result.outcomes.get(QubitId(0)).unwrap(); + + assert!(!outcome.outcome); + assert!( + outcome.is_deterministic, + "injected H followed by circuit H must return the qubit to |0>" + ); + } + + #[test] + fn coherent_idle_is_rejected_by_importance_runner_during_configuration() { + let noise = crate::noise::GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(0.25) + .with_p_idle_coherent(true) + .build(); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = ImportanceSamplingRunner::new(SparseStab::with_seed(1, 42)).with_noise(noise); + })) + .expect_err("importance sampling cannot execute coherent idle rotations"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("with_p_idle_coherent(true)"), "{message}"); + assert!(message.contains("ImportanceSamplingRunner"), "{message}"); + assert!(message.contains("stochastic idle"), "{message}"); + assert!( + message.contains("does not provide a rotation executor"), + "{message}" + ); + } + + #[test] + fn seeded_xyz_noise_output_is_byte_identical_on_both_runners() { + let qubits = [0, 1, 2, 3, 4, 5, 6, 7]; + let commands = CommandBuilder::new().pz(&qubits).mz(&qubits).build(); + let noise = || ComposableNoiseModel::new().add_channel(SeededPauliAfterPreparation); + + let mut state = SparseStab::with_seed(qubits.len(), 42); + let mut circuit_runner = crate::runner::CircuitRunner::::new() + .with_noise(noise()) + .with_seed(0x436_437); + let circuit_bytes = + outcome_bytes(&circuit_runner.apply_circuit(&mut state, &commands).unwrap()); + + let mut importance_runner = + ImportanceSamplingRunner::new(SparseStab::with_seed(qubits.len(), 42)) + .with_noise(noise()) + .with_seed(0x436_437); + let importance_bytes = outcome_bytes(&importance_runner.run_shot(&commands).outcomes); + + let baseline = vec![ + 0, 1, 1, 0, 1, 1, 1, 0, 2, 1, 1, 0, 3, 1, 1, 0, 4, 1, 1, 0, 5, 0, 1, 0, 6, 1, 1, 0, 7, + 1, 1, 0, + ]; + assert_eq!(circuit_bytes, baseline); + assert_eq!(importance_bytes, baseline); + } + + #[test] + #[should_panic( + expected = "ImportanceSamplingRunner invariant violated: injected noise gate PZ" + )] + fn unsupported_noise_gate_panics_in_importance_runner() { + let mut runner = ImportanceSamplingRunner::new(SparseStab::with_seed(1, 42)); + runner.execute_noise_gate(&GateCommand::pz(QubitId(0))); + } + + #[test] + #[should_panic(expected = "ImportanceSamplingRunner cannot execute circuit gate T")] + fn unsupported_circuit_gate_panics_in_importance_runner() { + let commands = CommandBuilder::new().pz(&[0]).t(&[0]).build(); + let mut runner = ImportanceSamplingRunner::new(SparseStab::with_seed(1, 42)); + let _ = runner.run_shot(&commands); + } + + #[test] + #[should_panic(expected = "ImportanceSamplingRunner cannot execute circuit gate T")] + fn unsupported_circuit_gate_panics_in_biased_importance_runner() { + let commands = CommandBuilder::new().pz(&[0]).t(&[0]).build(); + let mut runner = ImportanceSamplingRunner::new(SparseStab::with_seed(1, 42)); + let _ = runner.run_shot_biased(&commands); + } + + #[test] + #[should_panic( + expected = "ImportanceSamplingRunner invariant violated: injected noise gate CX has 1" + )] + fn malformed_multi_qubit_noise_gate_panics_in_importance_runner() { + let mut runner = ImportanceSamplingRunner::new(SparseStab::with_seed(1, 42)); + runner.execute_noise_gate(&GateCommand::new( + GateType::CX, + smallvec::smallvec![QubitId(0)], + )); + } + #[test] fn test_importance_sampling_estimates_correct_rate() { // This test verifies that importance sampling produces diff --git a/exp/pecos-neo/src/sampling/path.rs b/exp/pecos-neo/src/sampling/path.rs index 54bf83840..1956288e3 100644 --- a/exp/pecos-neo/src/sampling/path.rs +++ b/exp/pecos-neo/src/sampling/path.rs @@ -390,6 +390,11 @@ impl PathExplorer { /// /// This executes the program normally (with random measurement outcomes) /// while recording which outcomes occurred. + /// + /// # Panics + /// + /// Panics if the circuit contains a gate the Clifford simulator cannot + /// execute. pub fn run_and_record(&mut self, commands: &CommandQueue) -> PathRecordedResult { self.simulator.reset(); let mut outcomes = MeasurementOutcomes::new(); @@ -410,6 +415,11 @@ impl PathExplorer { /// /// Returns the outcomes and the actual path taken (which may differ /// from the input if some measurements were deterministic). + /// + /// # Panics + /// + /// Panics if the circuit contains a gate the Clifford simulator cannot + /// execute. pub fn run_with_path( &mut self, commands: &CommandQueue, @@ -609,7 +619,9 @@ impl PathExplorer { qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); self.simulator.swap(&pairs); } - _ => {} + unsupported => panic!( + "PathExplorer cannot execute circuit gate {unsupported:?}; use a Clifford gate" + ), } } } @@ -684,6 +696,20 @@ mod tests { use crate::command::CommandBuilder; use pecos_simulators::SparseStab; + #[test] + #[should_panic(expected = "PathExplorer cannot execute circuit gate T")] + fn unsupported_gate_is_not_silently_dropped() { + let commands = CommandBuilder::new() + .gate(GateCommand::new( + GateType::T, + smallvec::smallvec![QubitId(0)], + )) + .build(); + let mut explorer = PathExplorer::new(SparseStab::with_seed(1, 42)); + + let _ = explorer.run_and_record(&commands); + } + #[test] fn test_measurement_path_basic() { let mut path = MeasurementPath::new(); diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 8e56a44b3..a7c148a6f 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -347,6 +347,11 @@ pub trait SimulatorFactory: Send + Sync { "custom backend" } + /// Whether runners created by this factory include a rotation executor. + fn has_rotation_support(&self) -> bool { + false + } + /// Create a program runner for the given number of qubits. /// /// Called once during simulation startup. The returned runner handles @@ -559,6 +564,10 @@ where + 'static, F: Fn(usize) -> S + Send + Sync, { + fn has_rotation_support(&self) -> bool { + true + } + fn create_runner( &self, num_qubits: usize, @@ -2604,6 +2613,33 @@ impl SimNeoBuilder { _ => {} } + if let Some(noise) = &self.noise { + let (runner, has_rotation_support) = match &sampling { + Sampling::ImportanceSampling { .. } => ("ImportanceSamplingRunner", false), + Sampling::SubsetSimulation { .. } => ("CircuitRunner", false), + Sampling::MonteCarlo { .. } => match &quantum_backend { + QuantumBackend::SparseStab | QuantumBackend::Stabilizer => { + ("CircuitRunner", false) + } + QuantumBackend::StateVec => ("CircuitRunner", true), + QuantumBackend::Custom(factory) => { + (factory.diagnostic_label(), factory.has_rotation_support()) + } + QuantumBackend::AdaptedQuantumEngine(_) => { + // Noise was rejected above for this backend. + ("QuantumEngineBuilder backend", false) + } + }, + Sampling::PathEnumeration { .. } => { + // Noise is rejected by path-enumeration validation below. + ("PathExplorer", false) + } + }; + noise + .validate_runner_gate_support(runner, has_rotation_support) + .unwrap_or_else(|message| panic!("{message}")); + } + let parallel_plan = match &sampling { Sampling::MonteCarlo { workers, .. } if *workers > 1 => { let plan = build_parallel_execution_plan( @@ -2647,6 +2683,7 @@ impl SimNeoBuilder { Some(StaticCircuitSpec { circuit, num_qubits, + noise: self.noise.clone(), }) } else { None @@ -2689,6 +2726,7 @@ impl SimNeoBuilder { Some(StaticCircuitSpec { circuit, num_qubits, + noise: None, }) } _ => None, @@ -3447,10 +3485,14 @@ fn is_sim_startup(resources: &mut Resources) { // Consume QuantumBackendResource (IS always uses SparseStab internally) let _ = resources.remove::(); - // Also consume NoiseResource if present (IS uses its own boosted noise) - let _ = resources.try_remove::(); + let noise = resources + .try_remove::() + .map(|resource| resource.0); - let runner = build_importance_runner(&is_config, num_qubits); + let mut runner = build_importance_runner(&is_config, num_qubits); + if let Some(noise) = noise { + runner = runner.with_noise(noise); + } resources.insert(ISShotState { runner, @@ -3551,6 +3593,7 @@ struct SubsetRunSpec { struct StaticCircuitSpec { circuit: CommandQueue, num_qubits: usize, + noise: Option, } /// Native backend used by the internal parallel runner factory. @@ -4015,6 +4058,9 @@ impl Simulation { } let mut runner = build_importance_runner(is_config, spec.num_qubits); + if let Some(noise) = spec.noise.clone() { + runner = runner.with_noise(noise); + } let start = start_indices[worker_id]; for shot_index in start..start + worker_shots { if let Some(base_seed) = base_seed { @@ -4296,11 +4342,46 @@ fn distribute_shots(num_shots: usize, num_workers: usize) -> Vec { #[allow(clippy::cast_precision_loss)] // statistical tests use count as f64 mod tests { use super::*; - use crate::command::CommandBuilder; - use crate::noise::{ComposableNoiseModel, SingleQubitChannel}; + use crate::command::{CommandBuilder, GateCommand, GateType}; + use crate::noise::{ + ComposableNoiseModel, GeneralNoiseModelBuilder, NoiseChannel, NoiseContext, NoiseEvent, + NoiseResponse, SingleQubitChannel, + }; use crate::program::ConditionalProgram; use pecos_core::QubitId; + #[derive(Clone)] + struct AfterPreparationHChannel; + + impl NoiseChannel for AfterPreparationHChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + matches!(event, NoiseEvent::AfterPreparation { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + _ctx: &mut NoiseContext, + _rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::AfterPreparation { qubits } = event else { + return NoiseResponse::None; + }; + NoiseResponse::inject_gate(GateCommand::new( + GateType::H, + smallvec::smallvec![qubits[0]], + )) + } + + fn name(&self) -> &'static str { + "AfterPreparationHChannel" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + } + #[test] fn test_sim_neo_basic() { let circuit = CommandBuilder::new() @@ -6102,6 +6183,65 @@ mod tests { assert_eq!(weights.len(), 100); } + #[test] + fn importance_sampling_keeps_configured_noise_in_sequential_and_parallel_runs() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + + for workers in [1, 2] { + let noise = ComposableNoiseModel::new().add_channel(AfterPreparationHChannel); + let results = sim_neo(circuit.clone()) + .auto() + .noise(noise) + .sampling( + importance_sampling(4) + .with_uniform_error(0.0) + .workers(workers), + ) + .seed(42) + .run(); + + for outcomes in &results.outcomes { + let outcome = outcomes.get(QubitId(0)).unwrap(); + assert!(!outcome.outcome); + assert!( + outcome.is_deterministic, + "configured H noise was lost with {workers} worker(s)" + ); + } + } + } + + #[test] + fn coherent_idle_mismatch_fails_while_building_sim_neo() { + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + let noise = GeneralNoiseModelBuilder::new() + .with_p_idle_quadratic(0.25) + .with_p_idle_coherent(true) + .build(); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = sim_neo(circuit) + .auto() + .noise(noise) + .sampling(importance_sampling(1)) + .build(); + })) + .expect_err("the noise/runner mismatch must fail during build"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic payload should be a string"); + + assert!(message.contains("with_p_idle_coherent(true)"), "{message}"); + assert!(message.contains("ImportanceSamplingRunner"), "{message}"); + assert!(message.contains("stochastic idle"), "{message}"); + assert!( + message.contains("does not provide a rotation executor"), + "{message}" + ); + } + #[test] fn test_sim_neo_importance_sampling_uniform() { // Test the convenience method for uniform error rates diff --git a/exp/pecos-neo/tests/engine_comparison_test.rs b/exp/pecos-neo/tests/engine_comparison_test.rs index 7de29e357..cb1186dbe 100644 --- a/exp/pecos-neo/tests/engine_comparison_test.rs +++ b/exp/pecos-neo/tests/engine_comparison_test.rs @@ -186,7 +186,7 @@ fn test_monte_carlo_with_depolarizing_noise() { // Build equivalent noise models // pecos-engines uses scaled probabilities let engines_noise = GeneralNoiseModel::builder() - .with_average_p1_probability(p1 / 1.5) // Scale down for engines + .with_average_p1(p1 / 1.5) // Scale down for engines .build(); // Simple circuit: prep, apply X (identity on |0>), measure @@ -266,8 +266,8 @@ fn test_monte_carlo_measurement_errors() { // pecos-engines noise model let engines_noise = GeneralNoiseModel::builder() - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) .build(); // Circuit: prep |0>, measure (should be 0, but measurement errors flip some) @@ -401,7 +401,7 @@ fn test_monte_carlo_two_qubit_noise() { // pecos-engines noise model (scaled) let engines_noise = GeneralNoiseModel::builder() - .with_average_p2_probability(p2 / 1.25) // Scale down for engines + .with_average_p2(p2 / 1.25) // Scale down for engines .build(); // Circuit: Bell state creation, errors will decorrelate outcomes diff --git a/exp/pecos-neo/tests/noise_comparison_test.rs b/exp/pecos-neo/tests/noise_comparison_test.rs index 29df0f235..6d971cf2f 100644 --- a/exp/pecos-neo/tests/noise_comparison_test.rs +++ b/exp/pecos-neo/tests/noise_comparison_test.rs @@ -163,11 +163,11 @@ fn test_single_qubit_depolarizing_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(average_p1) - .with_average_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_average_p1(average_p1) + .with_average_p2(0.0) .with_p1_emission_ratio(0.0) // No leakage .with_seed(42) .build(); @@ -227,11 +227,11 @@ fn test_two_qubit_depolarizing_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(average_p2) + .with_p_prep(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_average_p1(0.0) + .with_average_p2(average_p2) .with_p2_emission_ratio(0.0) // No leakage .with_seed(42) .build(); @@ -296,11 +296,11 @@ fn test_measurement_error_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(0.0) + .with_p_prep(0.0) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(0.0) + .with_average_p1(0.0) + .with_average_p2(0.0) .with_seed(42) .build(); @@ -361,12 +361,12 @@ fn test_preparation_error_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) + .with_p_prep(p_prep) .with_prep_leak_ratio(0.0) // No leakage - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0) + .with_average_p1(0.0) + .with_average_p2(0.0) .with_seed(42) .build(); @@ -418,12 +418,12 @@ fn test_combined_noise_comparison() { // GeneralNoiseModel setup let general_model = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) + .with_p_prep(p_prep) .with_prep_leak_ratio(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -503,12 +503,12 @@ fn test_general_noise_model_builder_comparison() { // Original GeneralNoiseModel from pecos-engines let general_model = GeneralNoiseModel::builder() - .with_prep_probability(p_prep) + .with_p_prep(p_prep) .with_prep_leak_ratio(0.0) - .with_meas_0_probability(p_meas_0) - .with_meas_1_probability(p_meas_1) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_meas_0(p_meas_0) + .with_p_meas_1(p_meas_1) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -566,11 +566,11 @@ fn test_idle_noise_with_time_scale() { // Test that idle noise with TimeScale produces expected decoherence. // // Circuit: prep |0> → X (to get |1>) → H → idle → H → measure - // The H gates convert Z errors (dephasing) to bit flip errors for detection. + // The H gates convert Y/Z transverse-coherence errors to bit flips for detection. // With T1=10us, T2=5us, and 1us idle, we expect ~10% error rate. // - // Note: IdleChannel by default produces Z-only errors (dephasing model), - // so we use H-basis measurement to detect them. + // The Pauli twirl produces X, Y, and Z errors. H-basis measurement detects the transverse + // coherence errors Y and Z while X leaves |+> unchanged. use pecos_core::TimeScale; @@ -585,13 +585,13 @@ fn test_idle_noise_with_time_scale() { assert!(model.time_scale().is_some()); assert_eq!(model.channel_count(), 1); - // Circuit with idle - use H gates to make Z errors detectable - // H|+> = |0>, H|-> = |1>, so Z|+> = |-> gives different outcome after H + // Circuit with idle - use H gates to make the Y/Z phase-changing errors detectable. + // H|+> = |0>, H|-> = |1>, so Y|+> and Z|+> give |-> up to phase. let commands = CommandBuilder::new() .pz(&[0]) .h(&[0]) // Prepare |+> state - .idle(&[0], 1000) // 1000 ns idle = 1 us (Z errors here) - .h(&[0]) // Convert Z errors to bit flips + .idle(&[0], 1000) // 1000 ns idle = 1 us (Pauli-twirled errors here) + .h(&[0]) // Convert Y/Z phase changes to measurement flips .mz(&[0]) .build(); @@ -609,7 +609,7 @@ fn test_idle_noise_with_time_scale() { if let Some(bits) = outcomes.bitstring(&qubits) && bits[0] { - error_count += 1; // Z error during idle will cause |1> outcome + error_count += 1; // Y/Z error during idle will cause |1> outcome } } @@ -617,13 +617,13 @@ fn test_idle_noise_with_time_scale() { println!("Idle noise with TimeScale:"); println!(" T1=10us, T2=5us, idle=1us"); - println!(" Error rate: {error_rate:.1}% (expected ~10% from linear/T1 dephasing)"); + println!(" Error rate: {error_rate:.1}% (expected ~10% from total-T2 coherence)"); - // Analytic expectation: linear_rate = 1/T1 = 1e-4/ns, so 1000 ns idle - // gives p = 0.1 exactly (Z-only weights, detected via H basis). The - // quadratic T2 term contributes sin^2(4e-5) ~ 1.6e-9, negligible. + // Analytic first-order Pauli twirl: the total linear rate is 1.25e-4/ns with weights + // (0.2, 0.2, 0.6), and the quadratic rate is zero. In the H basis, Y and Z are detected, so + // 1000 ns gives 1000 * 1.25e-4 * (0.2 + 0.6) = 0.1. assert!( rate_matches_expected(error_rate, 10.0), - "Error rate {error_rate:.1}% should be within {K_SIGMA} sigma of the analytic 10% T1 dephasing rate" + "Error rate {error_rate:.1}% should be within {K_SIGMA} sigma of the analytic 10% total-T2 coherence rate" ); } diff --git a/exp/pecos-neo/tests/sim_neo_comparison_test.rs b/exp/pecos-neo/tests/sim_neo_comparison_test.rs index 1cf9ad081..242654525 100644 --- a/exp/pecos-neo/tests/sim_neo_comparison_test.rs +++ b/exp/pecos-neo/tests/sim_neo_comparison_test.rs @@ -283,7 +283,7 @@ fn test_sim_neo_vs_sim_depolarizing_noise() { let p1 = 0.05; // pecos-engines noise model builder - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); // Scale factor for engines + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); // Scale factor for engines let qasm = r#" OPENQASM 2.0; @@ -348,8 +348,8 @@ fn test_sim_neo_vs_sim_measurement_noise() { // pecos-engines noise model let engines_noise = EnginesNoiseBuilder::new() - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas); + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas); let qasm = r#" OPENQASM 2.0; @@ -514,7 +514,7 @@ fn test_sim_neo_vs_sim_conditional_with_noise() { measure q[0] -> c[0]; "#; - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); // Run with sim() let engines_results = sim(qasm_engine().qasm(qasm)) @@ -660,7 +660,7 @@ fn test_sim_neo_ergonomic_builder_direct() { let p1 = 0.10; // pecos-engines - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); let qasm = r#" OPENQASM 2.0; @@ -890,7 +890,7 @@ fn test_sim_neo_noise_level_scaling() { for &p1 in &noise_levels { // pecos-engines - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); let qasm = r#" OPENQASM 2.0; @@ -958,11 +958,11 @@ fn test_sim_neo_noise_level_scaling() { fn test_sim_neo_vs_sim_zero_noise() { // Explicitly test with noise model but zero error rates let engines_noise = EnginesNoiseBuilder::new() - .with_prep_probability(0.0) - .with_average_p1_probability(0.0) - .with_average_p2_probability(0.0) - .with_meas_0_probability(0.0) - .with_meas_1_probability(0.0); + .with_p_prep(0.0) + .with_average_p1(0.0) + .with_average_p2(0.0) + .with_p_meas_0(0.0) + .with_p_meas_1(0.0); let qasm = r#" OPENQASM 2.0; @@ -1029,7 +1029,7 @@ fn test_sim_neo_high_noise_chaos() { // Test behavior at high noise levels (near 50% depolarizing) let p1 = 0.40; // 40% depolarizing - very noisy - let engines_noise = EnginesNoiseBuilder::new().with_average_p1_probability(p1 / 1.5); + let engines_noise = EnginesNoiseBuilder::new().with_average_p1(p1 / 1.5); let qasm = r#" OPENQASM 2.0; @@ -1092,7 +1092,7 @@ fn test_sim_neo_vs_sim_two_qubit_noise() { let p2 = 0.10; // pecos-engines noise model with scaling factor - let engines_noise = EnginesNoiseBuilder::new().with_average_p2_probability(p2 / 1.25); // Scale factor for engines + let engines_noise = EnginesNoiseBuilder::new().with_average_p2(p2 / 1.25); // Scale factor for engines let qasm = r#" OPENQASM 2.0; @@ -1165,7 +1165,7 @@ fn test_sim_neo_vs_sim_preparation_noise() { let p_prep = 0.15; // pecos-engines noise model - let engines_noise = EnginesNoiseBuilder::new().with_prep_probability(p_prep); + let engines_noise = EnginesNoiseBuilder::new().with_p_prep(p_prep); let qasm = r#" OPENQASM 2.0; @@ -1227,11 +1227,11 @@ fn test_sim_neo_vs_sim_combined_noise() { // pecos-engines noise model (with scaling factors) let engines_noise = EnginesNoiseBuilder::new() - .with_prep_probability(p_prep) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas); + .with_p_prep(p_prep) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas); // Bell state circuit with noise let qasm = r#" diff --git a/exp/pecos-neo/tests/statistical_validation_test.rs b/exp/pecos-neo/tests/statistical_validation_test.rs index 59031a82b..6fdd271d8 100644 --- a/exp/pecos-neo/tests/statistical_validation_test.rs +++ b/exp/pecos-neo/tests/statistical_validation_test.rs @@ -482,8 +482,8 @@ fn test_neo_vs_engines_noisy_comparison() { // pecos-engines noise model let engines_noise = GeneralNoiseModel::builder() - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .build(); let engine = QASMEngine::from_str(qasm).unwrap(); diff --git a/exp/pecos-neo/tests/surface_code_comparison_test.rs b/exp/pecos-neo/tests/surface_code_comparison_test.rs index e82307c72..366bc2d7d 100644 --- a/exp/pecos-neo/tests/surface_code_comparison_test.rs +++ b/exp/pecos-neo/tests/surface_code_comparison_test.rs @@ -416,11 +416,11 @@ fn test_repetition_code_logical_error_vs_rounds() { // GeneralNoiseModel let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) // Scale for average probability - .with_average_p2_probability(p2 / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) // Scale for average probability + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -480,11 +480,11 @@ fn test_repetition_code_syndrome_rates() { let p_meas = 0.02; let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -545,11 +545,11 @@ fn test_repetition_code_syndrome_correlations() { let p_meas = 0.02; let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p_meas) - .with_meas_1_probability(p_meas) - .with_average_p1_probability(p1 / 1.5) - .with_average_p2_probability(p2 / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p_meas) + .with_p_meas_1(p_meas) + .with_average_p1(p1 / 1.5) + .with_average_p2(p2 / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) @@ -623,11 +623,11 @@ fn test_repetition_code_error_scaling() { for &p in &error_rates { let general_model = GeneralNoiseModel::builder() - .with_prep_probability(0.0) - .with_meas_0_probability(p) - .with_meas_1_probability(p) - .with_average_p1_probability(p / 1.5) - .with_average_p2_probability(p / 1.25) + .with_p_prep(0.0) + .with_p_meas_0(p) + .with_p_meas_1(p) + .with_average_p1(p / 1.5) + .with_average_p2(p / 1.25) .with_p1_emission_ratio(0.0) .with_p2_emission_ratio(0.0) .with_seed(42) diff --git a/mkdocs.yml b/mkdocs.yml index b58629fb6..cad2211f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - QEC Geometry: user-guide/qec-geometry.md - QEC with Guppy: user-guide/qec-guppy.md - Detector Error Models from Guppy: user-guide/dem-from-guppy.md + - Inferring DEM Annotations from Guppy Outputs: user-guide/inferred-guppy-dem.md - Decoders: user-guide/decoders.md - Graph API: user-guide/graph-api.md - Circuit Representation: user-guide/circuit-representation.md @@ -79,6 +80,8 @@ nav: - LLVM Setup: user-guide/llvm-setup.md - CUDA Setup: user-guide/cuda-setup.md - cmake Setup (MWPF): user-guide/cmake-setup.md +- Workflows: + - Decode a Guppy QEC experiment with idle noise: workflows/guppy-dem-decoding.md - Concepts: - concepts/index.md - StabVec Simulator: concepts/clifford-rz-simulator.md @@ -96,8 +99,6 @@ nav: - Experimental: - experimental/index.md - Composable Noise (pecos-neo): experimental/composable-noise.md -- Proposals: - - proposals/README.md - Releases: - releases/changelog.md markdown_extensions: diff --git a/python/pecos-rslib/examples/namespace_demo.py b/python/pecos-rslib/examples/namespace_demo.py index 415878cde..18eae4131 100755 --- a/python/pecos-rslib/examples/namespace_demo.py +++ b/python/pecos-rslib/examples/namespace_demo.py @@ -85,8 +85,8 @@ def namespace_usage_examples() -> None: .seed(42)\\ .quantum_engine(quantum.sparse_stab())\\ .noise(noise.depolarizing() - .with_prep_probability(0.001) - .with_p1_probability(0.01))\\ + .with_p_prep(0.001) + .with_p1(0.01))\\ .run(1000) """, ) @@ -130,11 +130,7 @@ def run_example_simulations() -> None: .to_sim() .quantum_engine(pecos_rslib.quantum.sparse_stab()) .noise( - pecos_rslib.noise.depolarizing() - .with_prep_probability(0.001) - .with_meas_probability(0.001) - .with_p1_probability(0.002) - .with_p2_probability(0.01), + pecos_rslib.noise.depolarizing().with_p_prep(0.001).with_p_meas(0.001).with_p1(0.002).with_p2(0.01), ) .run(1000) ) @@ -148,7 +144,7 @@ def run_example_simulations() -> None: sim = engines.qasm().program(bell_state).to_sim() sim.seed(12345) sim.quantum_engine(quantum.sparse_stab()) # Using the alias - sim.noise(noise.general().with_p1_probability(0.001)) + sim.noise(noise.general().with_p1(0.001)) results = sim.run(500) print(" Ran 500 shots with imported namespaces") diff --git a/python/pecos-rslib/examples/namespace_example.py b/python/pecos-rslib/examples/namespace_example.py index 37b5c228d..3be05ebcc 100644 --- a/python/pecos-rslib/examples/namespace_example.py +++ b/python/pecos-rslib/examples/namespace_example.py @@ -51,10 +51,10 @@ def main() -> None: # Configure depolarizing noise noise_model = ( pecos_rslib.noise.depolarizing() - .with_prep_probability(0.001) # State preparation errors - .with_meas_probability(0.005) # Measurement errors - .with_p1_probability(0.002) # Single-qubit gate errors - .with_p2_probability(0.01) # Two-qubit gate errors + .with_p_prep(0.001) # State preparation errors + .with_p_meas(0.005) # Measurement errors + .with_p1(0.002) # Single-qubit gate errors + .with_p2(0.01) # Two-qubit gate errors ) # Run simulation using namespace API diff --git a/python/pecos-rslib/examples/qasm_simulation_examples.py b/python/pecos-rslib/examples/qasm_simulation_examples.py index c7185275b..3daabee28 100755 --- a/python/pecos-rslib/examples/qasm_simulation_examples.py +++ b/python/pecos-rslib/examples/qasm_simulation_examples.py @@ -42,13 +42,7 @@ def example_bell_state() -> None: print(f" |{outcome:02b}⟩: {count} times") # Run with depolarizing noise - noise = ( - depolarizing_noise() - .with_prep_probability(0.001) - .with_meas_probability(0.002) - .with_p1_probability(0.02) - .with_p2_probability(0.02) - ) + noise = depolarizing_noise().with_p_prep(0.001).with_p_meas(0.002).with_p1(0.02).with_p2(0.02) results_noisy = qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(noise).run(1000) results_noisy_dict = results_noisy.to_dict() counts_noisy = Counter(results_noisy_dict["c"]) @@ -76,10 +70,10 @@ def example_ghz_state() -> None: # Run with custom depolarizing noise noise = ( depolarizing_noise() - .with_prep_probability(0.001) # Low preparation error - .with_meas_probability(0.005) # Moderate measurement error - .with_p1_probability(0.001) # Low single-qubit gate error - .with_p2_probability(0.01) + .with_p_prep(0.001) # Low preparation error + .with_p_meas(0.005) # Moderate measurement error + .with_p1(0.001) # Low single-qubit gate error + .with_p2(0.01) ) # Higher two-qubit gate error # Different ways to specify quantum engine: @@ -127,14 +121,7 @@ def example_biased_depolarizing() -> None: ideal_counts = Counter(results_ideal_dict["c"]) # Biased depolarizing noise - noise = ( - biased_depolarizing_noise() - .with_prep_probability(0.1) - .with_meas_0_probability(0.1) - .with_meas_1_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1) - ) + noise = biased_depolarizing_noise().with_p_prep(0.1).with_p_meas_0(0.1).with_p_meas_1(0.1).with_p1(0.1).with_p2(0.1) results_biased = qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(noise).run(1000) results_biased_dict = results_biased.to_dict() @@ -203,13 +190,7 @@ def example_builder_pattern() -> None: """ # Build once, run multiple times with different shot counts - noise = ( - depolarizing_noise() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.01) - .with_p2_probability(0.01) - ) + noise = depolarizing_noise().with_p_prep(0.01).with_p_meas(0.01).with_p1(0.01).with_p2(0.01) sim = ( qasm_engine() @@ -232,11 +213,11 @@ def example_builder_pattern() -> None: # Or run directly without building noise_biased = ( biased_depolarizing_noise() - .with_prep_probability(0.005) - .with_meas_0_probability(0.005) - .with_meas_1_probability(0.005) - .with_p1_probability(0.005) - .with_p2_probability(0.005) + .with_p_prep(0.005) + .with_p_meas_0(0.005) + .with_p_meas_1(0.005) + .with_p1(0.005) + .with_p2(0.005) ) results = qasm_engine().program(Qasm.from_string(qasm)).to_sim().noise(noise_biased).run(500) @@ -305,13 +286,7 @@ def example_parallel_execution() -> None: measure q -> c; """ - noise = ( - depolarizing_noise() - .with_prep_probability(0.001) - .with_meas_probability(0.001) - .with_p1_probability(0.001) - .with_p2_probability(0.001) - ) + noise = depolarizing_noise().with_p_prep(0.001).with_p_meas(0.001).with_p1(0.001).with_p2(0.001) # Single worker start = time.time() diff --git a/python/pecos-rslib/examples/structured_config_examples.py b/python/pecos-rslib/examples/structured_config_examples.py index 972ea52ac..f7b4bb69f 100644 --- a/python/pecos-rslib/examples/structured_config_examples.py +++ b/python/pecos-rslib/examples/structured_config_examples.py @@ -35,10 +35,10 @@ def example_basic_noise_builder() -> None: noise = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) # Single-qubit gate error - .with_p2_probability(0.01) # Two-qubit gate error - .with_meas_0_probability(0.002) # 0->1 measurement flip - .with_meas_1_probability(0.002) # 1->0 measurement flip + .with_p1(0.001) # Single-qubit gate error + .with_p2(0.01) # Two-qubit gate error + .with_p_meas_0(0.002) # 0->1 measurement flip + .with_p_meas_1(0.002) # 1->0 measurement flip ) # Use noise directly with .noise() @@ -74,7 +74,7 @@ def example_advanced_noise_builder() -> None: .with_scale(1.2) # Scale all error rates by 1.2 .with_noiseless_gate("H") # H gates have no noise # Single-qubit gate noise with Pauli distribution - .with_average_p1_probability(0.001) # Average error (converted to total) + .with_average_p1(0.001) # Average error (converted to total) .with_p1_pauli_model( { "X": 0.5, # 50% X errors @@ -83,11 +83,11 @@ def example_advanced_noise_builder() -> None: }, ) # Two-qubit gate noise - .with_average_p2_probability(0.008) # Average error (converted to total) + .with_average_p2(0.008) # Average error (converted to total) # Preparation and measurement noise - .with_prep_probability(0.001) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.003) # Asymmetric measurement error + .with_p_prep(0.001) + .with_p_meas_0(0.002) + .with_p_meas_1(0.003) # Asymmetric measurement error ) results = sim(qasm).noise(noise).run(1000) @@ -113,7 +113,7 @@ def example_direct_configuration() -> None: """ # Create noise using functional API - noise = general_noise().with_p1_probability(0.001).with_p2_probability(0.01) + noise = general_noise().with_p1(0.001).with_p2(0.01) # Configure entire simulation with method chaining simulation = ( @@ -157,10 +157,10 @@ def example_builder_vs_direct() -> None: print("Using general_noise() with method chaining:") noise_via_builder = ( general_noise() - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) .with_noiseless_gate("H") .with_p1_pauli_model({"X": 0.5, "Y": 0.3, "Z": 0.2}) ) @@ -173,10 +173,10 @@ def example_builder_vs_direct() -> None: noise_equivalent = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) .set_noiseless_gates(["H"]) .with_p1_pauli_model({"X": 0.5, "Y": 0.3, "Z": 0.2}) ) @@ -205,16 +205,12 @@ def example_different_noise_models() -> None: ("Depolarizing", depolarizing_noise().with_probability(0.1)), ( "Custom depolarizing", - depolarizing_noise() - .with_prep_probability(0.01) - .with_meas_probability(0.05) - .with_p1_probability(0.02) - .with_p2_probability(0.03), + depolarizing_noise().with_p_prep(0.01).with_p_meas(0.05).with_p1(0.02).with_p2(0.03), ), ("Biased depolarizing", biased_depolarizing_noise().with_probability(0.1)), ( "General", - general_noise().with_meas_1_probability(0.1), # 10% chance to flip 1->0 + general_noise().with_p_meas_1(0.1), # 10% chance to flip 1->0 ), ] @@ -251,14 +247,14 @@ def example_ion_trap_noise() -> None: general_noise() .with_seed(42) # Ion trap typical parameters - .with_prep_probability(0.001) # State prep error + .with_p_prep(0.001) # State prep error # Single-qubit gates (typically very good) - .with_p1_probability(0.0001) + .with_p1(0.0001) # Two-qubit gates (main error source) - .with_p2_probability(0.003) + .with_p2(0.003) # Measurement (asymmetric for ions) - .with_meas_0_probability(0.001) # Dark state error - .with_meas_1_probability(0.005) # Bright state error + .with_p_meas_0(0.001) # Dark state error + .with_p_meas_1(0.005) # Bright state error ) results = sim(qasm).noise(noise).run(1000) diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 23843ae48..e22878eb2 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -21,9 +21,11 @@ from typing import ( Any, Callable, Generic, + Iterable, Iterator, Mapping, Sequence, + SupportsIndex, TypeVar, overload, ) @@ -2034,6 +2036,27 @@ class WasmForeignObject: # Quantum Error Correction Types # ============================================================================= +class ObservableFlips: + """Which logical observables flipped, with an explicit observable count. + + Returned by both decoder results and sampled ground truth, so a + prediction can be compared to the truth directly. ``flips[i]`` is + bounds-checked against the observable count. + """ + + @property + def mask(self) -> int: ... + def indices(self) -> list[int]: ... + def __len__(self) -> int: ... + def __getitem__(self, index: int) -> bool: ... + def __iter__(self) -> Iterator[bool]: ... + def __eq__(self, other: object) -> bool: ... + def __repr__(self) -> str: ... + @staticmethod + def from_mask(mask: SupportsIndex, num_observables: int) -> ObservableFlips: ... + @staticmethod + def from_bits(bits: Iterable[SupportsIndex]) -> ObservableFlips: ... + class qec: """Fault-tolerance and detector-error-model submodule.""" @@ -2175,6 +2198,8 @@ class qec: def build(self) -> qec.DetectorErrorModel: ... def build_with_source_tracking(self) -> qec.DetectorErrorModel: ... + ObservableFlips = ObservableFlips + class SampleBatch: def __init__( self, @@ -2185,9 +2210,10 @@ class qec: ) -> None: ... @property def num_shots(self) -> int: ... + @property + def num_observables(self) -> int: ... def get_syndrome(self, i: int) -> list[int]: ... - def get_observable_mask(self, i: int) -> int: ... - def get_observable_mask_wide(self, i: int) -> int: ... + def get_observable_flips(self, i: int) -> ObservableFlips: ... def detector_events(self) -> list[list[bool]]: ... def observable_flips(self) -> list[list[bool]]: ... def decode_count(self, dem: str, decoder_type: str = ...) -> int: ... @@ -2535,6 +2561,8 @@ DemSampler = qec.DemSampler class decoders: """Decoder submodule for quantum error correction.""" + ObservableFlips = ObservableFlips + class BpResult: """Result from belief propagation decoders. @@ -2550,7 +2578,6 @@ class decoders: def converged(self) -> bool: ... @property def iterations(self) -> int: ... - def to_list(self) -> list[int]: ... def __repr__(self) -> str: ... def __len__(self) -> int: ... def __getitem__(self, idx: int) -> int: ... @@ -2579,18 +2606,40 @@ class decoders: """Result from MWPM decoders.""" @property - def correction(self) -> list[int]: ... + def observable_flips(self) -> ObservableFlips: ... def __repr__(self) -> str: ... class PyMatchingDecoder: """PyMatching MWPM decoder.""" - def __init__( + def __init__(self, num_nodes: int, num_observables: int = ...) -> None: ... + @staticmethod + def from_dem( + dem: str, + error_probability: float | None = ..., + ) -> decoders.PyMatchingDecoder: + """Build from a detector error model. + + Args: + dem: Detector error model text; its graph dimensions remain structural. + error_probability: Replaces every edge probability and its derived matching weight; better + calibration can improve accuracy without changing asymptotic runtime or memory. + """ + ... + + @staticmethod + def from_dem_with_correlations( + dem: str, + enable_correlations: bool = ..., + ) -> decoders.PyMatchingDecoder: ... + @staticmethod + def from_check_matrix(check_matrix: decoders.CheckMatrix) -> decoders.PyMatchingDecoder: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.MwpmResult: ... + def decode_batch( self, - check_matrix: decoders.CheckMatrix, - weights: list[float] | None = ..., - ) -> None: ... - def decode(self, syndrome: list[int]) -> decoders.MwpmResult: ... + detection_events: list[list[int]], + num_shots: int, + ) -> list[list[int]]: ... def __repr__(self) -> str: ... class FusionBlossomDecoder: @@ -2601,7 +2650,28 @@ class decoders: check_matrix: decoders.CheckMatrix, weights: list[float] | None = ..., ) -> None: ... - def decode(self, syndrome: list[int]) -> decoders.MwpmResult: ... + @staticmethod + def from_dem( + dem: str, + correlated: bool = ..., + solver_type: str | None = ..., + ) -> decoders.FusionBlossomDecoder: + """Build from a detector error model. + + Args: + dem: Detector error model text; node and observable counts are always derived from it. + correlated: Preserves decomposed correlations for accuracy at additional construction/runtime cost. + solver_type: ``"serial"`` is generally faster; ``"legacy"`` supports more graph shapes. + ``None`` preserves the serial default. Parallel requires an unavailable partition configuration. + """ + ... + + def decode_syndrome(self, syndrome: list[int]) -> decoders.MwpmResult: ... + def decode_from_defects( + self, + defects: list[int], + erasures: list[int] | None = ..., + ) -> decoders.MwpmResult: ... def __repr__(self) -> str: ... class BpOsdBuilder: @@ -2617,7 +2687,7 @@ class decoders: >>> from pecos_rslib.decoders import BpOsdBuilder, SparseMatrix >>> H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) >>> decoder = BpOsdBuilder(H, error_rate=0.01).osd_method("osd_cs").osd_order(7).build() - >>> result = decoder.decode([0, 0, 0]) + >>> result = decoder.decode_syndrome([0, 0, 0]) """ def __init__(self, pcm: decoders.SparseMatrix, error_rate: float) -> None: ... @@ -2653,7 +2723,31 @@ class decoders: Created via ``BpOsdBuilder(...).build()``. """ - def decode(self, syndrome: list[int]) -> decoders.BpResult: ... + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int | None = ..., + bp_schedule: str | None = ..., + ms_scaling_factor: float | None = ..., + osd_order: int | None = ..., + random_schedule_seed: int | None = ..., + ) -> decoders.DemAwareDecoder: + """Build BP+OSD from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + bp_schedule: Update order; serial may converge sooner while parallel favors throughput. + ms_scaling_factor: Selects minimum-sum BP and sets its correction factor; tuning can improve + accuracy at negligible runtime cost. ``None`` preserves product-sum BP. + osd_order: Combination-sweep order; larger values can improve accuracy at steep runtime cost. + random_schedule_seed: Makes randomized scheduling reproducible without changing its runtime bound. + """ + ... + + def decode_syndrome(self, syndrome: list[int]) -> decoders.BpResult: ... def __repr__(self) -> str: ... class BpLsdBuilder: @@ -2701,6 +2795,28 @@ class decoders: Created via ``BpLsdBuilder(...).build()``. """ + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int | None = ..., + bp_schedule: str | None = ..., + ms_scaling_factor: float | None = ..., + random_schedule_seed: int | None = ..., + ) -> decoders.DemAwareDecoder: + """Build BP+LSD from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + bp_schedule: Update order; serial may converge sooner while parallel favors throughput. + ms_scaling_factor: Selects minimum-sum BP and sets its correction factor; tuning can improve + accuracy at negligible runtime cost. ``None`` preserves product-sum BP. + random_schedule_seed: Makes randomized scheduling reproducible without changing its runtime bound. + """ + ... + def decode(self, syndrome: list[int]) -> decoders.BpResult: ... def __repr__(self) -> str: ... @@ -2716,7 +2832,7 @@ class decoders: >>> from pecos_rslib.decoders import UnionFindBuilder, SparseMatrix >>> H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) >>> decoder = UnionFindBuilder(H).method("peeling").build() - >>> result = decoder.decode([0, 0, 0]) + >>> result = decoder.decode_syndrome([0, 0, 0]) """ def __init__(self, pcm: decoders.SparseMatrix) -> None: ... @@ -2736,7 +2852,20 @@ class decoders: Created via ``UnionFindBuilder(...).build()``. """ - def decode( + @staticmethod + def from_dem( + dem: str, + method: str | None = ..., + ) -> decoders.DemAwareDecoder: + """Build Union-Find from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + method: ``"peeling"`` is faster on compatible LDPC matrices; ``"inversion"`` is more general. + """ + ... + + def decode_syndrome( self, syndrome: list[int], llrs: list[float] | None = ..., @@ -2748,16 +2877,72 @@ class decoders: """Result from Tesseract decoder.""" @property - def correction(self) -> list[int]: ... + def observable_flips(self) -> ObservableFlips: ... + @property + def cost(self) -> float: ... @property - def weight(self) -> float: ... + def low_confidence(self) -> bool: ... def __repr__(self) -> str: ... class TesseractDecoder: """Tesseract decoder.""" - def __init__(self, dem_string: str) -> None: ... - def decode(self, syndrome: list[int]) -> decoders.TesseractResult: ... + @staticmethod + def from_dem( + dem: str, + preset: str = ..., + det_beam: int | None = ..., + beam_climbing: bool | None = ..., + verbose: bool | None = ..., + no_revisit_dets: bool | None = ..., + pqlimit: int | None = ..., + det_penalty: float | None = ..., + ) -> decoders.TesseractDecoder: + """Build Tesseract from a detector error model and optional preset overrides. + + Args: + dem: Detector error model text; detector and observable counts are derived from it. + preset: Baseline accuracy/runtime profile: ``"default"``, ``"fast"``, or ``"accurate"``. + det_beam: Larger detector beams can improve accuracy at increased runtime and memory cost. + beam_climbing: Enables a faster search heuristic that can alter the accuracy/runtime balance. + verbose: Enables diagnostic output without changing accuracy or memory use. + no_revisit_dets: Avoids revisits for lower runtime, with a possible accuracy cost. + pqlimit: Priority-queue cap; smaller values bound memory at a possible accuracy cost. + det_penalty: Larger penalties prune search more aggressively for speed at possible accuracy cost. + """ + ... + + def decode_from_defects(self, detections: list[int]) -> decoders.TesseractResult: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.TesseractResult: ... + def decode_batch( + self, + syndromes: list[list[int]], + num_workers: int | None = ..., + ) -> list[decoders.TesseractResult]: ... + def __repr__(self) -> str: ... + + class DemAwareResult: + """Result from a DEM-aware decoder.""" + + @property + def observable_flips(self) -> ObservableFlips: ... + @property + def converged(self) -> bool: ... + @property + def iterations(self) -> int: ... + def __repr__(self) -> str: ... + + class DemAwareDecoder: + """DEM-aware wrapper over a check-matrix decoder.""" + + @staticmethod + def from_dem( + dem: str, + decoder_type: str = ..., + error_rate: float | None = ..., + max_iter: int = ..., + ) -> decoders.DemAwareDecoder: ... + def decode_syndrome(self, syndrome: list[int]) -> decoders.DemAwareResult: ... def __repr__(self) -> str: ... class RelayBpBuilder: @@ -2825,6 +3010,25 @@ class decoders: Created via ``RelayBpBuilder(...).build()``. """ + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int | None = ..., + alpha: float | None = ..., + seed: int | None = ..., + ) -> decoders.DemAwareDecoder: + """Build Relay BP from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + alpha: Min-sum scaling factor; tuning can improve accuracy at negligible runtime cost. + seed: Makes relay sampling reproducible without increasing its runtime bound. + """ + ... + def decode(self, syndrome: list[int]) -> decoders.BpResult: """Decode a syndrome vector. @@ -2893,6 +3097,23 @@ class decoders: Created via ``MinSumBpBuilder(...).build()``. """ + @staticmethod + def from_dem( + dem: str, + error_rate: float | None = ..., + max_iter: int | None = ..., + alpha: float | None = ..., + ) -> decoders.DemAwareDecoder: + """Build min-sum BP from a detector error model. + + Args: + dem: Detector error model text; check-matrix dimensions are derived from it. + error_rate: Uniform prior override; mismatch can reduce accuracy with little runtime effect. + max_iter: BP iteration cap; larger values may improve convergence but increase runtime. + alpha: Min-sum scaling factor; tuning can improve accuracy at negligible runtime cost. + """ + ... + def decode(self, syndrome: list[int]) -> decoders.BpResult: """Decode a syndrome vector. diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index ac9f51ac5..252676726 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -40,6 +40,21 @@ use ndarray::{Array1, Array2}; use pyo3::prelude::*; +use crate::observable_flips_bindings::PyObservableFlips; + +fn explicit_decode_attribute_error(class_name: &str, name: &str) -> PyErr { + if name == "decode" { + pyo3::exceptions::PyAttributeError::new_err(format!( + "{class_name} has no attribute 'decode'; use decode_syndrome(...) for a dense vector \ + or decode_from_defects(...) for sparse detector indices" + )) + } else { + pyo3::exceptions::PyAttributeError::new_err(format!( + "'{class_name}' object has no attribute '{name}'" + )) + } +} + // ============================================================================= // Common Result Types // ============================================================================= @@ -50,15 +65,15 @@ use pyo3::prelude::*; /// /// # Attributes /// -/// * `correction` - The decoded correction/observable flip (list of 0/1 for each observable) +/// * `observable_flips` - The decoded observable flips /// * `weight` - Total weight of the matching (lower is better) /// /// # Example /// /// ```python -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// if result.weight < threshold: -/// apply_correction(result.correction) +/// apply_correction(result.observable_flips) /// ``` #[pyclass( name = "MwpmResult", @@ -76,22 +91,15 @@ pub struct PyMwpmResult { #[pymethods] impl PyMwpmResult { - /// The decoded correction (observable flips) as a Python list. + /// The decoded observable flips with their intrinsic observable count. #[getter] - fn correction(&self) -> Vec { - self.correction_data.iter().map(|&x| i32::from(x)).collect() - } - - /// Get the correction as a list (alias for correction attribute). - /// - /// This mirrors `PyMatching`'s `decode()` return value. - fn to_list(&self) -> Vec { - self.correction() + fn observable_flips(&self) -> PyObservableFlips { + PyObservableFlips::from_u8_bits(&self.correction_data) } fn __repr__(&self) -> String { format!( - "MwpmResult(correction={:?}, weight={:.4})", + "MwpmResult(observable_flips={:?}, weight={:.4})", self.correction_data, self.weight ) } @@ -112,10 +120,13 @@ impl PyMwpmResult { /// /// # Attributes /// -/// * `decoding` - The decoded error vector +/// * `decoding` - The decoded error vector, indexed by error mechanism, not observable /// * `converged` - Whether BP converged before max iterations /// * `iterations` - Number of BP iterations performed /// +/// Per-observable results come from the decoders' `from_dem` constructors, +/// which return `DemAwareResult` from `decode_syndrome`. +/// /// # Example /// /// ```python @@ -143,16 +154,15 @@ pub struct PyBpResult { #[pymethods] impl PyBpResult { /// The decoded error vector as a Python list. + /// + /// This vector is indexed by error mechanism, not by observable. + /// Per-observable results come from the `from_dem` constructors, whose + /// `decode_syndrome` method returns `DemAwareResult`. #[getter] fn decoding(&self) -> Vec { self.decoding_data.iter().map(|&x| i32::from(x)).collect() } - /// Get the decoding as a list. - fn to_list(&self) -> Vec { - self.decoding() - } - fn __repr__(&self) -> String { format!( "BpResult(converged={}, iterations={}, decoding_len={})", @@ -183,6 +193,19 @@ use pecos_decoders::{ PyMatchingConfig as RustPyMatchingConfig, PyMatchingDecoder as RustPyMatchingDecoder, }; +#[derive(Debug, Clone, Copy, Default, PartialEq)] +struct PyMatchingDemConfig { + error_probability: Option, +} + +fn pymatching_config(error_probability: Option) -> PyMatchingDemConfig { + let mut config = PyMatchingDemConfig::default(); + if let Some(error_probability) = error_probability { + config.error_probability = Some(error_probability); + } + config +} + /// Sparse check matrix for MWPM decoders. /// /// Represents a parity check matrix H where each column corresponds to an error @@ -334,8 +357,8 @@ impl PyCheckMatrix { /// /// ```python /// syndrome = [1, 0] # Detection events -/// result = decoder.decode(syndrome) -/// print(f"Correction: {result.correction}, Weight: {result.weight}") +/// result = decoder.decode_syndrome(syndrome) +/// print(f"Observable flips: {list(result.observable_flips)}, Weight: {result.weight}") /// ``` // Note: unsendable because contains FFI pointers (cxx UniquePtr) #[pyclass( @@ -427,6 +450,8 @@ impl PyPyMatchingDecoder { /// # Arguments /// /// * `dem` - Detector error model string in Stim format + /// * `error_probability` - Replace every edge probability and its derived matching weight; + /// closer calibration can improve decoding accuracy without changing asymptotic runtime /// /// # Example /// @@ -435,8 +460,15 @@ impl PyPyMatchingDecoder { /// decoder = PyMatchingDecoder.from_dem(dem) /// ``` #[staticmethod] - fn from_dem(dem: &str) -> PyResult { - RustPyMatchingDecoder::from_dem(dem) + #[pyo3(signature = (dem, error_probability=None))] + fn from_dem(dem: &str, error_probability: Option) -> PyResult { + let config = pymatching_config(error_probability); + let inner = if let Some(error_probability) = config.error_probability { + RustPyMatchingDecoder::from_dem_with_error_probability(dem, error_probability) + } else { + RustPyMatchingDecoder::from_dem(dem) + }; + inner .map(|inner| Self { inner }) .map_err(|e| PyErr::new::(e.to_string())) } @@ -504,7 +536,7 @@ impl PyPyMatchingDecoder { /// Decode a syndrome to find the most likely error. /// - /// This mirrors `PyMatching`'s `Matching.decode()`. + /// This mirrors `PyMatching`'s `Matching.decode()` with an explicit dense encoding name. /// /// # Arguments /// @@ -512,16 +544,16 @@ impl PyPyMatchingDecoder { /// /// # Returns /// - /// `MwpmResult` with correction vector and matching weight. + /// `MwpmResult` with observable flips and matching weight. /// /// # Example /// /// ```python /// syndrome = [1, 0, 1, 0] - /// result = decoder.decode(syndrome) - /// correction = result.correction # Observable flips to apply + /// result = decoder.decode_syndrome(syndrome) + /// observable_flips = result.observable_flips /// ``` - fn decode(&mut self, syndrome: Vec) -> PyResult { + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { self.inner .decode(&syndrome) .map(|result| PyMwpmResult { @@ -533,7 +565,7 @@ impl PyPyMatchingDecoder { /// Decode a batch of syndromes at once. /// - /// Much faster than calling `decode()` in a Python loop -- the entire batch + /// Much faster than calling `decode_syndrome()` in a Python loop -- the entire batch /// is processed in Rust with no per-shot Python overhead. /// /// # Arguments @@ -544,8 +576,8 @@ impl PyPyMatchingDecoder { /// # Returns /// /// List of observable predictions (one per shot), where each prediction - /// is a list of 0/1 values (one per observable). Use `observables_mask` - /// property on each element or just check index 0 for single-observable codes. + /// is a list of 0/1 values (one per observable). Check index 0 for + /// single-observable codes. /// /// # Example /// @@ -607,6 +639,10 @@ impl PyPyMatchingDecoder { self.inner.num_observables() ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("PyMatchingDecoder", name)) + } } // ============================================================================= @@ -619,6 +655,34 @@ use pecos_decoders::{ StandardCode as RustStandardCode, SyndromeData as RustSyndromeData, }; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FusionBlossomDemConfig { + correlated: bool, + solver_type: RustSolverType, +} + +fn fusion_blossom_config( + correlated: bool, + solver_type: Option<&str>, +) -> Result { + let mut config = FusionBlossomDemConfig { + correlated, + solver_type: RustSolverType::Serial, + }; + match solver_type.unwrap_or("serial") { + "legacy" => config.solver_type = RustSolverType::Legacy, + "serial" => {} + "parallel" => { + return Err( + "solver_type 'parallel' requires a partition configuration, which from_dem does not accept" + .to_string(), + ); + } + _ => return Err("solver_type must be 'legacy' or 'serial'".to_string()), + } + Ok(config) +} + /// Fusion Blossom MWPM decoder. /// /// Pure Rust implementation of minimum-weight perfect matching. @@ -646,7 +710,7 @@ use pecos_decoders::{ /// # Decoding /// /// ```python -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// decoder.clear() # Reset for next shot (efficient reuse) /// ``` #[pyclass(name = "FusionBlossomDecoder", module = "pecos_rslib.decoders")] @@ -702,6 +766,35 @@ impl PyFusionBlossomDecoder { /// H = [[1, 1, 0], [0, 1, 1]] /// decoder = FusionBlossomDecoder.from_check_matrix(H) /// ``` + /// Create a decoder from a Detector Error Model. + /// + /// # Arguments + /// + /// * `dem` - Detector error model string in Stim format + /// * `correlated` - Exploit X-Z correlations from decomposed mechanisms + /// * `solver_type` - "serial" is usually faster while "legacy" can handle + /// more graph shapes; neither changes the DEM-derived memory footprint + /// + /// # Example + /// + /// ```python + /// decoder = FusionBlossomDecoder.from_dem(dem_string) + /// ``` + #[staticmethod] + #[pyo3(signature = (dem, correlated=false, solver_type=None))] + fn from_dem(dem: &str, correlated: bool, solver_type: Option<&str>) -> PyResult { + let config = fusion_blossom_config(correlated, solver_type) + .map_err(PyErr::new::)?; + let inner = if config.correlated { + RustFusionBlossomDecoder::from_dem_correlated_with_solver_type(dem, config.solver_type) + } else { + RustFusionBlossomDecoder::from_dem_with_solver_type(dem, config.solver_type) + }; + inner + .map(|inner| Self { inner }) + .map_err(|e| PyErr::new::(e.to_string())) + } + #[staticmethod] #[pyo3(signature = (check_matrix, weights=None, num_observables=None))] fn from_check_matrix( @@ -839,8 +932,8 @@ impl PyFusionBlossomDecoder { /// /// # Returns /// - /// `MwpmResult` with correction and weight. - fn decode(&mut self, syndrome: Vec) -> PyResult { + /// `MwpmResult` with observable flips and weight. + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); self.inner .decode(&arr.view()) @@ -904,6 +997,13 @@ impl PyFusionBlossomDecoder { self.inner.num_edges() ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error( + "FusionBlossomDecoder", + name, + )) + } } // ============================================================================= @@ -1019,13 +1119,287 @@ fn parse_bp_method(s: &str) -> PyResult { /// Parse a BP schedule string into the Rust enum. fn parse_bp_schedule(s: &str) -> PyResult { + bp_schedule(s).map_err(PyErr::new::) +} + +fn bp_schedule(s: &str) -> Result { match s { "parallel" => Ok(RustBpSchedule::Parallel), "serial" => Ok(RustBpSchedule::Serial), - _ => Err(PyErr::new::( - "schedule must be 'parallel' or 'serial'", - )), + "serial_relative" => Ok(RustBpSchedule::SerialRelative), + _ => Err("bp_schedule must be 'parallel', 'serial', or 'serial_relative'".to_string()), + } +} + +fn uf_method(s: &str) -> Result { + match s { + "inversion" => Ok(RustUfMethod::Inversion), + "peeling" => Ok(RustUfMethod::Peeling), + _ => Err("method must be 'inversion' or 'peeling'".to_string()), + } +} + +fn optional_usize(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + usize::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be a non-negative integer no greater than {}", + usize::MAX + )) + }) + }) + .transpose() +} + +fn optional_u16(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + u16::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be an integer between 0 and {}", + u16::MAX + )) + }) + }) + .transpose() +} + +fn optional_i32(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + i32::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be an integer between {} and {}", + i32::MIN, + i32::MAX + )) + }) + }) + .transpose() +} + +fn optional_u64(value: Option, parameter: &str) -> PyResult> { + value + .map(|value| { + u64::try_from(value).map_err(|_| { + PyErr::new::(format!( + "{parameter} must be an integer between 0 and {}", + u64::MAX + )) + }) + }) + .transpose() +} + +const DEFAULT_DEM_MAX_ITER: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct BpOsdDemConfig { + error_rate: Option, + max_iter: usize, + bp_method: RustBpMethod, + bp_schedule: RustBpSchedule, + ms_scaling_factor: f64, + osd_method: RustOsdMethod, + osd_order: usize, + random_schedule_seed: Option, +} + +impl Default for BpOsdDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + bp_method: RustBpMethod::ProductSum, + bp_schedule: RustBpSchedule::Parallel, + ms_scaling_factor: 1.0, + osd_method: RustOsdMethod::Osd0, + osd_order: 0, + random_schedule_seed: None, + } + } +} + +fn bp_osd_config( + error_rate: Option, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + osd_order: Option, + random_schedule_seed: Option, +) -> Result { + let mut config = BpOsdDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); + } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(bp_schedule) = bp_schedule { + config.bp_schedule = self::bp_schedule(bp_schedule)?; + } + if let Some(ms_scaling_factor) = ms_scaling_factor { + config.bp_method = RustBpMethod::MinimumSum; + config.ms_scaling_factor = ms_scaling_factor; + } + if let Some(osd_order) = osd_order { + if osd_order > 0 { + config.osd_method = RustOsdMethod::OsdCs; + } + config.osd_order = osd_order; + } + if let Some(random_schedule_seed) = random_schedule_seed { + config.random_schedule_seed = Some(random_schedule_seed); + } + Ok(config) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct BpLsdDemConfig { + error_rate: Option, + max_iter: usize, + bp_method: RustBpMethod, + bp_schedule: RustBpSchedule, + ms_scaling_factor: f64, + random_schedule_seed: Option, +} + +impl Default for BpLsdDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + bp_method: RustBpMethod::ProductSum, + bp_schedule: RustBpSchedule::Parallel, + ms_scaling_factor: 1.0, + random_schedule_seed: None, + } + } +} + +fn bp_lsd_config( + error_rate: Option, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + random_schedule_seed: Option, +) -> Result { + let mut config = BpLsdDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); + } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(bp_schedule) = bp_schedule { + config.bp_schedule = self::bp_schedule(bp_schedule)?; + } + if let Some(ms_scaling_factor) = ms_scaling_factor { + config.bp_method = RustBpMethod::MinimumSum; + config.ms_scaling_factor = ms_scaling_factor; + } + if let Some(random_schedule_seed) = random_schedule_seed { + config.random_schedule_seed = Some(random_schedule_seed); + } + Ok(config) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct UnionFindDemConfig { + method: RustUfMethod, +} + +impl Default for UnionFindDemConfig { + fn default() -> Self { + Self { + method: RustUfMethod::Inversion, + } + } +} + +fn union_find_config(method: Option<&str>) -> Result { + let mut config = UnionFindDemConfig::default(); + if let Some(method) = method { + config.method = uf_method(method)?; + } + Ok(config) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct RelayBpDemConfig { + error_rate: Option, + max_iter: usize, + alpha: Option, + seed: u64, +} + +impl Default for RelayBpDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + alpha: None, + seed: 0, + } + } +} + +fn relay_bp_config( + error_rate: Option, + max_iter: Option, + alpha: Option, + seed: Option, +) -> RelayBpDemConfig { + let mut config = RelayBpDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); + } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(alpha) = alpha { + config.alpha = Some(alpha); + } + if let Some(seed) = seed { + config.seed = seed; + } + config +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct MinSumBpDemConfig { + error_rate: Option, + max_iter: usize, + alpha: Option, +} + +impl Default for MinSumBpDemConfig { + fn default() -> Self { + Self { + error_rate: None, + max_iter: DEFAULT_DEM_MAX_ITER, + alpha: None, + } + } +} + +fn min_sum_bp_config( + error_rate: Option, + max_iter: Option, + alpha: Option, +) -> MinSumBpDemConfig { + let mut config = MinSumBpDemConfig::default(); + if let Some(error_rate) = error_rate { + config.error_rate = Some(error_rate); } + if let Some(max_iter) = max_iter { + config.max_iter = max_iter; + } + if let Some(alpha) = alpha { + config.alpha = Some(alpha); + } + config } /// Parse an OSD method string into the Rust enum. @@ -1052,7 +1426,7 @@ fn parse_osd_method(s: &str) -> PyResult { /// /// H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) /// decoder = BpOsdBuilder(H, error_rate=0.1).osd_method("osd_cs").osd_order(7).build() -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// ``` #[pyclass(name = "BpOsdBuilder", module = "pecos_rslib.decoders")] pub struct PyBpOsdBuilder { @@ -1160,6 +1534,38 @@ pub struct PyBpOsdDecoder { #[pymethods] impl PyBpOsdDecoder { + /// Create a DEM-aware BP+OSD decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `bp_schedule` - Update ordering; serial may converge sooner while parallel favors throughput + /// * `ms_scaling_factor` - Select minimum-sum BP and set its correction factor; + /// tuning can improve accuracy with negligible runtime cost + /// * `osd_order` - Combination-sweep order; larger values can improve accuracy at steep runtime cost + /// * `random_schedule_seed` - Reproducible randomized scheduling; changes exploration, not its runtime bound + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, bp_schedule=None, ms_scaling_factor=None, osd_order=None, random_schedule_seed=None))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + osd_order: Option, + random_schedule_seed: Option, + ) -> PyResult { + let config = bp_osd_config( + error_rate, + optional_usize(max_iter, "max_iter")?, + bp_schedule, + ms_scaling_factor, + optional_usize(osd_order, "osd_order")?, + optional_i32(random_schedule_seed, "random_schedule_seed")?, + ) + .map_err(PyErr::new::)?; + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::BpOsd(config)) + } + /// Decode a syndrome. /// /// # Arguments @@ -1169,7 +1575,7 @@ impl PyBpOsdDecoder { /// # Returns /// /// `BpResult` with decoding, convergence status, and iteration count. - fn decode(&mut self, syndrome: Vec) -> PyResult { + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); self.inner .decode(&arr.view()) @@ -1185,6 +1591,10 @@ impl PyBpOsdDecoder { fn __repr__(&self) -> String { "BpOsdDecoder(...)".to_string() } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("BpOsdDecoder", name)) + } } /// Builder for BP+LSD decoder. @@ -1299,6 +1709,35 @@ pub struct PyBpLsdDecoder { #[pymethods] impl PyBpLsdDecoder { + /// Create a DEM-aware BP+LSD decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `bp_schedule` - Update ordering; serial may converge sooner while parallel favors throughput + /// * `ms_scaling_factor` - Select minimum-sum BP and set its correction factor; + /// tuning can improve accuracy with negligible runtime cost + /// * `random_schedule_seed` - Reproducible randomized scheduling; changes exploration, not its runtime bound + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, bp_schedule=None, ms_scaling_factor=None, random_schedule_seed=None))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: Option, + bp_schedule: Option<&str>, + ms_scaling_factor: Option, + random_schedule_seed: Option, + ) -> PyResult { + let config = bp_lsd_config( + error_rate, + optional_usize(max_iter, "max_iter")?, + bp_schedule, + ms_scaling_factor, + optional_i32(random_schedule_seed, "random_schedule_seed")?, + ) + .map_err(PyErr::new::)?; + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::BpLsd(config)) + } + /// Decode a syndrome. fn decode(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); @@ -1330,7 +1769,7 @@ impl PyBpLsdDecoder { /// /// H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) /// decoder = UnionFindBuilder(H).method("peeling").build() -/// result = decoder.decode(syndrome) +/// result = decoder.decode_syndrome(syndrome) /// ``` #[pyclass(name = "UnionFindBuilder", module = "pecos_rslib.decoders")] pub struct PyUnionFindBuilder { @@ -1395,6 +1834,17 @@ pub struct PyUnionFindDecoder { #[pymethods] impl PyUnionFindDecoder { + /// Create a DEM-aware Union-Find decoder from a Detector Error Model. + /// + /// * `method` - "peeling" is faster on compatible LDPC matrices; "inversion" is more general + #[staticmethod] + #[pyo3(signature = (dem, method=None))] + fn from_dem(dem: &str, method: Option<&str>) -> PyResult { + let config = + union_find_config(method).map_err(PyErr::new::)?; + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::UnionFind(config)) + } + /// Decode a syndrome. /// /// # Arguments @@ -1403,7 +1853,7 @@ impl PyUnionFindDecoder { /// * `llrs` - Optional log-likelihood ratios for soft information /// * `bits_per_step` - Bits to grow per step (0 = all at once) #[pyo3(signature = (syndrome, llrs=None, bits_per_step=0))] - fn decode( + fn decode_syndrome( &mut self, syndrome: Vec, llrs: Option>, @@ -1426,6 +1876,10 @@ impl PyUnionFindDecoder { fn __repr__(&self) -> String { "UnionFindDecoder(...)".to_string() } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("UnionFindDecoder", name)) + } } // ============================================================================= @@ -1436,11 +1890,49 @@ use pecos_decoders::{ TesseractConfig as RustTesseractConfig, TesseractDecoder as RustTesseractDecoder, }; +fn tesseract_config( + preset: &str, + det_beam: Option, + beam_climbing: Option, + verbose: Option, + no_revisit_dets: Option, + pqlimit: Option, + det_penalty: Option, +) -> Result { + let mut config = match preset { + "fast" => RustTesseractConfig::fast(), + "accurate" => RustTesseractConfig::accurate(), + "default" => RustTesseractConfig::default(), + _ => return Err("preset must be 'default', 'fast', or 'accurate'".to_string()), + }; + + if let Some(det_beam) = det_beam { + config.det_beam = det_beam; + } + if let Some(beam_climbing) = beam_climbing { + config.beam_climbing = beam_climbing; + } + if let Some(verbose) = verbose { + config.verbose = verbose; + } + if let Some(no_revisit_dets) = no_revisit_dets { + config.no_revisit_dets = no_revisit_dets; + } + if let Some(pqlimit) = pqlimit { + config.pqlimit = pqlimit; + } + if let Some(det_penalty) = det_penalty { + config.det_penalty = det_penalty; + } + + Ok(config) +} + /// Result from Tesseract decoder. /// /// # Attributes /// -/// * `observables_mask` - Bitwise XOR of observables affected by predicted errors +/// * `observable_flips` - Observables affected by predicted errors /// * `cost` - Total cost of the solution /// * `low_confidence` - Whether this is a low-confidence prediction #[pyclass( @@ -1450,27 +1942,29 @@ use pecos_decoders::{ )] #[derive(Clone)] pub struct PyTesseractResult { - #[pyo3(get)] observables_mask: u64, #[pyo3(get)] cost: f64, #[pyo3(get)] low_confidence: bool, + num_observables: usize, } #[pymethods] impl PyTesseractResult { - /// Get the observable predictions as a list of bits. - fn observable_bits(&self, num_observables: usize) -> Vec { - (0..num_observables) - .map(|i| ((self.observables_mask >> i) & 1) as i32) - .collect() + /// The decoded observable flips with the decoder's observable count. + #[getter] + fn observable_flips(&self) -> PyObservableFlips { + PyObservableFlips::from_mask_value( + pecos_decoder_core::obs_mask::ObsMask::from_u64(self.observables_mask), + self.num_observables, + ) } fn __repr__(&self) -> String { format!( - "TesseractResult(observables_mask={}, cost={:.4}, low_confidence={})", - self.observables_mask, self.cost, self.low_confidence + "TesseractResult(observable_flips=ObservableFlips(num_observables={}, mask={}), cost={:.4}, low_confidence={})", + self.num_observables, self.observables_mask, self.cost, self.low_confidence ) } } @@ -1502,8 +1996,8 @@ impl PyTesseractResult { /// ```python /// # Detection events as list of detector indices that fired /// detection_indices = [0, 2] -/// result = decoder.decode(detection_indices) -/// print(f"Observable mask: {result.observables_mask}, Cost: {result.cost}") +/// result = decoder.decode_from_defects(detection_indices) +/// print(f"Observable mask: {result.observable_flips.mask}, Cost: {result.cost}") /// ``` #[pyclass(name = "TesseractDecoder", module = "pecos_rslib.decoders", unsendable)] pub struct PyTesseractDecoder { @@ -1522,7 +2016,10 @@ impl PyTesseractDecoder { /// * `preset` - Configuration preset: "default", "fast", or "accurate" /// * `det_beam` - Detector beam size (default: `u16::MAX` for infinite) /// * `beam_climbing` - Enable beam climbing heuristic - /// * `verbose` - Enable verbose output + /// * `verbose` - Enable verbose output; no accuracy/runtime tradeoff when disabled + /// * `no_revisit_dets` - Avoid revisiting detectors, reducing runtime at possible accuracy cost + /// * `pqlimit` - Priority queue entry cap; smaller values bound memory at possible accuracy cost + /// * `det_penalty` - Search penalty for adding detectors; larger values prune more aggressively /// /// # Example /// @@ -1533,28 +2030,29 @@ impl PyTesseractDecoder { /// decoder = TesseractDecoder.from_dem(dem, preset="fast") /// ``` #[staticmethod] - #[pyo3(signature = (dem, preset="default", det_beam=None, beam_climbing=None, verbose=false))] + #[pyo3(signature = (dem, preset="default", det_beam=None, beam_climbing=None, verbose=None, no_revisit_dets=None, pqlimit=None, det_penalty=None))] fn from_dem( dem: &str, preset: &str, - det_beam: Option, + det_beam: Option, beam_climbing: Option, - verbose: bool, + verbose: Option, + no_revisit_dets: Option, + pqlimit: Option, + det_penalty: Option, ) -> PyResult { - let mut config = match preset { - "fast" => RustTesseractConfig::fast(), - "accurate" => RustTesseractConfig::accurate(), - _ => RustTesseractConfig::default(), - }; - - // Override with explicit parameters - if let Some(beam) = det_beam { - config.det_beam = beam; - } - if let Some(climbing) = beam_climbing { - config.beam_climbing = climbing; - } - config.verbose = verbose; + let det_beam = optional_u16(det_beam, "det_beam")?; + let pqlimit = optional_usize(pqlimit, "pqlimit")?; + let config = tesseract_config( + preset, + det_beam, + beam_climbing, + verbose, + no_revisit_dets, + pqlimit, + det_penalty, + ) + .map_err(PyErr::new::)?; let dem_string = dem.to_string(); RustTesseractDecoder::new(dem, config.clone()) @@ -1574,17 +2072,18 @@ impl PyTesseractDecoder { /// /// # Returns /// - /// `TesseractResult` with observables mask, cost, and confidence info. + /// `TesseractResult` with observable flips, cost, and confidence info. /// /// # Example /// /// ```python /// # Detectors 0 and 2 fired - /// result = decoder.decode([0, 2]) - /// print(f"Observable prediction: {result.observable_bits(1)}") + /// result = decoder.decode_from_defects([0, 2]) + /// print(f"Observable prediction: {list(result.observable_flips)}") /// ``` - fn decode(&mut self, detections: Vec) -> PyResult { + fn decode_from_defects(&mut self, detections: Vec) -> PyResult { let detections_arr = ndarray::Array1::from_vec(detections); + let num_observables = self.inner.num_observables(); self.inner .decode_detections(&detections_arr.view()) @@ -1592,6 +2091,7 @@ impl PyTesseractDecoder { observables_mask: result.observables_mask, cost: result.cost, low_confidence: result.low_confidence, + num_observables, }) .map_err(|e| PyErr::new::(e.to_string())) } @@ -1613,7 +2113,7 @@ impl PyTesseractDecoder { .filter_map(|(i, &val)| if val != 0 { Some(i as u64) } else { None }) .collect(); - self.decode(detections) + self.decode_from_defects(detections) } /// Decode a batch of syndromes in parallel using multiple decoder instances. @@ -1647,6 +2147,7 @@ impl PyTesseractDecoder { let dem_str = &self.dem_string; let config = &self.config; + let num_observables = self.inner.num_observables(); let results: Result, _> = pool.install(|| { syndromes @@ -1682,6 +2183,7 @@ impl PyTesseractDecoder { observables_mask: r.observables_mask, cost: r.cost, low_confidence: r.low_confidence, + num_observables, }) .map_err(|e| e.to_string()) }) @@ -1718,6 +2220,10 @@ impl PyTesseractDecoder { self.inner.num_observables() ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("TesseractDecoder", name)) + } } // ============================================================================= @@ -1970,6 +2476,30 @@ pub struct PyRelayBpDecoder { #[pymethods] impl PyRelayBpDecoder { + /// Create a DEM-aware Relay BP decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `alpha` - Min-sum scaling factor; tuning can improve accuracy with negligible runtime cost + /// * `seed` - Reproducible relay sampling; changes exploration without increasing its runtime bound + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, alpha=None, seed=None))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: Option, + alpha: Option, + seed: Option, + ) -> PyResult { + let config = relay_bp_config( + error_rate, + optional_usize(max_iter, "max_iter")?, + alpha, + optional_u64(seed, "seed")?, + ); + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::RelayBp(config)) + } + /// Decode a syndrome. /// /// # Arguments @@ -2133,6 +2663,23 @@ pub struct PyMinSumBpDecoder { #[pymethods] impl PyMinSumBpDecoder { + /// Create a DEM-aware min-sum BP decoder from a Detector Error Model. + /// + /// * `error_rate` - Uniform prior override; model mismatch can reduce accuracy, with little runtime effect + /// * `max_iter` - BP iteration cap; larger values can improve convergence but increase runtime + /// * `alpha` - Min-sum scaling factor; tuning can improve accuracy with negligible runtime cost + #[staticmethod] + #[pyo3(signature = (dem, error_rate=None, max_iter=None, alpha=None))] + fn from_dem( + dem: &str, + error_rate: Option, + max_iter: Option, + alpha: Option, + ) -> PyResult { + let config = min_sum_bp_config(error_rate, optional_usize(max_iter, "max_iter")?, alpha); + PyDemAwareDecoder::from_dem_with_config(dem, DemDecoderConfig::MinSumBp(config)) + } + /// Decode a syndrome. /// /// # Arguments @@ -2190,11 +2737,32 @@ enum InnerDecoder { MinSumBp(Box), } +#[derive(Debug, Clone, Copy, PartialEq)] +enum DemDecoderConfig { + BpOsd(BpOsdDemConfig), + BpLsd(BpLsdDemConfig), + UnionFind(UnionFindDemConfig), + RelayBp(RelayBpDemConfig), + MinSumBp(MinSumBpDemConfig), +} + +impl DemDecoderConfig { + fn error_rate(self) -> Option { + match self { + Self::BpOsd(config) => config.error_rate, + Self::BpLsd(config) => config.error_rate, + Self::UnionFind(_) => None, + Self::RelayBp(config) => config.error_rate, + Self::MinSumBp(config) => config.error_rate, + } + } +} + /// DEM-aware decoder that wraps a check-matrix decoder. /// /// Parses a DEM string, extracts the check matrix and observable matrix, /// creates the inner decoder, and provides `decode_syndrome()` that returns -/// an `observables_mask` -- the same interface as `PyMatching` and Tesseract. +/// `observable_flips` -- the same interface as `PyMatching` and Tesseract. /// /// # Example /// @@ -2203,7 +2771,7 @@ enum InnerDecoder { /// /// decoder = DemAwareDecoder.from_dem(dem_string, decoder_type="bp_osd") /// result = decoder.decode_syndrome([0, 1, 1, 0]) -/// print(f"Observable prediction: {result.observables_mask}") +/// print(f"Observable prediction: {result.observable_flips}") /// ``` #[pyclass(name = "DemAwareDecoder", module = "pecos_rslib.decoders", unsendable)] pub struct PyDemAwareDecoder { @@ -2211,156 +2779,113 @@ pub struct PyDemAwareDecoder { dem_check_matrix: DemCheckMatrix, } -/// Result from a DEM-aware decoder. -#[pyclass( - name = "DemAwareResult", - module = "pecos_rslib.decoders", - skip_from_py_object -)] -#[derive(Clone)] -pub struct PyDemAwareResult { - /// Bitmask of predicted observable flips. - #[pyo3(get)] - pub observables_mask: u64, - /// Whether the BP decoder converged. - #[pyo3(get)] - pub converged: bool, - /// Number of BP iterations used. - #[pyo3(get)] - pub iterations: usize, -} +impl PyDemAwareDecoder { + fn parse_dem(dem: &str) -> PyResult { + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; -#[pymethods] -impl PyDemAwareResult { - fn __repr__(&self) -> String { - format!( - "DemAwareResult(observables_mask={}, converged={}, iterations={})", - self.observables_mask, self.converged, self.iterations - ) + if dcm.num_mechanisms == 0 { + return Err(PyErr::new::( + "DEM contains no error mechanisms", + )); + } + + Ok(dcm) } -} -#[pymethods] -impl PyDemAwareDecoder { - /// Create a DEM-aware decoder from a DEM string. - /// - /// # Arguments - /// - /// * `dem` - DEM string in Stim format - /// * `decoder_type` - One of "`bp_osd`", "`bp_lsd`", "`union_find`", "`relay_bp`", "`min_sum_bp`" - /// * `error_rate` - Override error rate for BP priors (default: use DEM probabilities) - /// * `max_iter` - Maximum BP iterations (default: 100) - /// - /// # Example - /// - /// ```python - /// decoder = DemAwareDecoder.from_dem(dem, decoder_type="bp_osd") - /// ``` - #[staticmethod] - #[pyo3(signature = (dem, decoder_type="bp_osd", error_rate=None, max_iter=100))] - fn from_dem( - dem: &str, - decoder_type: &str, - error_rate: Option, - max_iter: usize, - ) -> PyResult { - let dcm = DemCheckMatrix::from_dem_str(dem) - .map_err(|e| PyErr::new::(e.to_string()))?; - - if dcm.num_mechanisms == 0 { - return Err(PyErr::new::( - "DEM contains no error mechanisms", - )); - } + fn from_dem_with_config(dem: &str, config: DemDecoderConfig) -> PyResult { + let dcm = Self::parse_dem(dem)?; + Self::from_dem_check_matrix_with_config(dcm, config) + } - // Error priors: use per-mechanism probabilities from DEM, or uniform override - let priors: Vec = if let Some(p) = error_rate { + fn from_dem_check_matrix_with_config( + dcm: DemCheckMatrix, + config: DemDecoderConfig, + ) -> PyResult { + // Error priors: use per-mechanism probabilities from DEM, or uniform override. + let priors: Vec = if let Some(p) = config.error_rate() { vec![p; dcm.num_mechanisms] } else { dcm.error_priors.clone() }; - // Build the check matrix in the two formats decoders need: - // SparseMatrix for LDPC decoders, Array2 view for Relay/MinSum. + // The check matrix shape and observable map are structural properties of + // the DEM and are deliberately never accepted as caller overrides. let sparse_h = RustSparseMatrix::from_dense(&dcm.check_matrix.view()); - let inner = match decoder_type { - "bp_osd" => { + let inner = match config { + DemDecoderConfig::BpOsd(config) => { let decoder = RustBpOsdDecoder::new( &sparse_h, - None, // error_rate - Some(&priors), // error_channel - max_iter, - RustBpMethod::ProductSum, - RustBpSchedule::Parallel, - 1.0, // ms_scaling_factor - RustOsdMethod::Osd0, - 0, // osd_order - RustInputVectorType::Syndrome, None, + Some(&priors), + config.max_iter, + config.bp_method, + config.bp_schedule, + config.ms_scaling_factor, + config.osd_method, + config.osd_order, + RustInputVectorType::Syndrome, None, None, + config.random_schedule_seed, ) .map_err(|e| PyErr::new::(e.to_string()))?; InnerDecoder::BpOsd(decoder) } - "bp_lsd" => { + DemDecoderConfig::BpLsd(config) => { let decoder = RustBpLsdDecoder::new( &sparse_h, - None, // error_rate - Some(&priors), // error_channel - max_iter, - RustBpMethod::ProductSum, - RustBpSchedule::Parallel, - 1.0, // ms_scaling_factor - RustOsdMethod::Off, // lsd_method (LSD-0) - 0, // lsd_order - 0, // bits_per_step - RustInputVectorType::Syndrome, None, + Some(&priors), + config.max_iter, + config.bp_method, + config.bp_schedule, + config.ms_scaling_factor, + RustOsdMethod::Off, + 0, + 0, + RustInputVectorType::Syndrome, None, None, + config.random_schedule_seed, ) .map_err(|e| PyErr::new::(e.to_string()))?; InnerDecoder::BpLsd(decoder) } - "union_find" => { - let decoder = RustUnionFindDecoder::new(&sparse_h, RustUfMethod::Inversion) - .map_err(|e| { - PyErr::new::(e.to_string()) - })?; + DemDecoderConfig::UnionFind(config) => { + let decoder = RustUnionFindDecoder::new(&sparse_h, config.method).map_err(|e| { + PyErr::new::(e.to_string()) + })?; InnerDecoder::UnionFind(decoder) } - "relay_bp" => { + DemDecoderConfig::RelayBp(config) => { use pecos_decoders::RelayBpBuilder as RustRelayBpBuilderT; let h_view = dcm.check_matrix.view(); let decoder = RustRelayBpBuilderT::new(&h_view) .error_priors(&priors) - .max_iter(max_iter) + .max_iter(config.max_iter) + .alpha(config.alpha) + .seed(config.seed) .build() .map_err(|e| { PyErr::new::(e.to_string()) })?; InnerDecoder::RelayBp(Box::new(decoder)) } - "min_sum_bp" => { + DemDecoderConfig::MinSumBp(config) => { use pecos_decoders::MinSumBpBuilder as RustMinSumBpBuilderT; let h_view = dcm.check_matrix.view(); let decoder = RustMinSumBpBuilderT::new(&h_view) .error_priors(&priors) - .max_iter(max_iter) + .max_iter(config.max_iter) + .alpha(config.alpha) .build() .map_err(|e| { PyErr::new::(e.to_string()) })?; InnerDecoder::MinSumBp(Box::new(decoder)) } - _ => { - return Err(PyErr::new::(format!( - "Unknown decoder type: {decoder_type}. \ - Supported: bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp" - ))); - } }; Ok(Self { @@ -2368,6 +2893,113 @@ impl PyDemAwareDecoder { dem_check_matrix: dcm, }) } +} + +/// Result from a DEM-aware decoder. +#[pyclass( + name = "DemAwareResult", + module = "pecos_rslib.decoders", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyDemAwareResult { + /// Predicted observable flips, wide enough for more than 64 observables. + pub observables: pecos_decoder_core::obs_mask::ObsMask, + /// Whether the BP decoder converged. + #[pyo3(get)] + pub converged: bool, + /// Number of BP iterations used. + #[pyo3(get)] + pub iterations: usize, + num_observables: usize, +} + +impl PyDemAwareResult { + /// Render the mask for `__repr__`: the plain integer when it fits in 64 + /// bits, otherwise the set observable indices. + fn mask_display(&self) -> String { + self.observables.to_u64().map_or_else( + || { + let bits: Vec = self + .observables + .iter_set_bits() + .map(|bit| bit.to_string()) + .collect(); + format!("", bits.join(",")) + }, + |value| value.to_string(), + ) + } +} + +#[pymethods] +impl PyDemAwareResult { + /// The decoded observable flips with the decoder's observable count. + #[getter] + fn observable_flips(&self) -> PyObservableFlips { + PyObservableFlips::from_mask_value(self.observables.clone(), self.num_observables) + } + + fn __repr__(&self) -> String { + format!( + "DemAwareResult(observable_flips=ObservableFlips(num_observables={}, mask={}), converged={}, iterations={})", + self.num_observables, + self.mask_display(), + self.converged, + self.iterations + ) + } +} + +#[pymethods] +impl PyDemAwareDecoder { + /// Create a DEM-aware decoder from a DEM string. + /// + /// # Arguments + /// + /// * `dem` - DEM string in Stim format + /// * `decoder_type` - One of "`bp_osd`", "`bp_lsd`", "`union_find`", "`relay_bp`", "`min_sum_bp`" + /// * `error_rate` - Override error rate for BP priors (default: use DEM probabilities) + /// * `max_iter` - Maximum BP iterations (default: 100) + /// + /// # Example + /// + /// ```python + /// decoder = DemAwareDecoder.from_dem(dem, decoder_type="bp_osd") + /// ``` + #[staticmethod] + #[pyo3(signature = (dem, decoder_type="bp_osd", error_rate=None, max_iter=100))] + fn from_dem( + dem: &str, + decoder_type: &str, + error_rate: Option, + max_iter: usize, + ) -> PyResult { + let dcm = Self::parse_dem(dem)?; + let config = match decoder_type { + "bp_osd" => DemDecoderConfig::BpOsd( + bp_osd_config(error_rate, Some(max_iter), None, None, None, None) + .map_err(PyErr::new::)?, + ), + "bp_lsd" => DemDecoderConfig::BpLsd( + bp_lsd_config(error_rate, Some(max_iter), None, None, None) + .map_err(PyErr::new::)?, + ), + "union_find" => DemDecoderConfig::UnionFind(UnionFindDemConfig::default()), + "relay_bp" => { + DemDecoderConfig::RelayBp(relay_bp_config(error_rate, Some(max_iter), None, None)) + } + "min_sum_bp" => { + DemDecoderConfig::MinSumBp(min_sum_bp_config(error_rate, Some(max_iter), None)) + } + _ => { + return Err(PyErr::new::(format!( + "Unknown decoder type: {decoder_type}. Supported: bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp" + ))); + } + }; + Self::from_dem_check_matrix_with_config(dcm, config) + } /// Decode a dense syndrome vector. /// @@ -2377,7 +3009,7 @@ impl PyDemAwareDecoder { /// /// # Returns /// - /// `DemAwareResult` with `observables_mask`, `converged`, and `iterations`. + /// `DemAwareResult` with `observable_flips`, `converged`, and `iterations`. fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { let arr = Array1::from_vec(syndrome); let (decoding, converged, iterations) = match &mut self.inner { @@ -2414,14 +3046,16 @@ impl PyDemAwareDecoder { }; let correction: Vec = decoding.iter().map(|&v| v & 1).collect(); - let observables_mask = self + // Wide packing: the u64 variant silently wraps observable bits at 64. + let observables = self .dem_check_matrix - .observables_mask_from_correction(&correction); + .observables_obsmask_from_correction(&correction); Ok(PyDemAwareResult { - observables_mask, + observables, converged, iterations, + num_observables: self.dem_check_matrix.num_observables, }) } @@ -2459,6 +3093,10 @@ impl PyDemAwareDecoder { self.dem_check_matrix.num_observables, ) } + + fn __getattr__(&self, name: &str) -> PyResult<()> { + Err(explicit_decode_attribute_error("DemAwareDecoder", name)) + } } // ============================================================================= @@ -2471,6 +3109,7 @@ pub fn register_decoders_module(parent_module: &Bound<'_, PyModule>) -> PyResult let decoders_module = PyModule::new(py, "decoders")?; // Common result types + decoders_module.add_class::()?; decoders_module.add_class::()?; decoders_module.add_class::()?; @@ -2514,3 +3153,544 @@ pub fn register_decoders_module(parent_module: &Bound<'_, PyModule>) -> PyResult Ok(()) } + +#[cfg(test)] +mod dem_tuning_tests { + use super::*; + + const DEM: &str = + "detector D0\ndetector D1\nlogical_observable L0\nerror(0.1) D0\nerror(0.1) D1 L0\n"; + + #[test] + fn pymatching_error_probability_override_reaches_config() { + let config = pymatching_config(Some(0.123)); + + assert_eq!(config.error_probability, Some(0.123)); + } + + #[test] + fn pymatching_omitted_override_preserves_default() { + let config = pymatching_config(None); + + assert_eq!(config.error_probability, None); + } + + #[test] + fn fusion_blossom_solver_type_override_reaches_config() { + let config = fusion_blossom_config(true, Some("legacy")).unwrap(); + + assert!(config.correlated); + assert_eq!(config.solver_type, RustSolverType::Legacy); + } + + #[test] + fn fusion_blossom_omitted_override_preserves_default() { + let config = fusion_blossom_config(false, None).unwrap(); + + assert!(!config.correlated); + assert_eq!(config.solver_type, RustSolverType::Serial); + } + + #[test] + fn fusion_blossom_parallel_solver_names_parameter() { + let error = fusion_blossom_config(false, Some("parallel")).unwrap_err(); + + assert!(error.contains("solver_type")); + assert!(error.contains("partition configuration")); + } + + #[test] + fn fusion_blossom_unknown_solver_names_parameter() { + let error = fusion_blossom_config(false, Some("fast")).unwrap_err(); + + assert!(error.contains("solver_type")); + } + + #[test] + fn tesseract_default_preset_has_documented_fields() { + let config = tesseract_config("default", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, u16::MAX); + assert!(!config.beam_climbing); + assert!(config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 200_000); + assert_eq!(config.det_penalty.to_bits(), 0.0_f64.to_bits()); + } + + #[test] + fn tesseract_fast_preset_has_documented_fields() { + let config = tesseract_config("fast", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 5); + assert!(config.beam_climbing); + assert!(config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 200_000); + assert_eq!(config.det_penalty.to_bits(), 0.1_f64.to_bits()); + } + + #[test] + fn tesseract_accurate_preset_has_documented_fields() { + let config = tesseract_config("accurate", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, u16::MAX); + assert!(!config.beam_climbing); + assert!(!config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 1_000_000); + assert_eq!(config.det_penalty.to_bits(), 0.0_f64.to_bits()); + } + + #[test] + fn tesseract_det_beam_override_reaches_config() { + let config = tesseract_config("default", Some(17), None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 17); + } + + #[test] + fn tesseract_beam_climbing_override_reaches_config() { + let config = + tesseract_config("accurate", None, Some(true), None, None, None, None).unwrap(); + + assert!(config.beam_climbing); + } + + #[test] + fn tesseract_verbose_override_reaches_config() { + let config = tesseract_config("default", None, None, Some(true), None, None, None).unwrap(); + + assert!(config.verbose); + } + + #[test] + fn tesseract_no_revisit_dets_override_reaches_config() { + let config = tesseract_config("fast", None, None, None, Some(false), None, None).unwrap(); + + assert!(!config.no_revisit_dets); + } + + #[test] + fn tesseract_pqlimit_override_reaches_config() { + let config = tesseract_config("fast", None, None, None, None, Some(345_678), None).unwrap(); + + assert_eq!(config.pqlimit, 345_678); + } + + #[test] + fn tesseract_det_penalty_override_reaches_config() { + let config = tesseract_config("fast", None, None, None, None, None, Some(0.25)).unwrap(); + + assert_eq!(config.det_penalty.to_bits(), 0.25_f64.to_bits()); + } + + #[test] + fn tesseract_override_wins_over_preset() { + let config = tesseract_config("fast", Some(19), None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 19); + assert!(config.beam_climbing); + } + + #[test] + fn tesseract_omitted_override_preserves_preset() { + let config = tesseract_config("fast", None, None, None, None, None, None).unwrap(); + + assert_eq!(config.det_beam, 5); + assert!(config.beam_climbing); + assert!(config.no_revisit_dets); + assert!(!config.verbose); + assert_eq!(config.pqlimit, 200_000); + assert_eq!(config.det_penalty.to_bits(), 0.1_f64.to_bits()); + } + + #[test] + fn tesseract_unknown_preset_names_parameter() { + let error = tesseract_config("quick", None, None, None, None, None, None).unwrap_err(); + + assert!(error.contains("preset")); + } + + #[test] + fn bp_osd_error_rate_override_reaches_config() { + let config = bp_osd_config(Some(0.123), None, None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, Some(0.123)); + } + + #[test] + fn bp_osd_max_iter_override_reaches_config() { + let config = bp_osd_config(None, Some(17), None, None, None, None).unwrap(); + + assert_eq!(config.max_iter, 17); + } + + #[test] + fn bp_osd_bp_schedule_override_reaches_config() { + let config = bp_osd_config(None, None, Some("serial_relative"), None, None, None).unwrap(); + + assert_eq!(config.bp_schedule, RustBpSchedule::SerialRelative); + } + + #[test] + fn bp_osd_ms_scaling_factor_override_reaches_config() { + let config = bp_osd_config(None, None, None, Some(0.625), None, None).unwrap(); + + assert_eq!(config.bp_method, RustBpMethod::MinimumSum); + assert_eq!(config.ms_scaling_factor.to_bits(), 0.625_f64.to_bits()); + } + + #[test] + fn bp_osd_osd_order_override_reaches_config() { + let config = bp_osd_config(None, None, None, None, Some(2), None).unwrap(); + + assert_eq!(config.osd_method, RustOsdMethod::OsdCs); + assert_eq!(config.osd_order, 2); + } + + #[test] + fn bp_osd_random_schedule_seed_override_reaches_config() { + let config = bp_osd_config(None, None, None, None, None, Some(42)).unwrap(); + + assert_eq!(config.random_schedule_seed, Some(42)); + } + + #[test] + fn bp_osd_omitted_overrides_preserve_defaults() { + let config = bp_osd_config(None, None, None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.bp_method, RustBpMethod::ProductSum); + assert_eq!(config.bp_schedule, RustBpSchedule::Parallel); + assert_eq!(config.ms_scaling_factor.to_bits(), 1.0_f64.to_bits()); + assert_eq!(config.osd_method, RustOsdMethod::Osd0); + assert_eq!(config.osd_order, 0); + assert_eq!(config.random_schedule_seed, None); + } + + #[test] + fn bp_osd_unknown_schedule_names_parameter() { + let error = bp_osd_config(None, None, Some("random"), None, None, None).unwrap_err(); + + assert!(error.contains("bp_schedule")); + } + + #[test] + fn bp_lsd_error_rate_override_reaches_config() { + let config = bp_lsd_config(Some(0.234), None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, Some(0.234)); + } + + #[test] + fn bp_lsd_max_iter_override_reaches_config() { + let config = bp_lsd_config(None, Some(19), None, None, None).unwrap(); + + assert_eq!(config.max_iter, 19); + } + + #[test] + fn bp_lsd_bp_schedule_override_reaches_config() { + let config = bp_lsd_config(None, None, Some("serial_relative"), None, None).unwrap(); + + assert_eq!(config.bp_schedule, RustBpSchedule::SerialRelative); + } + + #[test] + fn bp_lsd_ms_scaling_factor_override_reaches_config() { + let config = bp_lsd_config(None, None, None, Some(0.75), None).unwrap(); + + assert_eq!(config.bp_method, RustBpMethod::MinimumSum); + assert_eq!(config.ms_scaling_factor.to_bits(), 0.75_f64.to_bits()); + } + + #[test] + fn bp_lsd_random_schedule_seed_override_reaches_config() { + let config = bp_lsd_config(None, None, None, None, Some(24)).unwrap(); + + assert_eq!(config.random_schedule_seed, Some(24)); + } + + #[test] + fn bp_lsd_omitted_overrides_preserve_defaults() { + let config = bp_lsd_config(None, None, None, None, None).unwrap(); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.bp_method, RustBpMethod::ProductSum); + assert_eq!(config.bp_schedule, RustBpSchedule::Parallel); + assert_eq!(config.ms_scaling_factor.to_bits(), 1.0_f64.to_bits()); + assert_eq!(config.random_schedule_seed, None); + } + + #[test] + fn bp_lsd_unknown_schedule_names_parameter() { + let error = bp_lsd_config(None, None, Some("random"), None, None).unwrap_err(); + + assert!(error.contains("bp_schedule")); + } + + #[test] + fn union_find_method_override_reaches_config() { + let config = union_find_config(Some("peeling")).unwrap(); + + assert_eq!(config.method, RustUfMethod::Peeling); + } + + #[test] + fn union_find_omitted_override_preserves_default() { + let config = union_find_config(None).unwrap(); + + assert_eq!(config.method, RustUfMethod::Inversion); + } + + #[test] + fn union_find_unknown_method_names_parameter() { + let error = union_find_config(Some("fast")).unwrap_err(); + + assert!(error.contains("method")); + } + + #[test] + fn relay_bp_error_rate_override_reaches_config() { + let config = relay_bp_config(Some(0.345), None, None, None); + + assert_eq!(config.error_rate, Some(0.345)); + } + + #[test] + fn relay_bp_max_iter_override_reaches_config() { + let config = relay_bp_config(None, Some(23), None, None); + + assert_eq!(config.max_iter, 23); + } + + #[test] + fn relay_bp_alpha_override_reaches_config() { + let config = relay_bp_config(None, None, Some(0.8), None); + + assert_eq!(config.alpha, Some(0.8)); + } + + #[test] + fn relay_bp_seed_override_reaches_config() { + let config = relay_bp_config(None, None, None, Some(91)); + + assert_eq!(config.seed, 91); + } + + #[test] + fn relay_bp_omitted_overrides_preserve_defaults() { + let config = relay_bp_config(None, None, None, None); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.alpha, None); + assert_eq!(config.seed, 0); + } + + #[test] + fn min_sum_bp_error_rate_override_reaches_config() { + let config = min_sum_bp_config(Some(0.456), None, None); + + assert_eq!(config.error_rate, Some(0.456)); + } + + #[test] + fn min_sum_bp_max_iter_override_reaches_config() { + let config = min_sum_bp_config(None, Some(29), None); + + assert_eq!(config.max_iter, 29); + } + + #[test] + fn min_sum_bp_alpha_override_reaches_config() { + let config = min_sum_bp_config(None, None, Some(0.7)); + + assert_eq!(config.alpha, Some(0.7)); + } + + #[test] + fn min_sum_bp_omitted_overrides_preserve_defaults() { + let config = min_sum_bp_config(None, None, None); + + assert_eq!(config.error_rate, None); + assert_eq!(config.max_iter, 100); + assert_eq!(config.alpha, None); + } + + #[test] + fn tesseract_overrides_reach_config_and_win_over_preset() { + let decoder = PyTesseractDecoder::from_dem( + DEM, + "fast", + None, + None, + None, + Some(false), + Some(12_345), + Some(0.25), + ) + .unwrap(); + + assert!(!decoder.config.no_revisit_dets); + assert_eq!(decoder.config.pqlimit, 12_345); + assert!((decoder.config.det_penalty - 0.25).abs() < f64::EPSILON); + assert_eq!(decoder.config.det_beam, 5); + } + + #[test] + fn bp_osd_overrides_reach_inner_decoder() { + let decoder = PyBpOsdDecoder::from_dem( + DEM, + None, + Some(17), + Some("serial"), + Some(0.75), + Some(2), + Some(42), + ) + .unwrap(); + let InnerDecoder::BpOsd(inner) = decoder.inner else { + panic!("expected BP+OSD inner decoder"); + }; + + assert_eq!(inner.max_iter(), 17); + assert_eq!(inner.bp_method(), RustBpMethod::MinimumSum); + assert_eq!(inner.bp_schedule(), RustBpSchedule::Serial); + assert!((inner.ms_scaling_factor() - 0.75).abs() < f64::EPSILON); + assert_eq!(inner.osd_order(), 2); + assert_eq!(inner.osd_method(), RustOsdMethod::OsdCs); + assert_eq!(inner.random_schedule_seed(), 42); + } + + #[test] + fn bp_lsd_overrides_reach_inner_decoder() { + let decoder = PyBpLsdDecoder::from_dem( + DEM, + None, + Some(19), + Some("serial_relative"), + Some(0.625), + Some(24), + ) + .unwrap(); + let InnerDecoder::BpLsd(inner) = decoder.inner else { + panic!("expected BP+LSD inner decoder"); + }; + + assert_eq!(inner.max_iter(), 19); + assert_eq!(inner.bp_method(), RustBpMethod::MinimumSum); + assert_eq!(inner.bp_schedule(), RustBpSchedule::SerialRelative); + assert!((inner.ms_scaling_factor() - 0.625).abs() < f64::EPSILON); + assert_eq!(inner.random_schedule_seed(), 24); + } + + #[test] + fn union_find_override_reaches_inner_decoder() { + let decoder = PyUnionFindDecoder::from_dem(DEM, Some("peeling")).unwrap(); + let InnerDecoder::UnionFind(inner) = decoder.inner else { + panic!("expected Union-Find inner decoder"); + }; + + assert_eq!(inner.method(), RustUfMethod::Peeling); + } + + #[test] + fn relay_bp_overrides_reach_inner_decoder() { + let decoder = PyRelayBpDecoder::from_dem(DEM, None, Some(23), Some(0.8), Some(91)).unwrap(); + let InnerDecoder::RelayBp(inner) = decoder.inner else { + panic!("expected Relay BP inner decoder"); + }; + + assert_eq!(inner.max_iter(), 23); + assert_eq!(inner.alpha(), Some(0.8)); + assert_eq!(inner.seed(), 91); + } + + #[test] + fn min_sum_bp_overrides_reach_inner_decoder() { + let decoder = PyMinSumBpDecoder::from_dem(DEM, None, Some(29), Some(0.7)).unwrap(); + let InnerDecoder::MinSumBp(inner) = decoder.inner else { + panic!("expected min-sum BP inner decoder"); + }; + + assert_eq!(inner.max_iter(), 29); + assert_eq!(inner.alpha(), Some(0.7)); + } + + #[test] + fn bp_family_none_overrides_preserve_dem_defaults() { + let bp_osd = PyBpOsdDecoder::from_dem(DEM, None, None, None, None, None, None).unwrap(); + let InnerDecoder::BpOsd(bp_osd) = bp_osd.inner else { + panic!("expected BP+OSD inner decoder"); + }; + assert_eq!(bp_osd.max_iter(), 100); + assert_eq!(bp_osd.bp_method(), RustBpMethod::ProductSum); + assert_eq!(bp_osd.bp_schedule(), RustBpSchedule::Parallel); + assert!((bp_osd.ms_scaling_factor() - 1.0).abs() < f64::EPSILON); + assert_eq!(bp_osd.osd_order(), 0); + assert_eq!(bp_osd.random_schedule_seed(), -1); + + let relay = PyRelayBpDecoder::from_dem(DEM, None, None, None, None).unwrap(); + let InnerDecoder::RelayBp(relay) = relay.inner else { + panic!("expected Relay BP inner decoder"); + }; + assert_eq!(relay.max_iter(), 100); + assert_eq!(relay.alpha(), None); + assert_eq!(relay.seed(), 0); + } + + #[test] + fn textual_guards_name_the_parameter() { + pyo3::Python::initialize(); + + let preset_error = + PyTesseractDecoder::from_dem(DEM, "quick", None, None, None, None, None, None) + .err() + .unwrap(); + assert!(preset_error.to_string().contains("preset")); + + let schedule_error = PyBpLsdDecoder::from_dem(DEM, None, None, Some("random"), None, None) + .err() + .unwrap(); + assert!(schedule_error.to_string().contains("bp_schedule")); + + let method_error = PyUnionFindDecoder::from_dem(DEM, Some("fast")) + .err() + .unwrap(); + assert!(method_error.to_string().contains("method")); + + let solver_error = PyFusionBlossomDecoder::from_dem(DEM, false, Some("parallel")) + .err() + .unwrap(); + let message = solver_error.to_string(); + assert!(message.contains("solver_type")); + assert!(message.contains("partition configuration")); + + let solver_error = PyFusionBlossomDecoder::from_dem(DEM, false, Some("fast")) + .err() + .unwrap(); + assert!(solver_error.to_string().contains("solver_type")); + + for (error, parameter) in [ + ( + optional_usize(Some(-1), "max_iter").unwrap_err(), + "max_iter", + ), + ( + optional_u16(Some(65_536), "det_beam").unwrap_err(), + "det_beam", + ), + ( + optional_i32(Some(i64::from(i32::MAX) + 1), "random_schedule_seed").unwrap_err(), + "random_schedule_seed", + ), + (optional_u64(Some(-1), "seed").unwrap_err(), "seed"), + ] { + assert!(error.to_string().contains(parameter)); + } + } +} diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 2f6210c35..828686676 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -24,6 +24,7 @@ type RustStateVectorEngineBuilder = StateVectorEngineBuilder; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; +use pyo3::types::PyBool; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -852,7 +853,9 @@ pub fn hugr_engine() -> PyHugrEngineBuilder { PyHugrEngineBuilder::new() } -/// Create a general noise model builder +/// Create a general noise model builder with no-effect defaults. +/// +/// Call ``.auto()`` to opt into the legacy demonstration preset. #[pyfunction] pub fn general_noise() -> PyGeneralNoiseModelBuilder { PyGeneralNoiseModelBuilder::new() @@ -877,6 +880,15 @@ pub struct PyGeneralNoiseModelBuilder { pub(crate) inner: GeneralNoiseModelBuilder, } +impl PyGeneralNoiseModelBuilder { + pub(crate) fn validated_inner(&self) -> PyResult { + self.inner + .validate_configuration() + .map_err(|message| pyo3::exceptions::PyValueError::new_err(message.to_string()))?; + Ok(self.inner.clone()) + } +} + #[pymethods] impl PyGeneralNoiseModelBuilder { #[new] @@ -886,38 +898,61 @@ impl PyGeneralNoiseModelBuilder { } } + /// Fill unset parameters with the legacy demonstration preset. + /// + /// This reproduces the general noise model's historical defaults for demonstrations; it is + /// not a calibrated device model. Explicit setters win in either call order because ``auto`` + /// fills only parameters that the caller has not set. + /// + /// The preset sets preparation, measurement, one-qubit, two-qubit, and linear-idle rates to + /// 0.01, 0.01/0.01, 0.001, 0.01, and 0.001 respectively. In addition: + /// + /// * ``p_prep_leak_ratio = 0.5`` means half of preparation faults leak the qubit out of the + /// computational subspace. + /// * ``p1_emission_ratio = p2_emission_ratio = 0.5`` means half of gate errors take the + /// spontaneous-emission branch, which removes the original gate and substitutes a sample + /// from the emission model. The preset emission models contain Pauli keys only, so these + /// branches cause no leakage. + /// * ``p1_seepage_prob = p2_seepage_prob = 0.5`` applies only to qubits that are already + /// leaked. + fn auto(&self) -> Self { + Self { + inner: self.inner.clone().auto(), + } + } + /// Set single-qubit gate error probability - fn with_p1_probability(&self, p: f64) -> PyResult { + fn with_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p1_probability(p), + inner: self.inner.clone().with_p1(p), }) } /// Set two-qubit gate error probability - fn with_p2_probability(&self, p: f64) -> PyResult { + fn with_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_probability(p), + inner: self.inner.clone().with_p2(p), }) } /// Set preparation error probability - fn with_prep_probability(&self, p: f64) -> PyResult { + fn with_p_prep(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_prep(p), }) } /// Set measurement error probability for |0⟩ state - fn with_meas_0_probability(&self, p: f64) -> PyResult { + fn with_p_meas_0(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_0_probability(p), + inner: self.inner.clone().with_p_meas_0(p), }) } /// Set measurement error probability for |1⟩ state - fn with_meas_1_probability(&self, p: f64) -> PyResult { + fn with_p_meas_1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_1_probability(p), + inner: self.inner.clone().with_p_meas_1(p), }) } @@ -974,41 +1009,30 @@ impl PyGeneralNoiseModelBuilder { } /// Set average single-qubit gate error probability - fn with_average_p1_probability(&self, p: f64) -> PyResult { + fn with_average_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_average_p1_probability(p), + inner: self.inner.clone().with_average_p1(p), }) } /// Set average two-qubit gate error probability - fn with_average_p2_probability(&self, p: f64) -> PyResult { + fn with_average_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_average_p2_probability(p), + inner: self.inner.clone().with_average_p2(p), }) } /// Set measurement error probability (symmetric) - fn with_meas_probability(&self, p: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_meas_probability(p), - }) - } - - /// Set preparation error probability - fn with_preparation_probability(&self, p: f64) -> PyResult { + fn with_p_meas(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_meas(p), }) } /// Set measurement error probability (asymmetric) fn with_measurement_probability(&self, p0: f64, p1: f64) -> PyResult { Ok(Self { - inner: self - .inner - .clone() - .with_meas_0_probability(p0) - .with_meas_1_probability(p1), + inner: self.inner.clone().with_p_meas_0(p0).with_p_meas_1(p1), }) } @@ -1055,60 +1079,103 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set whether to use coherent dephasing for idle errors - fn with_p_idle_coherent(&self, use_coherent: bool) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_p_idle_coherent(use_coherent), - }) - } - - /// Set the idling noise error rate for the linear term - fn with_p_idle_linear_rate(&self, rate: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_p_idle_linear_rate(rate), - }) - } - - /// Set the idling noise error rate for the quadratic term - fn with_p_idle_quadratic_rate(&self, rate: f64) -> PyResult { - Ok(Self { - inner: self.inner.clone().with_p_idle_quadratic_rate(rate), - }) - } - - /// Set the stochastic model for idling that is linearly dependent on time - fn with_p_idle_linear_model( + /// Set the DEM-style linear idle-noise family. + /// + /// ``rate`` is the total event rate per time unit. The X/Y/Z/L ``model`` must be a + /// normalized distribution because this family splits one total linear rate across axes. + /// + /// By contrast, ``with_p_idle_sin_squared`` uses radians per time unit and unnormalized + /// relative multipliers because sine laws do not add linearly: each axis has its own + /// independent rate. It applies no unit conversion. + /// + /// All engines idle-noise families are off by default, so translating a DEM configuration only + /// requires setting the requested families. Engines keeps its existing linear sampling + /// structure: one event followed by a categorical axis choice, versus the DEM's independent + /// per-axis mechanisms. The difference is second order in the rates; this setter aligns units + /// and the axis alphabet, not the sampling structure. + fn with_p_idle_linear( &self, + rate: f64, model: std::collections::BTreeMap, ) -> PyResult { - use std::collections::BTreeMap; - let btree_map: BTreeMap = model.into_iter().collect(); - Ok(Self { - inner: self.inner.clone().with_p_idle_linear_model(&btree_map), - }) - } - - /// Set coherent to incoherent noise conversion factor - fn with_p_idle_coherent_to_incoherent_factor(&self, factor: f64) -> PyResult { Ok(Self { - inner: self - .inner - .clone() - .with_p_idle_coherent_to_incoherent_factor(factor), + inner: self.inner.clone().with_p_idle_linear(rate, &model), }) } - /// Set the average idling noise error rate per channel for the linear term - fn with_average_p_idle_linear_rate(&self, rate: f64) -> PyResult { + /// Set the DEM-style stochastic sine-squared idle-noise family. + /// + /// ``rate`` is radians per time unit and no unit conversion is applied. For each X/Y/Z/L axis + /// P, multiplier ``n_P``, and duration ``d``, engines independently samples + /// ``P(P) = sin^2(rate * n_P * d)``. + /// + /// The model is intentionally unnormalized because sine laws do not add linearly: each axis + /// carries its own independent rate. ``with_p_idle_linear`` instead requires a normalized + /// distribution because it splits one total linear rate across axes. + /// + /// The removed cycles-per-time spelling migrates exactly as follows at its former default + /// factor of one: + /// + /// ``with_p_idle_quadratic_rate(r) == with_p_idle_sin_squared(r * PI, {"Z": 1.0})`` + /// + /// All engines idle-noise families are off by default, so translating a DEM configuration + /// only requires setting the requested families. + /// + /// Engines deliberately retains its existing linear sampling structure: one event followed + /// by a categorical axis choice, versus the DEM's independent per-axis mechanisms. The + /// difference is second order in the rates; this setter aligns units and the axis alphabet, + /// not the sampling structure. + fn with_p_idle_sin_squared( + &self, + rate: f64, + model: std::collections::BTreeMap, + ) -> PyResult { Ok(Self { - inner: self.inner.clone().with_average_p_idle_linear_rate(rate), + inner: self.inner.clone().with_p_idle_sin_squared(rate, &model), }) } - /// Set the average idling noise error rate per channel for the quadratic term - fn with_average_p_idle_quadratic_rate(&self, rate: f64) -> PyResult { + /// Set the DEM-style coherent idle-noise family. + /// + /// ``rate`` is radians per time unit and no unit conversion is applied. For each RX/RY/RZ + /// generator P, multiplier ``n_P``, and duration ``d``, engines deterministically applies a + /// rotation with angle ``rate * n_P * d``. Coherent evolution is not sampled and consumes no + /// random draw. + /// + /// The model is intentionally unnormalized because its values are relative rate multipliers, + /// not probabilities to be split from one total event rate. It defaults to + /// ``{"RX": 1.0, "RY": 1.0, "RZ": 1.0}``. Leakage and all other keys are rejected because + /// leakage is not a rotation. + /// + /// Consumption is consumer-dependent: the standard DEM builder rejects coherent idle noise; + /// the EEG route in ``exp/pecos-eeg`` represents it with an RZ generator; and a simulator + /// applies it only when its rotation executor is installed. PECOS #437 documents how a + /// missing executor could otherwise silently drop it. + #[pyo3(signature = (rate, model=None))] + fn with_p_idle_coherent( + &self, + rate: &Bound<'_, PyAny>, + model: Option>, + ) -> PyResult { + if rate.is_instance_of::() { + return Err(pyo3::exceptions::PyTypeError::new_err( + "coherent idling rate must be a finite, non-negative float, not bool", + )); + } + let rate = rate.extract::().map_err(|_| { + pyo3::exceptions::PyTypeError::new_err( + "coherent idling rate must be a finite, non-negative float", + ) + })?; + let model = model.unwrap_or_else(|| { + std::collections::BTreeMap::from([ + ("RX".to_string(), 1.0), + ("RY".to_string(), 1.0), + ("RZ".to_string(), 1.0), + ]) + }); Ok(Self { - inner: self.inner.clone().with_average_p_idle_quadratic_rate(rate), + inner: self.inner.clone().with_p_idle_coherent(rate, &model), }) } @@ -1220,10 +1287,18 @@ impl PyGeneralNoiseModelBuilder { }) } - /// Set idle probability for two-qubit gates - fn with_p2_idle(&self, probability: f64) -> PyResult { + /// Set the duration of the idle-noise site applied to each qubit after a two-qubit gate. + /// + /// A duration of `0.0` disables these sites. Nonzero sites receive all configured idle + /// families over the given duration: linear stochastic noise, independent per-axis + /// sine-squared noise, and coherent rotations. + /// + /// Anyone who previously wrote `with_p2_idle(0.01)` and no linear rate now gets no after-2q + /// idle noise; the equivalent is + /// `with_p_idle_linear(0.01, {"Z": 1.0}).with_idle_after_2q(1.0)`. + fn with_idle_after_2q(&self, duration: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_idle(probability), + inner: self.inner.clone().with_idle_after_2q(duration), }) } @@ -1299,30 +1374,30 @@ impl PyDepolarizingNoiseModelBuilder { } /// Set preparation error probability - fn with_prep_probability(&self, p: f64) -> PyResult { + fn with_p_prep(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_prep(p), }) } /// Set measurement error probability - fn with_meas_probability(&self, p: f64) -> PyResult { + fn with_p_meas(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_probability(p), + inner: self.inner.clone().with_p_meas(p), }) } /// Set single-qubit gate error probability - fn with_p1_probability(&self, p: f64) -> PyResult { + fn with_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p1_probability(p), + inner: self.inner.clone().with_p1(p), }) } /// Set two-qubit gate error probability - fn with_p2_probability(&self, p: f64) -> PyResult { + fn with_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_probability(p), + inner: self.inner.clone().with_p2(p), }) } @@ -1339,11 +1414,6 @@ impl PyDepolarizingNoiseModelBuilder { inner: self.inner.clone().with_seed(seed), }) } - - /// Set preparation error probability (alias for `with_prep_probability`) - fn with_preparation_probability(&self, p: f64) -> PyResult { - self.with_prep_probability(p) - } } /// Python wrapper for `BiasedDepolarizingNoiseModelBuilder` @@ -1363,37 +1433,37 @@ impl PyBiasedDepolarizingNoiseModelBuilder { } /// Set preparation error probability - fn with_prep_probability(&self, p: f64) -> PyResult { + fn with_p_prep(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_prep_probability(p), + inner: self.inner.clone().with_p_prep(p), }) } /// Set measurement 0->1 flip probability - fn with_meas_0_probability(&self, p: f64) -> PyResult { + fn with_p_meas_0(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_0_probability(p), + inner: self.inner.clone().with_p_meas_0(p), }) } /// Set measurement 1->0 flip probability - fn with_meas_1_probability(&self, p: f64) -> PyResult { + fn with_p_meas_1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_meas_1_probability(p), + inner: self.inner.clone().with_p_meas_1(p), }) } /// Set single-qubit gate error probability - fn with_p1_probability(&self, p: f64) -> PyResult { + fn with_p1(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p1_probability(p), + inner: self.inner.clone().with_p1(p), }) } /// Set two-qubit gate error probability - fn with_p2_probability(&self, p: f64) -> PyResult { + fn with_p2(&self, p: f64) -> PyResult { Ok(Self { - inner: self.inner.clone().with_p2_probability(p), + inner: self.inner.clone().with_p2(p), }) } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 0b9f4a899..491b030ab 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -54,7 +54,7 @@ use pecos_qec::fault_tolerance::dem_builder::{ DemSampler as RustNewDemSampler, DemSamplerBuilder as RustNewDemSamplerBuilder, DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, - FaultSourceType as RustFaultSourceType, MeasurementCrosstalkDemMode, + FaultSourceType as RustFaultSourceType, IdleNoiseFamily, MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, NoiseConfig, OutputMode, PAULI_2Q_ORDER, ParsedDem as RustParsedDem, PauliWeights, ReplacementBranchApproximation, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, @@ -71,14 +71,34 @@ use pecos_quantum::DagCircuit; use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; + +use crate::observable_flips_bindings::{PyObservableFlips, obsmask_to_py, py_to_obsmask}; use std::collections::BTreeMap; use std::str::FromStr; +mod decoder_comparison; + +use decoder_comparison::{PyDecoderComparisonResult, compare_decoder_outcomes}; + 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 idle_family_from_axis_rates(px: f64, py: f64, pz: f64) -> IdleNoiseFamily { + if px == 0.0 && py == 0.0 && pz == 0.0 { + return IdleNoiseFamily::default(); + } + IdleNoiseFamily::new( + 1.0, + BTreeMap::from([ + ("X".to_string(), px), + ("Y".to_string(), py), + ("Z".to_string(), pz), + ]), + ) +} + fn parse_p1_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; @@ -346,6 +366,32 @@ fn apply_noise_options( p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { + // Reject the base-idle-channel combinations this function would otherwise + // resolve silently: `set_t1_t2` makes T1/T2 the base channel that shadows + // `p_idle`, and `set_idle_rz` zeroes `p_idle` and overwrites T1/T2 with a + // synthetic T2. Each combination discards a caller-supplied rate without + // any signal (issue #426). + if p_idle.is_some() && (t1.is_some() || t2.is_some()) { + return Err(PyErr::new::( + "p_idle cannot be combined with t1/t2; the T1/T2 channel replaces the \ + depolarizing base idle channel, so p_idle would be ignored", + )); + } + if idle_rz.is_some() { + if p_idle.is_some() { + return Err(PyErr::new::( + "idle_rz cannot be combined with p_idle; the coherent RZ conversion \ + replaces the base idle channel, so p_idle would be ignored", + )); + } + if t1.is_some() || t2.is_some() { + return Err(PyErr::new::( + "idle_rz cannot be combined with t1/t2; the coherent RZ conversion \ + overwrites the T1/T2 channel with an equivalent T2", + )); + } + } + noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { noise = noise.set_t1_t2(t1_val, t2_val); @@ -353,42 +399,25 @@ fn apply_noise_options( if let Some(rz) = idle_rz { noise = noise.set_idle_rz(rz); } - if let Some(rate) = p_idle_linear_rate { - noise = noise.set_idle_linear_rate(rate); - } - if let Some(rate) = p_idle_quadratic_rate { - noise = noise.set_idle_quadratic_rate(rate); - } - if let Some(rate) = p_idle_x_linear_rate { - noise.p_idle_x_linear_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_y_linear_rate { - noise.p_idle_y_linear_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_z_linear_rate { - noise.p_idle_linear_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_x_quadratic_rate { - noise.p_idle_x_quadratic_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_y_quadratic_rate { - noise.p_idle_y_quadratic_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_z_quadratic_rate { - noise.p_idle_quadratic_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_quadratic_sine_rate { - noise = noise.set_idle_quadratic_sine_rate(rate); - } - if let Some(rate) = p_idle_x_quadratic_sine_rate { - noise.p_idle_x_quadratic_sine_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_y_quadratic_sine_rate { - noise.p_idle_y_quadratic_sine_rate = rate.max(0.0); - } - if let Some(rate) = p_idle_z_quadratic_sine_rate { - noise.p_idle_quadratic_sine_rate = rate.max(0.0); - } + noise.p_idle_linear = idle_family_from_axis_rates( + p_idle_x_linear_rate.unwrap_or(0.0), + p_idle_y_linear_rate.unwrap_or(0.0), + p_idle_z_linear_rate.or(p_idle_linear_rate).unwrap_or(0.0), + ); + noise.p_idle_quadratic = idle_family_from_axis_rates( + p_idle_x_quadratic_rate.unwrap_or(0.0), + p_idle_y_quadratic_rate.unwrap_or(0.0), + p_idle_z_quadratic_rate + .or(p_idle_quadratic_rate) + .unwrap_or(0.0), + ); + noise.p_idle_quadratic_sine = idle_family_from_axis_rates( + p_idle_x_quadratic_sine_rate.unwrap_or(0.0), + p_idle_y_quadratic_sine_rate.unwrap_or(0.0), + p_idle_z_quadratic_sine_rate + .or(p_idle_quadratic_sine_rate) + .unwrap_or(0.0), + ); if let Some(weights) = p1_weights { noise = noise.set_p1_weights(parse_p1_weights(weights)?); } @@ -1438,6 +1467,7 @@ fn contribution_record_to_pydict( dict.set_item("before_flags", contribution.source_before_flags.to_vec())?; if let Some(family) = contribution.direct_source_family { let family_label = match family { + RustDirectSourceFamily::ExclusiveSignature => "ExclusiveSignature", RustDirectSourceFamily::SingleLocation => "SingleLocation", RustDirectSourceFamily::SingleLocationY => "SingleLocationY", RustDirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", @@ -1728,6 +1758,32 @@ impl PyDetectorErrorModel { self.inner.num_contributions() } + /// Quantified residuals from infeasible categorical-to-independent conversions. + /// + /// Each dictionary reports the channel kind, fault location, representative + /// flip signature, total-variation magnitude, requested channel weight, and + /// their relative magnitude. An empty list means every categorical conversion + /// was exact. + #[getter] + fn idle_noise_residuals(&self, py: Python<'_>) -> PyResult>> { + self.inner + .idle_noise_residuals() + .iter() + .map(|residual| { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("location_index", residual.location_index)?; + dict.set_item("channel_kind", residual.channel_kind.as_str())?; + dict.set_item("detectors", residual.effect.detectors.to_vec())?; + dict.set_item("dem_outputs", residual.effect.dem_outputs.to_vec())?; + dict.set_item("tracked_paulis", residual.effect.tracked_paulis.to_vec())?; + dict.set_item("magnitude", residual.magnitude)?; + dict.set_item("channel_weight", residual.channel_weight)?; + dict.set_item("relative_magnitude", residual.relative_magnitude())?; + Ok(dict.unbind()) + }) + .collect() + } + /// Returns debug info about contributions for a specific mechanism. /// /// Args: @@ -3389,23 +3445,6 @@ 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(()) - } - /// Reject raw-measurement batches before treating their rows as syndromes. fn ensure_detector_events(&self) -> PyResult<()> { if self.raw_measurements { @@ -3416,27 +3455,6 @@ impl PySampleBatch { 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. - 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; - for (obs_idx, col) in self.obs_columns.iter().enumerate() { - if col[word_idx] & bit_mask != 0 { - mask |= 1u64 << obs_idx; - } - } - 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 { @@ -3646,6 +3664,17 @@ impl PySampleBatch { self.num_shots } + /// Stored observable-column width, i.e. the length of one row of + /// [`observable_flips`] and of every [`get_observable_flips`] value. + /// + /// This is the constructor's `num_observables` when supplied. Sampler-produced + /// columns hold all DEM outputs, which can be a superset of the logical + /// observables, so this is a width rather than a promise about the code. + #[getter] + fn num_observables(&self) -> usize { + self.obs_columns.len() + } + /// Get the syndrome for shot `i` as a list of u8 values. fn get_syndrome(&self, i: usize) -> PyResult> { if i >= self.num_shots { @@ -3659,30 +3688,6 @@ impl PySampleBatch { Ok(buf) } - /// Get the expected observable mask for shot `i` (`u64`; <=64 observables). - 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={})", - self.num_shots - ))); - } - 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)) - } - /// Return all detector events as shots-major boolean lists. /// /// The result has shape (`num_shots`, `num_detectors`). @@ -3702,6 +3707,26 @@ impl PySampleBatch { Self::columns_as_rows(&self.obs_columns, self.num_shots) } + /// Observable flips for shot `i` as an [`ObservableFlips`] value. + /// + /// This is the single-shot form of [`observable_flips`], and compares + /// directly against a decoder result's `observable_flips`. Its length is the + /// stored observable-column width, so it matches one row of + /// [`observable_flips`] and carries the same caveat about sampler-produced + /// columns being a superset of the logical observables. + fn get_observable_flips(&self, i: usize) -> PyResult { + if i >= self.num_shots { + return Err(PyErr::new::(format!( + "Shot index {i} out of range (num_shots={})", + self.num_shots + ))); + } + Ok(PyObservableFlips::from_mask_value( + self.extract_obs_mask_wide(i), + self.obs_columns.len(), + )) + } + /// Decode all samples with the given decoder type and return the error count. /// /// This runs entirely in Rust -- no per-shot Python crossing. @@ -3771,6 +3796,48 @@ impl PySampleBatch { Ok(predictions) } + /// Decode every shot with a decoder under test (DUT) and a reference decoder. + /// + /// Both decoders receive the same shots in the same order. Each result is + /// independently classified as correct, mismatch, or decode error, and a + /// decode error is counted for that shot without aborting the comparison. + /// Predictions and truth are compared as wide observable masks, with no + /// 64-observable limit. + /// + /// Args: + /// dem: DEM string shared by both decoders. + /// `dut_decoder_type`: Decoder type string for the decoder under test. + /// `reference_decoder_type`: Decoder type string for the reference. + /// alpha: Tail probability for equal-tailed Jeffreys intervals. + /// + /// Returns: + /// A `DecoderComparisonResult` containing the raw 3x3 counts and + /// headline DUT-only-failure and both-failed proportions. + #[pyo3(signature = (dem, dut_decoder_type, reference_decoder_type, alpha=0.05))] + fn compare_decoders( + &self, + dem: &str, + dut_decoder_type: &str, + reference_decoder_type: &str, + alpha: f64, + ) -> PyResult { + let mut dut = create_observable_decoder(dem, dut_decoder_type)?; + let mut reference = create_observable_decoder(dem, reference_decoder_type)?; + let mut syndrome = vec![0u8; self.num_detectors]; + let counts = compare_decoder_outcomes( + self.num_shots, + &mut syndrome, + |shot, buffer| { + self.extract_syndrome(shot, buffer); + self.extract_obs_mask_wide(shot) + }, + dut.as_mut(), + reference.as_mut(), + ); + PyDecoderComparisonResult::new(counts, alpha) + .map_err(|error| pyo3::exceptions::PyRuntimeError::new_err(error.to_string())) + } + /// Parallel decode: distributes samples across rayon workers. /// /// Each worker creates its own decoder instance. Faster for slow decoders. @@ -5628,52 +5695,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, @@ -7017,6 +7038,15 @@ fn mechanisms_to_dem_string(mechanisms: Vec<(f64, Vec, Vec)>) -> Strin #[pyfunction] fn decoder_dem_requirement(decoder_type: &str) -> PyResult { let base = decoder_type.split(':').next().unwrap_or(decoder_type); + // "perturbed" wraps an arbitrary inner decoder ("perturbed:K=15,inner=TYPE"), + // so its requirement is the inner decoder's. `inner=` takes the rest of the + // string, matching how create_observable_decoder parses nested specs. + if base == "perturbed" { + let inner = decoder_type + .split_once("inner=") + .map_or("pymatching", |(_, rest)| rest); + return decoder_dem_requirement(inner); + } match base { "pymatching" | "pymatching_correlated" @@ -7031,9 +7061,14 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { | "k_mwpm" | "perturbed_fb_corr" | "perturbed_fb" + | "beamsearch" + | "belief_matching" + | "belief_matching_correlated" + | "belief_matching_mgbp" + | "belief_matching_hybrid" | "ensemble" => Ok("graphlike".to_string()), - "tesseract" | "astar" | "astar_full" | "bp_osd" | "bp_lsd" | "union_find" - | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" => Ok("any".to_string()), + "tesseract" | "astar" | "astar_full" | "bp_osd" | "bp_lsd" | "belief_find" + | "union_find" | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" => Ok("any".to_string()), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "Unknown decoder type: {decoder_type:?}", ))), @@ -7048,6 +7083,7 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { let qec = PyModule::new(m.py(), "qec")?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; @@ -7056,6 +7092,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings/decoder_comparison.rs b/python/pecos-rslib/src/fault_tolerance_bindings/decoder_comparison.rs new file mode 100644 index 000000000..d961d6799 --- /dev/null +++ b/python/pecos-rslib/src/fault_tolerance_bindings/decoder_comparison.rs @@ -0,0 +1,443 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Paired DUT/reference decoder comparison over a shared sequence of shots. + +use pecos_decoder_core::obs_mask::ObsMask; +use pecos_decoder_core::{DecoderError, ObservableDecoder}; +use pecos_num::stats::{JeffreysError, JeffreysInterval, jeffreys_interval}; +use pyo3::prelude::*; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DecoderOutcome { + Correct, + Mismatch, + Error, +} + +impl DecoderOutcome { + const fn index(self) -> usize { + match self { + Self::Correct => 0, + Self::Mismatch => 1, + Self::Error => 2, + } + } +} + +fn classify(result: Result, truth: &ObsMask) -> DecoderOutcome { + match result { + Ok(prediction) if prediction == *truth => DecoderOutcome::Correct, + Ok(_) => DecoderOutcome::Mismatch, + Err(_) => DecoderOutcome::Error, + } +} + +/// Counts indexed by DUT outcome first, then reference outcome. +/// +/// In each dimension the order is correct, mismatch, decode error. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(super) struct DecoderComparisonCounts { + cells: [[u64; 3]; 3], +} + +impl DecoderComparisonCounts { + fn record(&mut self, dut: DecoderOutcome, reference: DecoderOutcome) { + self.cells[dut.index()][reference.index()] += 1; + } + + pub(super) const fn cells(&self) -> &[[u64; 3]; 3] { + &self.cells + } + + fn total_shots(&self) -> u64 { + self.cells.iter().flatten().sum() + } + + const fn dut_only_failures(&self) -> u64 { + self.cells[DecoderOutcome::Mismatch.index()][DecoderOutcome::Correct.index()] + } + + const fn both_failed(&self) -> u64 { + self.cells[DecoderOutcome::Mismatch.index()][DecoderOutcome::Mismatch.index()] + } +} + +/// Compare two decoders on the same shots in the same order. +/// +/// `prepare_shot` writes the selected syndrome into the reusable buffer and +/// returns that shot's wide true-observable mask. +pub(super) fn compare_decoder_outcomes( + num_shots: usize, + syndrome: &mut [u8], + mut prepare_shot: impl FnMut(usize, &mut [u8]) -> ObsMask, + dut: &mut dyn ObservableDecoder, + reference: &mut dyn ObservableDecoder, +) -> DecoderComparisonCounts { + let mut counts = DecoderComparisonCounts::default(); + for shot in 0..num_shots { + let truth = prepare_shot(shot, syndrome); + // Run both decoders before classifying either result. In particular, a + // DUT error must not prevent the reference from seeing this shot. + let dut_result = dut.decode_obs(syndrome); + let reference_result = reference.decode_obs(syndrome); + counts.record( + classify(dut_result, &truth), + classify(reference_result, &truth), + ); + } + counts +} + +#[derive(Clone, Copy, Debug)] +struct HeadlineProportion { + point: f64, + interval: JeffreysInterval, +} + +impl HeadlineProportion { + fn new(count: u64, total_shots: u64, alpha: f64) -> Result { + let interval = jeffreys_interval(count, total_shots, alpha)?; + Ok(Self { + point: interval.point, + interval, + }) + } +} + +/// Python-facing paired decoder contingency counts and headline proportions. +#[pyclass( + name = "DecoderComparisonResult", + module = "pecos_rslib.qec", + skip_from_py_object +)] +#[derive(Clone, Debug)] +pub(super) struct PyDecoderComparisonResult { + counts: DecoderComparisonCounts, + total_shots: u64, + alpha: f64, + dut_only_failure: HeadlineProportion, + both_failed: HeadlineProportion, +} + +impl PyDecoderComparisonResult { + pub(super) fn new(counts: DecoderComparisonCounts, alpha: f64) -> Result { + let total_shots = counts.total_shots(); + let dut_only_failure = + HeadlineProportion::new(counts.dut_only_failures(), total_shots, alpha)?; + let both_failed = HeadlineProportion::new(counts.both_failed(), total_shots, alpha)?; + Ok(Self { + counts, + total_shots, + alpha, + dut_only_failure, + both_failed, + }) + } +} + +#[pymethods] +impl PyDecoderComparisonResult { + /// Raw 3x3 counts in correct, mismatch, error order on both axes. + #[getter] + fn counts(&self) -> Vec> { + self.counts.cells().iter().map(|row| row.to_vec()).collect() + } + + /// Number of shots compared. + #[getter] + const fn total_shots(&self) -> u64 { + self.total_shots + } + + /// Tail probability used for the equal-tailed Jeffreys intervals. + #[getter] + const fn alpha(&self) -> f64 { + self.alpha + } + + #[getter] + const fn dut_correct_reference_correct(&self) -> u64 { + self.counts.cells[0][0] + } + + #[getter] + const fn dut_correct_reference_mismatch(&self) -> u64 { + self.counts.cells[0][1] + } + + #[getter] + const fn dut_correct_reference_error(&self) -> u64 { + self.counts.cells[0][2] + } + + #[getter] + const fn dut_mismatch_reference_correct(&self) -> u64 { + self.counts.cells[1][0] + } + + #[getter] + const fn dut_mismatch_reference_mismatch(&self) -> u64 { + self.counts.cells[1][1] + } + + #[getter] + const fn dut_mismatch_reference_error(&self) -> u64 { + self.counts.cells[1][2] + } + + #[getter] + const fn dut_error_reference_correct(&self) -> u64 { + self.counts.cells[2][0] + } + + #[getter] + const fn dut_error_reference_mismatch(&self) -> u64 { + self.counts.cells[2][1] + } + + #[getter] + const fn dut_error_reference_error(&self) -> u64 { + self.counts.cells[2][2] + } + + /// DUT mismatches on shots where the reference was correct. + #[getter] + const fn dut_only_failures(&self) -> u64 { + self.counts.dut_only_failures() + } + + /// Jeffreys posterior-mean proportion for DUT-only failures. + #[getter] + const fn dut_only_failure_proportion(&self) -> f64 { + self.dut_only_failure.point + } + + /// Equal-tailed Jeffreys interval for the DUT-only-failure proportion. + #[getter] + const fn dut_only_failure_interval(&self) -> (f64, f64) { + ( + self.dut_only_failure.interval.lo, + self.dut_only_failure.interval.hi, + ) + } + + /// Shots on which both decoders returned mismatching predictions. + #[getter] + const fn both_failed(&self) -> u64 { + self.counts.both_failed() + } + + /// Jeffreys posterior-mean proportion for shots where both decoders failed. + #[getter] + const fn both_failed_proportion(&self) -> f64 { + self.both_failed.point + } + + /// Equal-tailed Jeffreys interval for the both-failed proportion. + #[getter] + const fn both_failed_interval(&self) -> (f64, f64) { + (self.both_failed.interval.lo, self.both_failed.interval.hi) + } + + fn __repr__(&self) -> String { + format!( + "DecoderComparisonResult(shots={}, dut_only_failures={}, both_failed={})", + self.total_shots, + self.counts.dut_only_failures(), + self.counts.both_failed(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Debug)] + enum StubResult { + Prediction(ObsMask), + Error, + } + + struct StubDecoder { + expected_syndromes: Vec>, + results: Vec, + next: usize, + } + + impl StubDecoder { + fn new(expected_syndromes: &[Vec], results: Vec) -> Self { + assert_eq!(expected_syndromes.len(), results.len()); + Self { + expected_syndromes: expected_syndromes.to_vec(), + results, + next: 0, + } + } + } + + impl ObservableDecoder for StubDecoder { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + assert_eq!(syndrome, self.expected_syndromes[self.next]); + let result = match &self.results[self.next] { + StubResult::Prediction(mask) => Ok(mask.clone()), + StubResult::Error => Err(DecoderError::DecodingFailed("stub error".into())), + }; + self.next += 1; + result + } + } + + fn mask(bits: &[usize]) -> ObsMask { + let mut mask = ObsMask::new(); + for &bit in bits { + mask.set(bit); + } + mask + } + + fn predictions(masks: &[ObsMask]) -> Vec { + masks.iter().cloned().map(StubResult::Prediction).collect() + } + + fn compare( + shots: &[(Vec, ObsMask)], + dut_results: Vec, + reference_results: Vec, + ) -> DecoderComparisonCounts { + let syndromes: Vec> = shots.iter().map(|(s, _)| s.clone()).collect(); + let mut dut = StubDecoder::new(&syndromes, dut_results); + let mut reference = StubDecoder::new(&syndromes, reference_results); + let mut syndrome = vec![0; syndromes.first().map_or(0, Vec::len)]; + compare_decoder_outcomes( + shots.len(), + &mut syndrome, + |shot, buffer| { + buffer.copy_from_slice(&shots[shot].0); + shots[shot].1.clone() + }, + &mut dut, + &mut reference, + ) + } + + fn sample_shots() -> Vec<(Vec, ObsMask)> { + vec![ + (vec![0, 0], mask(&[])), + (vec![1, 0], mask(&[0])), + (vec![0, 1], mask(&[1])), + (vec![1, 1], mask(&[0, 1])), + ] + } + + #[test] + fn both_decoders_correct_puts_all_mass_in_correct_correct() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let counts = compare(&shots, predictions(&truths), predictions(&truths)); + + assert_eq!(counts.cells(), &[[4, 0, 0], [0, 0, 0], [0, 0, 0]]); + assert_eq!(counts.dut_only_failures(), 0); + } + + #[test] + fn dut_only_failures_count_a_known_wrong_subset() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = truths.clone(); + dut[1] = mask(&[]); + dut[3] = mask(&[]); + + let counts = compare(&shots, predictions(&dut), predictions(&truths)); + + // Shots 1 and 3 are deliberately wrong for the DUT: 2 DUT-only failures. + assert_eq!(counts.dut_only_failures(), 2); + assert_eq!(counts.cells(), &[[2, 0, 0], [2, 0, 0], [0, 0, 0]]); + } + + #[test] + fn dut_errors_are_not_mismatches_and_do_not_abort() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = predictions(&truths); + dut[1] = StubResult::Error; + dut[3] = StubResult::Error; + + let counts = compare(&shots, dut, predictions(&truths)); + + assert_eq!(counts.cells(), &[[2, 0, 0], [0, 0, 0], [2, 0, 0]]); + assert_eq!(counts.cells()[DecoderOutcome::Mismatch.index()][0], 0); + } + + #[test] + fn reference_errors_are_counted_and_do_not_abort() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut reference = predictions(&truths); + reference[0] = StubResult::Error; + reference[2] = StubResult::Error; + + let counts = compare(&shots, predictions(&truths), reference); + + assert_eq!(counts.cells(), &[[2, 0, 2], [0, 0, 0], [0, 0, 0]]); + } + + #[test] + fn wide_observable_difference_above_bit_63_is_preserved() { + let wide_truth = mask(&[70]); + let shots = vec![(vec![1], wide_truth.clone())]; + let counts = compare( + &shots, + predictions(&[ObsMask::new()]), + predictions(&[wide_truth]), + ); + + assert_eq!(counts.cells(), &[[0, 0, 0], [1, 0, 0], [0, 0, 0]]); + assert_eq!(counts.dut_only_failures(), 1); + } + + #[test] + fn headline_interval_matches_pecos_num_helper() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = truths.clone(); + dut[1] = mask(&[]); + let summary = PyDecoderComparisonResult::new( + compare(&shots, predictions(&dut), predictions(&truths)), + 0.05, + ) + .expect("valid Jeffreys inputs"); + let expected = jeffreys_interval(1, 4, 0.05).expect("valid direct helper inputs"); + + assert_eq!(summary.dut_only_failure.interval, expected); + // Both sides come from the same helper call, so the point estimate must be + // bit-identical; compare bit patterns rather than floats. + assert_eq!( + summary.dut_only_failure.point.to_bits(), + expected.point.to_bits() + ); + } + + #[test] + fn comparison_is_deterministic_for_the_same_batch() { + let shots = sample_shots(); + let truths: Vec = shots.iter().map(|(_, truth)| truth.clone()).collect(); + let mut dut = truths.clone(); + dut[2] = mask(&[]); + + let first = compare(&shots, predictions(&dut), predictions(&truths)); + let second = compare(&shots, predictions(&dut), predictions(&truths)); + + assert_eq!(first, second); + } +} diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index 0de139841..293d60860 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -50,6 +50,7 @@ mod gate_registry_bindings; mod graph_bindings; mod namespace_modules; mod num_bindings; +mod observable_flips_bindings; mod pauli_bindings; mod pauli_prop_bindings; mod pauli_sequence_bindings; diff --git a/python/pecos-rslib/src/observable_flips_bindings.rs b/python/pecos-rslib/src/observable_flips_bindings.rs new file mode 100644 index 000000000..f4c65ce1e --- /dev/null +++ b/python/pecos-rslib/src/observable_flips_bindings.rs @@ -0,0 +1,270 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Shared Python value type for logical-observable flip predictions and ground truth. + +use pecos_decoder_core::obs_mask::ObsMask; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyInt, PyList}; + +/// Logical-observable flips with an explicit observable count. +#[pyclass( + name = "ObservableFlips", + module = "pecos_rslib.decoders", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyObservableFlips { + mask: ObsMask, + num_observables: usize, +} + +impl PyObservableFlips { + pub(crate) fn from_mask_value(mask: ObsMask, num_observables: usize) -> Self { + debug_assert!(mask.iter_set_bits().all(|index| index < num_observables)); + Self { + mask, + num_observables, + } + } + + pub(crate) fn from_u8_bits(bits: &[u8]) -> Self { + let mut mask = ObsMask::new(); + for (index, &bit) in bits.iter().enumerate() { + if bit != 0 { + mask.set(index); + } + } + Self::from_mask_value(mask, bits.len()) + } + + fn normalize_index(&self, index: isize) -> PyResult { + let normalized = if index < 0 { + self.num_observables.checked_add_signed(index) + } else { + usize::try_from(index).ok() + }; + normalized + .filter(|&i| i < self.num_observables) + .ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err(format!( + "Observable index {index} out of range (num_observables={})", + self.num_observables + )) + }) + } + + fn validate_mask( + mask: &ObsMask, + mask_display: &str, + num_observables: usize, + ) -> Result<(), String> { + if let Some(index) = mask + .iter_set_bits() + .filter(|&index| index >= num_observables) + .max() + { + return Err(format!( + "mask={mask_display} has bit {index} set at or above \ + num_observables={num_observables}" + )); + } + Ok(()) + } + + fn value_eq(&self, other: &Self) -> bool { + self.num_observables == other.num_observables && self.mask == other.mask + } +} + +#[pymethods] +impl PyObservableFlips { + fn __len__(&self) -> usize { + self.num_observables + } + + fn __getitem__(&self, index: isize) -> PyResult { + self.normalize_index(index).map(|i| self.mask.get(i)) + } + + fn __iter__(&self, py: Python<'_>) -> PyResult> { + let bits = (0..self.num_observables).map(|index| self.mask.get(index)); + Ok(PyList::new(py, bits)?.call_method0("__iter__")?.unbind()) + } + + fn __eq__(&self, other: &Bound<'_, PyAny>, py: Python<'_>) -> PyResult> { + let Ok(other) = other.extract::>() else { + return Ok(py.NotImplemented()); + }; + Ok(self + .value_eq(&other) + .into_pyobject(py)? + .to_owned() + .into_any() + .unbind()) + } + + fn indices(&self) -> Vec { + self.mask.iter_set_bits().collect() + } + + #[getter] + fn mask(&self, py: Python<'_>) -> PyResult> { + obsmask_to_py(py, &self.mask) + } + + fn __repr__(&self, py: Python<'_>) -> PyResult { + let mask = obsmask_to_py(py, &self.mask)?; + Ok(format!( + "ObservableFlips(num_observables={}, mask={})", + self.num_observables, + mask.bind(py).str()?.to_str()? + )) + } + + #[staticmethod] + fn from_mask(mask: &Bound<'_, PyAny>, num_observables: usize) -> PyResult { + let mask = as_index(mask)?; + let mask_display = mask.str()?.to_str()?.to_owned(); + if mask.lt(0)? { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "mask={mask_display} is negative; an observable mask is unsigned" + ))); + } + let mask_value = py_to_obsmask(&mask)?; + Self::validate_mask(&mask_value, &mask_display, num_observables) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self::from_mask_value(mask_value, num_observables)) + } + + #[staticmethod] + fn from_bits(bits: &Bound<'_, PyAny>) -> PyResult { + let mut mask = ObsMask::new(); + let mut len = 0usize; + for (index, item) in bits.try_iter()?.enumerate() { + if bit_value(&item?, index)? { + mask.set(index); + } + len = index + 1; + } + Ok(Self::from_mask_value(mask, len)) + } +} + +/// Normalize an integer-like Python object to a true `int` via `__index__`. +/// +/// This is the protocol Python itself uses wherever an integer is required, so +/// `bool` and NumPy integer scalars are accepted on the same footing as `int`. +/// This also accepts values returned by integer-oriented libraries, while +/// observable masks routinely arrive as NumPy scalars. +fn as_index<'py>(value: &Bound<'py, PyAny>) -> PyResult> { + match value.call_method0("__index__") { + Ok(index) => Ok(index), + // A missing `__index__` means "not an integer", which Python reports as a + // TypeError. A `__index__` that exists and raises is a real error: pass it on. + Err(err) if err.is_instance_of::(value.py()) => { + let type_name = value + .get_type() + .name() + .map_or_else(|_| "object".to_owned(), |name| name.to_string()); + Err(pyo3::exceptions::PyTypeError::new_err(format!( + "'{type_name}' object cannot be interpreted as an integer" + ))) + } + Err(err) => Err(err), + } +} + +/// Read one entry of a `from_bits` iterable. +/// +/// Booleans (including NumPy booleans) are taken directly; anything integer-like +/// must be exactly 0 or 1. Truthiness is deliberately not used -- a non-bit value +/// is an error, not something to coerce. +fn bit_value(item: &Bound<'_, PyAny>, index: usize) -> PyResult { + if let Ok(bit) = item.extract::() { + return Ok(bit); + } + match as_index(item)?.extract::()? { + 0 => Ok(false), + 1 => Ok(true), + value => Err(pyo3::exceptions::PyValueError::new_err(format!( + "bit at index {index} must be 0 or 1, got {value}" + ))), + } +} + +/// Convert a wide observable mask to a Python integer (arbitrary precision). +pub(crate) fn obsmask_to_py(py: Python<'_>, mask: &ObsMask) -> PyResult> { + if let Some(value) = mask.to_u64() { + return Ok(value.into_pyobject(py)?.into_any().unbind()); + } + let mut bytes = Vec::with_capacity(mask.words().len() * 8); + for &word in mask.words() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let py_bytes = PyBytes::new(py, &bytes); + Ok(py + .get_type::() + .call_method1("from_bytes", (py_bytes, "little"))? + .unbind()) +} + +/// Convert a Python integer (arbitrary precision) to a wide observable mask. +pub(crate) fn py_to_obsmask(value: &Bound<'_, 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 = bytes + .chunks(8) + .map(|chunk| { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + u64::from_le_bytes(buf) + }) + .collect::>(); + Ok(ObsMask::from_words(&words)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_normalization_checks_both_ends() { + let flips = PyObservableFlips::from_u8_bits(&[1, 0]); + + assert_eq!(flips.normalize_index(0).unwrap(), 0); + assert_eq!(flips.normalize_index(-1).unwrap(), 1); + assert!(flips.normalize_index(2).is_err()); + assert!(flips.normalize_index(-3).is_err()); + } + + #[test] + fn equality_includes_length() { + let short = PyObservableFlips::from_mask_value(ObsMask::from_u64(1), 1); + let long = PyObservableFlips::from_mask_value(ObsMask::from_u64(1), 2); + + assert!(!short.value_eq(&long)); + } + + #[test] + fn mask_validation_rejects_bits_outside_length() { + let mask = ObsMask::from_u64(4); + let error = PyObservableFlips::validate_mask(&mask, "4", 2).unwrap_err(); + + assert!(error.contains("mask=4")); + assert!(error.contains("num_observables=2")); + } +} diff --git a/python/pecos-rslib/src/phir_classical_interpreter.rs b/python/pecos-rslib/src/phir_classical_interpreter.rs index 7e0fc7201..156896c27 100644 --- a/python/pecos-rslib/src/phir_classical_interpreter.rs +++ b/python/pecos-rslib/src/phir_classical_interpreter.rs @@ -1006,7 +1006,7 @@ fn build_noise_model( return Ok(Box::new(builder.inner.build())); } if let Ok(builder) = obj.extract::() { - return Ok(Box::new(builder.inner.build())); + return Ok(Box::new(builder.validated_inner()?.build())); } if let Ok(builder) = obj.extract::() { return Ok(Box::new(builder.inner.build())); diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index efe540af8..f3fc3b393 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -654,7 +654,8 @@ impl PySimBuilder { /// This is the preferred programmatic tracing path for QIS-control simulations. /// It collects the structured trace in memory first, and any JSON dumping /// configured via `trace_operations(...)` becomes an optional mirror/export. - fn capture_operation_trace(&self, py: Python<'_>) -> PyResult> { + #[pyo3(signature = (shots=1))] + fn capture_operation_trace(&self, py: Python<'_>, shots: usize) -> PyResult> { use crate::engine_builders::{ PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, PyGeneralNoiseModelBuilder, @@ -761,7 +762,7 @@ impl PySimBuilder { if let Some(ref noise_py) = builder.noise_builder { sim_builder = if let Ok(general) = noise_py.extract::(py) { - sim_builder.noise(general.inner.clone()) + sim_builder.noise(general.validated_inner()?) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -778,7 +779,12 @@ impl PySimBuilder { }; } - sim_builder.run(1).map_err(|e| { + if shots == 0 { + return Err(PyValueError::new_err( + "capture_operation_trace shots must be greater than zero", + )); + } + sim_builder.run(shots).map_err(|e| { PyRuntimeError::new_err(format!("Trace capture simulation failed: {e}")) })?; @@ -946,7 +952,7 @@ impl PySimBuilder { if let Some(ref noise_py) = builder.noise_builder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1122,7 +1128,7 @@ impl PySimBuilder { if let Some(ref noise_py) = builder.noise_builder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1288,7 +1294,7 @@ impl PySimBuilder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1488,7 +1494,7 @@ impl PySimBuilder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1660,7 +1666,7 @@ impl PySimBuilder { sim_builder = Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) + Ok(sim_builder.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { @@ -1873,7 +1879,7 @@ fn apply_noise_to_facade( Python::attach(|py| -> PyResult<_> { if let Ok(general) = noise_py.extract::(py) { - Ok(facade.noise(general.inner.clone())) + Ok(facade.noise(general.validated_inner()?)) } else if let Ok(depolarizing) = noise_py.extract::(py) { Ok(facade.noise(depolarizing.inner.clone())) } else if let Ok(biased) = noise_py.extract::(py) { diff --git a/python/pecos-rslib/tests/test_direct_builder.py b/python/pecos-rslib/tests/test_direct_builder.py index 4f81dfd0b..77ab9387d 100644 --- a/python/pecos-rslib/tests/test_direct_builder.py +++ b/python/pecos-rslib/tests/test_direct_builder.py @@ -29,10 +29,10 @@ def test_direct_builder_noise(self) -> None: builder = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) ) # Use sim() with noise builder @@ -59,7 +59,7 @@ def test_builder_with_pauli_model(self) -> None: builder = ( GeneralNoiseModelBuilder() .with_seed(42) - .with_p1_probability(0.1) # High error rate for testing + .with_p1(0.1) # High error rate for testing .with_p1_pauli_model({"X": 0.5, "Y": 0.3, "Z": 0.2}) ) @@ -90,7 +90,7 @@ def test_builder_with_method_chaining(self) -> None: prog = Qasm.from_string(qasm) # Create builder with fluent API - builder = GeneralNoiseModelBuilder().with_seed(42).with_p2_probability(0.01) + builder = GeneralNoiseModelBuilder().with_seed(42).with_p2(0.01) # Use sim() with direct method chaining results = sim(prog).seed(42).noise(builder).run(100).to_dict() @@ -103,7 +103,7 @@ def test_builder_chaining_validation(self) -> None: """Test that builder methods validate parameters.""" # Test validation - Rust panics raise BaseException with "PanicException" in the name with pytest.raises(BaseException, match="Probability must be between 0 and 1"): - GeneralNoiseModelBuilder().with_p1_probability(1.5) + GeneralNoiseModelBuilder().with_p1(1.5) # Scale validation happens at build time, not when setting the value # So we need to build and use the noise model to trigger validation @@ -133,8 +133,8 @@ def test_rust_vs_native_noise_models(self) -> None: # Create builder builder = GeneralNoiseModelBuilder() builder.with_seed(42) - builder.with_p1_probability(0.001) - builder.with_p2_probability(0.01) + builder.with_p1(0.001) + builder.with_p2(0.01) # Test that builder can be used directly in .noise() method results = sim(prog).noise(builder).seed(42).run(100).to_dict() diff --git a/python/pecos-rslib/tests/test_qasm_pythonic.py b/python/pecos-rslib/tests/test_qasm_pythonic.py index cfaeefa0c..21329a01c 100644 --- a/python/pecos-rslib/tests/test_qasm_pythonic.py +++ b/python/pecos-rslib/tests/test_qasm_pythonic.py @@ -109,10 +109,10 @@ def test_sim_qasm_with_custom_noise_builder(self) -> None: noise_builder = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) # Low single-qubit error - .with_p2_probability(0.1) # High two-qubit error - .with_meas_0_probability(0.02) - .with_meas_1_probability(0.02) + .with_p1(0.001) # Low single-qubit error + .with_p2(0.1) # High two-qubit error + .with_p_meas_0(0.02) + .with_p_meas_1(0.02) ) results = sim(prog).noise(noise_builder).run(1000).to_dict() diff --git a/python/pecos-rslib/tests/test_sim_api.py b/python/pecos-rslib/tests/test_sim_api.py index 482e585a6..daa4f8df4 100644 --- a/python/pecos-rslib/tests/test_sim_api.py +++ b/python/pecos-rslib/tests/test_sim_api.py @@ -149,12 +149,11 @@ def test_general_noise_model(self) -> None: program = Qasm.from_string(qasm) engine = qasm_engine().program(program) - # Test with general noise model - noise = general_noise() + # Preserve the historical demonstration preset for this broad smoke test. + noise = general_noise().auto() results = sim(program).classical(engine).noise(noise).run(100).to_dict() - # General noise model may introduce errors even without explicit configuration - # Just check that we get results + # Just check that we get results. assert "c" in results assert len(results["c"]) == 100 diff --git a/python/pecos-rslib/tests/test_sim_qasm.py b/python/pecos-rslib/tests/test_sim_qasm.py index 9ee6fe934..895e2ab3d 100644 --- a/python/pecos-rslib/tests/test_sim_qasm.py +++ b/python/pecos-rslib/tests/test_sim_qasm.py @@ -156,11 +156,7 @@ def test_noise_models(self) -> None: sim(Qasm.from_string(qasm_bell)) .seed(42) .noise( - depolarizing_noise() - .with_prep_probability(0.01) - .with_meas_probability(0.01) - .with_p1_probability(0.001) - .with_p2_probability(0.1), + depolarizing_noise().with_p_prep(0.01).with_p_meas(0.01).with_p1(0.001).with_p2(0.1), ) .run(1000) ) @@ -192,8 +188,8 @@ def test_noise_models(self) -> None: errors = sum(1 for val in results["c"] if val == 0) assert errors > 0 - # General noise - shot_vec = sim(Qasm.from_string(qasm)).noise(general_noise()).run(10) + # Preserve the historical demonstration preset in this all-model smoke test. + shot_vec = sim(Qasm.from_string(qasm)).noise(general_noise().auto()).run(10) results = shot_vec.to_dict() assert len(results["c"]) == 10 diff --git a/python/pecos-rslib/tests/test_structured_config.py b/python/pecos-rslib/tests/test_structured_config.py index f06932b89..82cf66db5 100644 --- a/python/pecos-rslib/tests/test_structured_config.py +++ b/python/pecos-rslib/tests/test_structured_config.py @@ -17,19 +17,12 @@ class TestDirectMethodChaining: def test_general_noise_model_builder_basic(self) -> None: """Test basic general_noise() usage.""" - noise = ( - general_noise() - .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) - ) + noise = general_noise().with_seed(42).with_p1(0.001).with_p2(0.01).with_p_meas_0(0.002).with_p_meas_1(0.002) # The noise object is already a builder, can be used directly # Test that it's a valid builder by checking it has builder methods assert hasattr(noise, "with_seed") - assert hasattr(noise, "with_p1_probability") + assert hasattr(noise, "with_p1") def test_general_noise_model_builder_validation(self) -> None: """Test general_noise() parameter validation.""" @@ -38,11 +31,11 @@ def test_general_noise_model_builder_validation(self) -> None: # Test invalid probability values # Rust panics raise BaseException with pytest.raises(BaseException, match=r".*"): # Rust panic - any error message - builder.with_p1_probability(-0.1) # Negative probability + builder.with_p1(-0.1) # Negative probability builder = general_noise() with pytest.raises(BaseException, match=r".*"): # Rust panic - any error message - builder.with_p2_probability(1.5) # > 1 probability + builder.with_p2(1.5) # > 1 probability def test_direct_noise_builder_with_sim(self) -> None: """Test using builders directly with sim().""" @@ -59,7 +52,7 @@ def test_direct_noise_builder_with_sim(self) -> None: prog = Qasm.from_string(qasm) # Create a configured noise builder - noise = general_noise().with_seed(42).with_p1_probability(0.001).with_p2_probability(0.01) + noise = general_noise().with_seed(42).with_p1(0.001).with_p2(0.01) # Use the builder directly with sim() results = sim(prog).noise(noise).run(1000).to_dict() @@ -133,14 +126,7 @@ def test_complex_circuit_with_noise(self) -> None: prog = Qasm.from_string(qasm) # Configure general noise with specific parameters - noise = ( - general_noise() - .with_seed(123) - .with_p1_probability(0.005) - .with_p2_probability(0.02) - .with_meas_0_probability(0.01) - .with_meas_1_probability(0.01) - ) + noise = general_noise().with_seed(123).with_p1(0.005).with_p2(0.02).with_p_meas_0(0.01).with_p_meas_1(0.01) results = sim(prog).noise(noise).run(1000).to_dict() diff --git a/python/quantum-pecos/src/pecos/__init__.py b/python/quantum-pecos/src/pecos/__init__.py index f58c562a7..079818a13 100644 --- a/python/quantum-pecos/src/pecos/__init__.py +++ b/python/quantum-pecos/src/pecos/__init__.py @@ -286,6 +286,7 @@ def __getattr__(name: str): # Import program wrappers from programs submodule for convenience # These can also be accessed via pecos.programs.Qasm, etc. from pecos.programs import Guppy, Hugr, PhirJson, ProgramWrapper, Qasm, Qis, Wasm, Wat +from pecos.qec.surface.decode import NoiseParameters from pecos.tracing import ( capture_qis_operation_trace, qis_operation_trace_to_tick_circuit, @@ -338,6 +339,7 @@ def __getattr__(name: str): "Inexact", "Integer", "Nanoseconds", + "NoiseParameters", "Numeric", "Pauli", "PauliString", diff --git a/python/quantum-pecos/src/pecos/_qis_trace_replay.py b/python/quantum-pecos/src/pecos/_qis_trace_replay.py index ac5cc9f25..a82514f13 100644 --- a/python/quantum-pecos/src/pecos/_qis_trace_replay.py +++ b/python/quantum-pecos/src/pecos/_qis_trace_replay.py @@ -196,7 +196,7 @@ def tuple_args(payload: object, op_name: str, arity: int) -> tuple[Any, ...]: float(theta), [(mapped_slot(int(qubit_a), op_name), mapped_slot(int(qubit_b), op_name))], ) - elif op_name == "Measure": + elif op_name in {"Measure", "MeasureLeaked"}: program_id, result_id = tuple_args(payload, op_name, 2) measurement_qubit = mapped_slot(int(program_id), op_name) if _should_add_global_measurement_crosstalk_payload( @@ -321,9 +321,12 @@ def _replay_lowered_qis_trace_into_tick_circuit( a tick --- matching the parallel structure of the abstract circuit. MeasIds flow from runtime-lowered measurement provenance: - ``lowered_quantum_ops`` MZ entries must carry ``measurement_result_ids``. - This avoids inferring lowered measurement IDs from raw QIS operation order, - which is not stable under runtime scheduling or transport. + ``lowered_quantum_ops`` MZ and MeasureLeaked entries must carry + ``measurement_result_ids``. MeasureLeaked is replayed as the Boolean MZ + component of the accepted no-leakage path; leakage probability remains + outside the Pauli circuit/DEM model. This avoids inferring lowered + measurement IDs from raw QIS operation order, which is not stable under + runtime scheduling or transport. """ measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( measurement_crosstalk_topology, @@ -372,10 +375,10 @@ def _replay_lowered_qis_trace_into_tick_circuit( msg = f"Lowered Idle gate expected one duration param, got {params!r}" raise ValueError(msg) tick.idle(_runtime_idle_seconds_to_time_units(params[0]), qubits) - elif gate_type == "MZ": + elif gate_type in {"MZ", "MeasureLeaked"}: if not isinstance(gate.get("measurement_result_ids"), list): msg = ( - "Lowered MZ trace is missing measurement_result_ids; " + f"Lowered {gate_type} trace is missing measurement_result_ids; " "rebuild PECOS so runtime-lowered measurements carry " "their result-id provenance instead of relying on " "operation-order inference." @@ -387,7 +390,10 @@ def _replay_lowered_qis_trace_into_tick_circuit( gate_type, ) if len(meas_ids) != len(qubits): - msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" + msg = ( + f"Lowered {gate_type} gate carries {len(meas_ids)} " + f"measurement_result_ids for {len(qubits)} qubit(s)" + ) raise ValueError(msg) if _should_add_global_measurement_crosstalk_payload( measurement_crosstalk_topology, @@ -645,6 +651,8 @@ def source_measurement_ids_from_operation_trace(chunks: list[dict[str, Any]]) -> if not isinstance(quantum, Mapping): continue measure = quantum.get("Measure") + if measure is None: + measure = quantum.get("MeasureLeaked") if not isinstance(measure, Sequence) or isinstance(measure, (str, bytes)) or len(measure) != 2: continue result_id = measure[1] diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index 56119208e..cf2637c5a 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -24,10 +24,12 @@ BpResult, CheckMatrix, DemAwareDecoder, + DemAwareResult, FusionBlossomDecoder, MinSumBpBuilder, MinSumBpDecoder, MwpmResult, + ObservableFlips, PyMatchingDecoder, RelayBpBuilder, RelayBpDecoder, @@ -50,11 +52,13 @@ "BpResult", "CheckMatrix", "DemAwareDecoder", + "DemAwareResult", "DummyDecoder", "FusionBlossomDecoder", "MinSumBpBuilder", "MinSumBpDecoder", "MwpmResult", + "ObservableFlips", "PyMatchingDecoder", "RelayBpBuilder", "RelayBpDecoder", diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 1662aa7e7..090a9c1bd 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -39,6 +39,7 @@ InfluenceBuilder, ParsedDem, PauliFrameLookup, + SampleBatch, assert_dems_equivalent, compare_dems_exact, compare_dems_statistical, @@ -77,7 +78,7 @@ # Python from_guppy convenience constructor attached. The Guppy/Selene trace # pipeline is Python-only, so it cannot live in the Rust extension without a # dependency cycle. -from pecos.qec.dem import DetectorErrorModel, build_dem_from_guppy +from pecos.qec.dem import DetectorErrorModel, GuppyDemBuilder, build_dem_from_guppy from pecos.qec.dem_spec import ( Detector, GuppyDemBuild, @@ -92,6 +93,10 @@ PauliType, StabilizerCheck, ) +from pecos.qec.guppy_output_dem import ( + InferredGuppyDemAnnotations, + infer_guppy_dem_annotations, +) from pecos.qec.protocols import ( InnerCodeGeometry, MSDProtocol, @@ -128,6 +133,7 @@ "DemBuilder", "DemSampler", "DemSamplerBuilder", + "SampleBatch", "DetectorErrorModel", "Detector", "EquivalenceResult", @@ -136,12 +142,15 @@ "PauliFrameLookup", "ParsedDem", "GuppyDemBuild", + "GuppyDemBuilder", + "InferredGuppyDemAnnotations", "Observable", "assert_dems_equivalent", "compare_dems_exact", "compare_dems_statistical", "verify_dem_equivalence", "build_dem_from_guppy", + "infer_guppy_dem_annotations", "rec", "result_ref", "surface_memory_dem_spec", diff --git a/python/quantum-pecos/src/pecos/qec/_idle_noise.py b/python/quantum-pecos/src/pecos/qec/_idle_noise.py new file mode 100644 index 000000000..7ba46872c --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/_idle_noise.py @@ -0,0 +1,245 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Shared translation of structured idle-noise families to DEM primitives. + +The linear family is a categorical Pauli channel. The Rust DEM builder first +groups its non-empty propagated flip signatures and only then converts distinct +signatures to independent mechanisms. An infeasible exact conversion uses a +non-negative boundary fit and exposes its quantified both-fire residual on the +DEM. Sine-squared axes are independent already and remain separate mechanisms +from the linear family. +""" + +from __future__ import annotations + +import math +import warnings +from collections.abc import Mapping + +_IDLE_MODEL_NORMALIZATION_TOLERANCE = 1.0e-5 +_IDLE_MODEL_FLOAT_EPSILON = 1.0e-10 + + +def _validate_idle_family_model( + *, + rate: float | None, + rate_name: str, + model: Mapping[str, float] | None, + model_name: str, + default_model: Mapping[str, float], + accepted_keys: frozenset[str], + require_normalized: bool, + nonzero_rate_guidance: str | None = None, + zero_only_key_guidance: Mapping[str, str] | None = None, +) -> tuple[float, dict[str, float]] | None: + """Validate one structured idle family and return its rate and multipliers.""" + if model is not None and rate is None: + msg = f"{model_name} requires {rate_name}; otherwise the model is inert" + raise ValueError(msg) + if rate is None: + return None + + if isinstance(rate, bool): + msg = f"{rate_name} must be a finite, non-negative float" + raise TypeError(msg) + try: + numeric_rate = float(rate) + except (TypeError, ValueError) as exc: + msg = f"{rate_name} must be a finite, non-negative float" + raise ValueError(msg) from exc + if not math.isfinite(numeric_rate) or numeric_rate < 0.0: + msg = f"{rate_name} must be a finite, non-negative float" + raise ValueError(msg) + if numeric_rate != 0.0 and nonzero_rate_guidance is not None: + raise ValueError(nonzero_rate_guidance) + + if model is not None and not isinstance(model, Mapping): + expected = ", ".join(repr(key) for key in sorted(accepted_keys)) + msg = f"{model_name} must be a mapping from {expected} to relative-rate multipliers" + raise ValueError(msg) + selected_model = model if model is not None else default_model + validated_model: dict[str, float] = {} + for key, multiplier in selected_model.items(): + if key not in accepted_keys: + expected = ", ".join(repr(valid_key) for valid_key in sorted(accepted_keys)) + msg = f"invalid {model_name} key {key!r}; expected {expected}" + raise ValueError(msg) + try: + numeric_multiplier = float(multiplier) + except (TypeError, ValueError) as exc: + msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" + raise ValueError(msg) from exc + if not math.isfinite(numeric_multiplier) or numeric_multiplier < 0.0: + msg = f"{model_name} multiplier for {key!r} must be a finite, non-negative float" + raise ValueError(msg) + validated_model[key] = numeric_multiplier + + if require_normalized: + total_multiplier = sum(validated_model.values()) + if total_multiplier <= 0.0 or abs(total_multiplier - 1.0) > _IDLE_MODEL_NORMALIZATION_TOLERANCE: + msg = ( + f"{model_name} multipliers must sum to 1.0 within tolerance " + f"{_IDLE_MODEL_NORMALIZATION_TOLERANCE:g}; got {total_multiplier}" + ) + raise ValueError(msg) + if abs(total_multiplier - 1.0) > _IDLE_MODEL_FLOAT_EPSILON: + validated_model = {key: multiplier / total_multiplier for key, multiplier in validated_model.items()} + + for key, guidance in (zero_only_key_guidance or {}).items(): + if validated_model.get(key, 0.0) != 0.0: + msg = f"{model_name} key {key!r} has a nonzero multiplier; {guidance}" + raise ValueError(msg) + + return numeric_rate, validated_model + + +def _translate_structured_idle_noise( + *, + p_idle_linear: float | None, + p_idle_linear_model: Mapping[str, float] | None, + p_idle_sin_squared: float | None, + p_idle_sin_squared_model: Mapping[str, float] | None, + p_idle_coherent: float | None, + p_idle_coherent_model: Mapping[str, float] | None, + p_idle_linear_rate: float | None, + p_idle_quadratic_rate: float | None, + p_idle_x_linear_rate: float | None, + p_idle_y_linear_rate: float | None, + p_idle_z_linear_rate: float | None, + p_idle_quadratic_sine_rate: float | None, + p_idle_x_quadratic_sine_rate: float | None, + p_idle_y_quadratic_sine_rate: float | None, + p_idle_z_quadratic_sine_rate: float | None, +) -> tuple[ + float | None, + float | None, + float | None, + float | None, + float | None, + float | None, +]: + """Validate and translate engines-style idle noise to DEM primitives.""" + _validate_idle_family_model( + rate=p_idle_coherent, + rate_name="p_idle_coherent", + model=p_idle_coherent_model, + model_name="p_idle_coherent_model", + default_model={"RX": 1.0, "RY": 1.0, "RZ": 1.0}, + accepted_keys=frozenset({"RX", "RY", "RZ"}), + require_normalized=False, + nonzero_rate_guidance=( + "the standard DEM builder cannot represent coherent idle noise; its previous behavior silently stored " + "the Pauli twirl, discarding exactly the coherence that was requested. The EEG coherent route in " + "exp/pecos-eeg is the consumer that can represent it, and only with an RZ generator even there. The " + "honest stochastic equivalent, which is the exact Pauli twirl of RZ(rate * t), is " + "p_idle_sin_squared=rate/2 with p_idle_sin_squared_model={'Z': 1.0}" + ), + ) + + linear_primitives = { + "p_idle_linear_rate": p_idle_linear_rate, + "p_idle_x_linear_rate": p_idle_x_linear_rate, + "p_idle_y_linear_rate": p_idle_y_linear_rate, + "p_idle_z_linear_rate": p_idle_z_linear_rate, + } + if (p_idle_linear is not None or p_idle_linear_model is not None) and any( + value is not None for value in linear_primitives.values() + ): + conflicts = ", ".join(name for name, value in linear_primitives.items() if value is not None) + msg = f"p_idle_linear/p_idle_linear_model cannot be combined with low-level idle rate(s): {conflicts}" + raise ValueError(msg) + sine_primitives = { + "p_idle_quadratic_sine_rate": p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": p_idle_z_quadratic_sine_rate, + } + if (p_idle_sin_squared is not None or p_idle_sin_squared_model is not None) and any( + value is not None for value in sine_primitives.values() + ): + conflicts = ", ".join(name for name, value in sine_primitives.items() if value is not None) + msg = f"p_idle_sin_squared/p_idle_sin_squared_model cannot be combined with sine-law idle rate(s): {conflicts}" + raise ValueError(msg) + + legacy_replacements = { + "p_idle_linear_rate": ( + p_idle_linear_rate, + ( + "p_idle_linear with p_idle_linear_model={'Z': 1.0} for the engines-consistent interface, " + "or p_idle_z_linear_rate for literal Z-only behavior" + ), + ), + "p_idle_quadratic_rate": ( + p_idle_quadratic_rate, + ( + "p_idle_sin_squared for the engines-consistent dephasing interface, " + "or p_idle_z_quadratic_rate for literal coefficient-style Z-only behavior" + ), + ), + "p_idle_quadratic_sine_rate": ( + p_idle_quadratic_sine_rate, + ( + "p_idle_sin_squared for the engines-consistent sine-law interface, " + "or p_idle_z_quadratic_sine_rate for literal Z-only behavior" + ), + ), + } + for name, (value, replacement) in legacy_replacements.items(): + if value is not None: + warnings.warn( + f"{name} is deprecated; use {replacement}", + DeprecationWarning, + stacklevel=3, + ) + + linear_family = _validate_idle_family_model( + rate=p_idle_linear, + rate_name="p_idle_linear", + model=p_idle_linear_model, + model_name="p_idle_linear_model", + default_model={"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0}, + accepted_keys=frozenset({"X", "Y", "Z", "L"}), + require_normalized=True, + zero_only_key_guidance={ + "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", + }, + ) + if linear_family is not None: + linear_rate, linear_model = linear_family + p_idle_x_linear_rate = linear_rate * linear_model.get("X", 0.0) + p_idle_y_linear_rate = linear_rate * linear_model.get("Y", 0.0) + p_idle_z_linear_rate = linear_rate * linear_model.get("Z", 0.0) + + sin_squared_family = _validate_idle_family_model( + rate=p_idle_sin_squared, + rate_name="p_idle_sin_squared", + model=p_idle_sin_squared_model, + model_name="p_idle_sin_squared_model", + default_model={"X": 1.0, "Y": 1.0, "Z": 1.0}, + accepted_keys=frozenset({"X", "Y", "Z", "L"}), + require_normalized=False, + zero_only_key_guidance={ + "L": "DEM fault propagation is Pauli-only; the engines simulators support and consume leakage models", + }, + ) + if sin_squared_family is not None: + sin_squared_rate, sin_squared_model = sin_squared_family + p_idle_x_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["X"] if sin_squared_model.get("X", 0.0) != 0.0 else None + ) + p_idle_y_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["Y"] if sin_squared_model.get("Y", 0.0) != 0.0 else None + ) + p_idle_z_quadratic_sine_rate = ( + sin_squared_rate * sin_squared_model["Z"] if sin_squared_model.get("Z", 0.0) != 0.0 else None + ) + + return ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 8516ee8a8..b66fbc3c3 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -38,6 +38,8 @@ import hashlib import json +import math +import warnings from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any @@ -47,16 +49,136 @@ measurement_ids_in_execution_order, normalize_traced_tick_circuit, ) -from pecos.qec.dem_spec import GuppyDemBuild, ResultRef, _resolve_dem_specs +from pecos.qec._idle_noise import _translate_structured_idle_noise +from pecos.qec.dem_spec import ( + GuppyDemBuild, + ResultRef, + _resolve_dem_specs, + _resolved_schema_from_validated_json, +) if TYPE_CHECKING: + from typing_extensions import Self + from pecos.qec.dem_spec import Detector, Observable + from pecos.qec.surface.decode import NoiseParameters P1Weights = Mapping[str, float] P2Weights = Mapping[str, float] _GENERATOR_LAYOUT_ATTR = "__pecos_named_measurement_layout_v2__" +_GUPPY_NOISE_KEYWORDS = ( + "p1", + "p1_weights", + "p2", + "p2_weights", + "p2_replacement_approximation", + "p_meas", + "p_prep", + "p_idle_linear", + "p_idle_linear_model", + "p_idle_sin_squared", + "p_idle_sin_squared_model", + "p_idle_coherent", + "p_idle_coherent_model", + "t1", + "t2", + "p_idle_linear_rate", + "p_idle_quadratic_rate", + "p_idle_x_linear_rate", + "p_idle_y_linear_rate", + "p_idle_z_linear_rate", + "p_idle_x_quadratic_rate", + "p_idle_y_quadratic_rate", + "p_idle_z_quadratic_rate", + "p_idle_quadratic_sine_rate", + "p_idle_x_quadratic_sine_rate", + "p_idle_y_quadratic_sine_rate", + "p_idle_z_quadratic_sine_rate", +) +_NOISE_PARAMETER_INTERNAL_IDLE_FIELDS = frozenset( + { + "p_idle_linear_rate", + "p_idle_quadratic_rate", + "p_idle_x_linear_rate", + "p_idle_y_linear_rate", + "p_idle_z_linear_rate", + "p_idle_x_quadratic_rate", + "p_idle_y_quadratic_rate", + "p_idle_z_quadratic_rate", + "p_idle_quadratic_sine_rate", + "p_idle_x_quadratic_sine_rate", + "p_idle_y_quadratic_sine_rate", + "p_idle_z_quadratic_sine_rate", + }, +) + + +class _NoiseKeywordDefault: + """Track whether a flat noise keyword was explicitly supplied.""" + + __slots__ = ("value",) + + def __init__(self, value: Any) -> None: + self.value = value + + def __repr__(self) -> str: + return repr(self.value) + + +_NOISE_DEFAULT_NONE = _NoiseKeywordDefault(None) +_NOISE_DEFAULT_P1 = _NoiseKeywordDefault(0.001) +_NOISE_DEFAULT_P2 = _NoiseKeywordDefault(0.01) + + +def _resolve_guppy_noise(noise: NoiseParameters | None, call_arguments: Mapping[str, Any]) -> dict[str, Any]: + """Resolve one grouped or flat Guppy DEM noise configuration.""" + explicitly_flat = [ + name for name in _GUPPY_NOISE_KEYWORDS if not isinstance(call_arguments[name], _NoiseKeywordDefault) + ] + if noise is None: + return { + name: ( + call_arguments[name].value + if isinstance(call_arguments[name], _NoiseKeywordDefault) + else call_arguments[name] + ) + for name in _GUPPY_NOISE_KEYWORDS + } + + if explicitly_flat: + conflicts = ", ".join(explicitly_flat) + msg = f"noise cannot be combined with flat noise keyword(s): {conflicts}" + raise ValueError(msg) + + # Import locally so dem.py remains below surface.decode in the package's + # initialization graph instead of introducing a module-level back edge. + from pecos.qec.surface.decode import NoiseParameters + + if not isinstance(noise, NoiseParameters): + msg = f"noise must be a NoiseParameters instance or None, got {type(noise).__name__}" + raise TypeError(msg) + + unsupported = { + "p_idle": "use p_idle_linear instead", + "p2_szz": "use the shared p2 rate instead", + "p2_szzdg": "use the shared p2 rate instead", + } + for field, guidance in unsupported.items(): + if getattr(noise, field) is not None: + msg = f"NoiseParameters.{field} is not supported by the Guppy DEM entry points; {guidance}" + raise ValueError(msg) + + expanded = { + name: getattr(noise, f"_{name}" if name in _NOISE_PARAMETER_INTERNAL_IDLE_FIELDS else name) + for name in _GUPPY_NOISE_KEYWORDS + } + for weights_name in ("p1_weights", "p2_weights"): + if expanded[weights_name] is not None: + expanded[weights_name] = dict(expanded[weights_name]) + return expanded + def _certifiable_hugr_bytes(guppy: Any) -> bytes | None: """Return the HUGR bytes that certify this program's static schedule. @@ -136,7 +258,6 @@ def _from_circuit_with_noise( p2_replacement_approximation: str | None, p_meas: float, p_prep: float, - p_idle: float | None, t1: float | None, t2: float | None, p_idle_linear_rate: float | None, @@ -161,7 +282,7 @@ def _from_circuit_with_noise( p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, - p_idle=p_idle, + p_idle=None, t1=t1, t2=t2, p_idle_linear_rate=p_idle_linear_rate, @@ -179,11 +300,48 @@ def _from_circuit_with_noise( ) +def _apply_traced_idle_passes( + circuit: Any, + *, + strip_traced_idles: bool | None, + idle_after_2q_duration: float | None, + idle_noise_parameters: Sequence[float | None], +) -> None: + """Apply requested idle passes and reject idle noise with no target gates.""" + if strip_traced_idles is None: + # Inserting a uniform idle convention on top of runtime-emitted idles + # would double-count idle noise, so insertion implies stripping first. + strip_traced_idles = idle_after_2q_duration is not None + if strip_traced_idles: + circuit.remove_identity() + if idle_after_2q_duration is not None: + if not math.isfinite(idle_after_2q_duration) or idle_after_2q_duration <= 0.0: + msg = ( + "idle_after_2q_duration must be a finite, positive duration; " + f"got {idle_after_2q_duration!r} (a non-positive duration would insert idle " + "gates that contribute zero idle noise)" + ) + raise ValueError(msg) + circuit.insert_idle_after_two_qubit_gates(idle_after_2q_duration) + + if any(value is not None for value in idle_noise_parameters) and circuit.gate_counts_by_type().get("Idle", 0) == 0: + msg = ( + "idle-noise parameters have no idle gates to attach to; either pass " + "idle_after_2q_duration=..., or use a Selene runtime that emits scheduled idles" + ) + raise ValueError(msg) + + class _DetectorErrorModelMixin: """Namespace for the Python Guppy/QIS-trace convenience constructor.""" __slots__ = () + @classmethod + def builder(cls) -> GuppyDemBuilder: + """Create a chained builder for an audited Guppy detector error model.""" + return GuppyDemBuilder() + @classmethod def from_guppy( cls, @@ -193,28 +351,36 @@ def from_guppy( detectors_json: str, observables_json: str = "[]", num_measurements: int | None = None, - p1: float = 0.001, - p1_weights: P1Weights | None = None, - p2: float = 0.01, - p2_weights: P2Weights | None = None, - p2_replacement_approximation: str | None = None, - p_meas: float = 0.001, - p_prep: float = 0.001, - p_idle: float | None = None, - t1: float | None = None, - t2: float | None = None, - p_idle_linear_rate: float | None = None, - p_idle_quadratic_rate: float | None = None, - p_idle_x_linear_rate: float | None = None, - p_idle_y_linear_rate: float | None = None, - p_idle_z_linear_rate: float | None = None, - p_idle_x_quadratic_rate: float | None = None, - p_idle_y_quadratic_rate: float | None = None, - p_idle_z_quadratic_rate: float | None = None, - p_idle_quadratic_sine_rate: float | None = None, - p_idle_x_quadratic_sine_rate: float | None = None, - p_idle_y_quadratic_sine_rate: float | None = None, - p_idle_z_quadratic_sine_rate: float | None = None, + noise: NoiseParameters | None = None, + p1: float = _NOISE_DEFAULT_P1, + p1_weights: P1Weights | None = _NOISE_DEFAULT_NONE, + p2: float = _NOISE_DEFAULT_P2, + p2_weights: P2Weights | None = _NOISE_DEFAULT_NONE, + p2_replacement_approximation: str | None = _NOISE_DEFAULT_NONE, + p_meas: float = _NOISE_DEFAULT_P1, + p_prep: float = _NOISE_DEFAULT_P1, + p_idle_linear: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared: float | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_coherent: float | None = _NOISE_DEFAULT_NONE, + p_idle_coherent_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + t1: float | None = _NOISE_DEFAULT_NONE, + t2: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + strip_traced_idles: bool | None = None, + idle_after_2q_duration: float | None = None, runtime: object | None = None, seed: int = 0, require_hosted_operation_order: bool = False, @@ -228,6 +394,13 @@ def from_guppy( native PECOS fault propagation. All metadata validation happens in the Rust DEM builder (single source of truth). + The three structured idle models contain relative-rate multipliers: + each axis rate is ``family_rate * axis_multiplier``. The linear law is + additive, so its multipliers must sum to 1.0 and coincide with the + engines relative-probability distribution. The nonlinear sine-squared + and coherent laws are not additive, so their finite, non-negative + multipliers have no sum constraint. + Args: guppy: A HUGR-certifiable program: a ``@guppy``-decorated function, a compiled Guppy program (e.g. the object returned by @@ -287,11 +460,18 @@ def from_guppy( num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced circuit; if given, it must match the traced count. - p1: Single-qubit gate Pauli error rate. + noise: Complete grouped noise configuration. When supplied, its + values replace all flat noise keywords, including this entry + point's defaults. In particular, ``NoiseParameters`` defaults such + as ``p1=0.0`` apply instead of this function's ``p1=0.001``. + Mixing ``noise`` with any flat noise keyword is rejected. + p1: Single-qubit gate Pauli error rate. The categorical Pauli + channel is converted after equal propagated signatures merge. p1_weights: Optional relative probabilities over single-qubit Pauli error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must sum to 1.0; ``p1`` remains the total single-qubit error rate. - p2: Two-qubit gate depolarizing rate. + p2: Two-qubit gate depolarizing rate. Its 15 categorical branches + are converted together after propagation. p2_weights: Optional relative probabilities over two-qubit Pauli error labels. Plain labels such as ``"XX"`` are post-gate Pauli branches; labels prefixed by ``"*"`` such as ``"*XX"`` @@ -308,24 +488,77 @@ def from_guppy( entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. - p_idle: Optional uniform depolarizing idle-noise rate per idle duration. + p_idle_linear: Optional total stochastic idle-noise rate linear in + duration. Uses the engines ``GeneralNoiseModel`` convention. + p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, + ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be + finite, non-negative, and sum to 1.0 within ``1e-5``, including + any explicit ``"L"`` weight. Defaults to the engines' uniform + Pauli model. DEM fault propagation is Pauli-only, so ``"L"`` + must be zero here; the engines simulators support nonzero + leakage weights. + p_idle_sin_squared: Optional stochastic sine-law idle rate. An + axis multiplier ``m`` gives probability + ``sin((p_idle_sin_squared * m) * t)^2`` for an idle of duration + ``t``. By default X, Y, and Z each use the full family rate. + p_idle_sin_squared_model: Optional relative-rate multipliers over + ``"X"``, ``"Y"``, ``"Z"``, and ``"L"`` for + ``p_idle_sin_squared``. Values must be finite and non-negative, + with no sum constraint. Defaults to + ``{"X": 1.0, "Y": 1.0, "Z": 1.0}``. DEM fault propagation + is Pauli-only, so ``"L"`` must be zero here; the engines + simulators support nonzero leakage weights. + p_idle_coherent: Optional coherent-rotation rate. The standard DEM + builder cannot represent coherent idle noise and rejects every + nonzero rate rather than silently storing a Pauli twirl that + discards the requested coherence. Use the EEG coherent route + in ``exp/pecos-eeg`` for coherent idle noise; it supports only + an RZ generator. The honest stochastic equivalent—the exact + Pauli twirl of ``RZ(rate * t)``—is + ``p_idle_sin_squared=rate/2`` with + ``p_idle_sin_squared_model={"Z": 1.0}``. Zero has no effect. + p_idle_coherent_model: Optional relative-rate multipliers over + ``"RX"``, ``"RY"``, and ``"RZ"`` for ``p_idle_coherent``. + Values must be finite and non-negative, with no sum constraint; + the default is ``{"RX": 1.0, "RY": 1.0, "RZ": 1.0}``. The + keys are validation-only on this route because any nonzero + coherent family rate is rejected. ``"L"`` is not a + coherent-model key, and ``"U"`` is reserved for future + Hamiltonian-level support and rejected. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. - p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate - linear in idle duration. - p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory rate - quadratic in idle duration. + p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic + rate linear in idle duration. Use ``p_idle_linear`` with a + Z-only model, or ``p_idle_z_linear_rate`` for literal behavior. + p_idle_quadratic_rate: Deprecated bare Z-only coefficient-style + rate quadratic in idle duration. Use ``p_idle_sin_squared`` for + the structured engines-style dephasing interface, or + ``p_idle_z_quadratic_rate`` for literal behavior. p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. - p_idle_quadratic_sine_rate: Optional legacy alias for stochastic Z-memory - rate with probability ``sin(rate * duration)^2``. + p_idle_quadratic_sine_rate: Deprecated bare Z-only alias for a + stochastic rate with probability ``sin(rate * duration)^2``. + Use ``p_idle_sin_squared`` or ``p_idle_z_quadratic_sine_rate``. p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. + strip_traced_idles: If true, remove identity-like gates from the + normalized traced circuit, including ``I``, ``Idle``, and + zero-angle rotations. This pass runs before idle insertion + when both idle-pass options are set. Defaults to ``None``, + which strips exactly when ``idle_after_2q_duration`` is set: + inserting a uniform idle convention on top of runtime-emitted + idles would double-count idle noise. Pass ``False`` explicitly + to keep runtime-emitted idles alongside inserted ones. + idle_after_2q_duration: If set, insert an ``Idle`` gate of this + duration on both qubits after every two-qubit gate in the + normalized traced circuit. Insertion runs after + ``strip_traced_idles`` and before result-tag resolution and + detector/observable metadata attachment. runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. @@ -344,15 +577,20 @@ def from_guppy( ValueError: If ``num_measurements`` disagrees with the traced measurement count, if a detector/observable is malformed or references an out-of-range ``record`` or an absent - ``meas_id``, or if the traced operation stream cannot be - replayed. + ``meas_id``, if ``idle_after_2q_duration`` is not a finite + positive number, if ``p_idle_coherent`` is nonzero, if any + representable idle-noise parameter is set but the final traced + circuit has no ``Idle`` gates, or if the traced operation stream + cannot be replayed. To provide targets for idle noise, pass + ``idle_after_2q_duration`` or use a Selene runtime that emits + scheduled idles. Note: Runtime-lowered idles are replayed as nanosecond PECOS ``TimeUnits``. If idle parameters come from a per-second simulator/runtime model, use ``noise.for_runtime_idle_time_units()`` and pass the converted - scalar idle-rate fields to this constructor. + model through this constructor's ``noise`` keyword. **Measurement-dependent (dynamic) control flow is unsupported.** ``from_guppy`` traces one ideal execution; a Guppy program whose @@ -372,121 +610,25 @@ def from_guppy( scalar ``result(tag, measure(q))`` in straight-line programs; the runtime-loop case (per-occurrence binding) remains deferred. """ - from pecos.tracing import trace_program_to_tick_circuit - - # Tag-referenced detectors require the compiled HUGR (to recover the - # sound, reorder-immune Guppy `result(tag, ...)` -> measurement - # binding). `guppy_to_hugr` accepts @guppy-decorated functions and - # `GuppyFunctionDefinition`s (e.g. `make_surface_code(...)`), but - # not arbitrary callables / non-Guppy `pecos.sim`-acceptable inputs. - # Compile upfront so a wrong input fails loud here, before tracing, - # with a clear @guppy-mentioning message instead of crashing later - # inside the HUGR step. - needs_tags = _result_tags_present(detectors_json, observables_json) - hugr_bytes = _certifiable_hugr_bytes(guppy) - if hugr_bytes is None: - if needs_tags: - msg = ( - "result_tags requires a @guppy-decorated function (or a " - "GuppyFunctionDefinition, e.g. the object " - "make_surface_code(...) returns) so the program can be " - "compiled to a HUGR. Pass such an input directly, or use " - "positional 'records' / 'meas_ids' instead." - ) - raise ValueError(msg) - msg = ( - "DetectorErrorModel.from_guppy requires a HUGR-certifiable program " - "(a @guppy function, pecos.Guppy, pecos.Hugr, or HUGR envelope " - f"bytes); a {type(guppy).__name__!r} input cannot be certified as " - "statically scheduled, so an audited DEM cannot be built from it" - ) - raise ValueError(msg) - certificate_carrier = _certificate_carrier(guppy) - generator_layout = ( - _generator_certified_layout(certificate_carrier, hugr_bytes) if certificate_carrier is not None else None + noise_keywords = {name: value for name, value in locals().items() if name in _GUPPY_NOISE_KEYWORDS} + builder = ( + cls.builder() + .with_program(guppy) + .with_qubits(num_qubits) + .with_detectors_json(detectors_json) + .with_observables_json(observables_json) + .with_strip_traced_idles(strip_traced_idles) + .with_idle_after_2q(idle_after_2q_duration) + .with_runtime(runtime) + .with_seed(seed) + .with_require_hosted_operation_order(require_hosted_operation_order) + .with_max_hosted_tick_separation(max_hosted_tick_separation) ) - if generator_layout is None: - from pecos_rslib import guppy_hugr_has_nontrivial_control_flow - - if guppy_hugr_has_nontrivial_control_flow(hugr_bytes): - msg = ( - "DetectorErrorModel.from_guppy requires a statically straight-line Guppy program; " - "branching or looping control flow cannot be certified from one runtime trace" - ) - raise ValueError(msg) - - # Trace the EXACT bytes that were certified above: re-compiling the - # original object for execution would let the audit and the execution - # diverge (and pays a second compile for nothing). - from pecos.programs import Hugr as _HugrProgram - - tc = trace_program_to_tick_circuit( - _HugrProgram(hugr_bytes), - num_qubits, - seed=seed, - runtime=runtime, - require_hosted_operation_order=require_hosted_operation_order, - max_hosted_tick_separation=max_hosted_tick_separation, - ) - - # Compilation passes required for traced QIS circuits before fault - # analysis: normalize parameterized Clifford rotations to named gates, - # stamp stable MeasIds onto measurement gates, and fail loudly if raw - # traced-QIS rotations survived normalization. - normalize_traced_tick_circuit(tc, context="DetectorErrorModel.from_guppy") - - # Resolve `result_tags` -> record offsets via Rust (sound HUGR - # extraction + runtime-loop guard via static-vs-traced measurement - # count). After this, `detectors_json` / `observables_json` no longer - # contain `result_tags`; the downstream Rust DEM builder is unchanged. - if needs_tags: - from pecos_rslib import resolve_result_tags_for_guppy - - source_ids_json = tc.get_meta("qis_source_measurement_ids") or tc.get_meta("guppy_source_measurement_ids") - source_measurement_ids = json.loads(source_ids_json) if source_ids_json else [] - - detectors_json, observables_json = resolve_result_tags_for_guppy( - detectors_json, - observables_json, - hugr_bytes, - source_measurement_ids, - measurement_ids_in_execution_order(tc), - ) - - # Hand the caller's metadata to the Rust builder verbatim; it owns all - # schema/ref validation (including D0/L0 id forms, tracked-Pauli - # rejection, num_measurements consistency, and stamped-MeasId - # resolution). - tc.set_meta("detectors", detectors_json) - tc.set_meta("observables", observables_json) if num_measurements is not None: - tc.set_meta("num_measurements", str(num_measurements)) - - return _from_circuit_with_noise( - tc, - p1=p1, - p1_weights=p1_weights, - p2=p2, - p2_weights=p2_weights, - p2_replacement_approximation=p2_replacement_approximation, - p_meas=p_meas, - p_prep=p_prep, - p_idle=p_idle, - t1=t1, - t2=t2, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, - ) + builder.with_num_measurements(num_measurements) + # Same-module private seam: the flat keyword surface stays on this + # function while noise() remains strictly a NoiseParameters-instance setter. + return builder._legacy_noise(noise, noise_keywords).build().dem # noqa: SLF001 def _result_tags_present(detectors_json: str, observables_json: str) -> bool: @@ -525,6 +667,7 @@ def _preflight_guppy_static_schedule( guppy: Any, *, required_tags: Sequence[str], + json_result_tags: bool = False, ) -> tuple[Sequence[Any] | None, bytes]: """Validate program-level trust before any runtime trace is captured. @@ -538,13 +681,23 @@ def _preflight_guppy_static_schedule( hugr_bytes = _certifiable_hugr_bytes(guppy) if hugr_bytes is None: if required_tags: - msg = "result_ref(...) requires a HUGR-compilable Guppy program" + msg = ( + "result_ref(...) requires a HUGR-compilable Guppy program; use " + "DetectorErrorModel.from_circuit(...) for circuit inputs" + ) + raise ValueError(msg) + if json_result_tags: + msg = ( + "result_tags requires a @guppy-decorated function (or a GuppyFunctionDefinition) so the program can " + "be compiled to a HUGR; use DetectorErrorModel.from_circuit(...) for circuit inputs" + ) raise ValueError(msg) msg = ( - "build_dem_from_guppy requires a HUGR-certifiable program (a @guppy " + "GuppyDemBuilder.program() requires a HUGR-certifiable program (a @guppy " "function, pecos.Guppy, pecos.Hugr, or HUGR envelope bytes); a " f"{type(guppy).__name__!r} input cannot be certified as statically " - "scheduled, so an audited DEM cannot be built from it" + "scheduled, so an audited DEM cannot be built from it; use " + "DetectorErrorModel.from_circuit(...) for circuit inputs" ) raise ValueError(msg) @@ -559,7 +712,7 @@ def _preflight_guppy_static_schedule( if guppy_hugr_has_nontrivial_control_flow(hugr_bytes): msg = ( - "build_dem_from_guppy requires a statically straight-line Guppy program unless it carries " + "GuppyDemBuilder requires a statically straight-line Guppy program unless it carries " "a trusted generator-owned measurement layout; branching or looping control flow cannot be " "certified from one runtime trace" ) @@ -766,34 +919,517 @@ def _generator_certified_result_traces( ] +_UNSET = object() +_LEGACY_NOISE = object() + + +def _builder_noise_defaults() -> dict[str, Any]: + """Return the legacy Guppy entry-point defaults with explicitness intact.""" + defaults = dict.fromkeys(_GUPPY_NOISE_KEYWORDS, _NOISE_DEFAULT_NONE) + defaults.update( + p1=_NOISE_DEFAULT_P1, + p2=_NOISE_DEFAULT_P2, + p_meas=_NOISE_DEFAULT_P1, + p_prep=_NOISE_DEFAULT_P1, + ) + return defaults + + +class GuppyDemBuilder: + """Configure and build an audited detector error model from a Guppy program.""" + + __slots__ = ( + "_detectors_kind", + "_detectors_value", + "_idle_after_2q", + "_max_hosted_tick_separation", + "_noise", + "_num_measurements", + "_observables_kind", + "_observables_value", + "_program", + "_qubits", + "_require_hosted_operation_order", + "_residual_warning_threshold", + "_runtime", + "_seed", + "_strip_traced_idles", + ) + + def __init__(self) -> None: + """Create an empty builder whose required inputs are not yet set.""" + self._program: Any = _UNSET + self._qubits: Any = _UNSET + self._detectors_kind: str | object = _UNSET + self._detectors_value: Any = _UNSET + self._observables_kind: str | object = _UNSET + self._observables_value: Any = _UNSET + self._num_measurements: Any = _UNSET + self._noise: Any = _UNSET + self._idle_after_2q: Any = _UNSET + self._strip_traced_idles: Any = _UNSET + self._runtime: Any = _UNSET + self._seed: Any = _UNSET + self._residual_warning_threshold: Any = _UNSET + self._require_hosted_operation_order: Any = _UNSET + self._max_hosted_tick_separation: Any = _UNSET + + def _set_once(self, attribute: str, value: Any, setter: str) -> None: + if getattr(self, attribute) is not _UNSET: + msg = f"{setter}() may only be called once" + raise ValueError(msg) + setattr(self, attribute, value) + + def _set_specs(self, role: str, kind: str, value: Any) -> None: + kind_attribute = f"_{role}_kind" + value_attribute = f"_{role}_value" + current_kind = getattr(self, kind_attribute) + setter = f"with_{role}" if kind == "typed" else f"with_{role}_json" + if current_kind is not _UNSET: + previous = f"with_{role}" if current_kind == "typed" else f"with_{role}_json" + if current_kind == kind: + msg = f"{setter}() may only be called once" + raise ValueError(msg) + msg = f"{setter}() cannot be combined with {previous}()" + raise ValueError(msg) + if kind == "typed" and self._num_measurements is not _UNSET: + msg = f"{setter}() cannot be combined with with_num_measurements()" + raise ValueError(msg) + setattr(self, kind_attribute, kind) + setattr(self, value_attribute, value) + + def with_program(self, program: Any) -> Self: + """Set the Guppy or HUGR program to trace.""" + self._set_once("_program", program, "with_program") + return self + + def with_qubits(self, num_qubits: int) -> Self: + """Set the number of qubits allocated to the trace.""" + self._set_once("_qubits", num_qubits, "with_qubits") + return self + + def with_detectors(self, specs: Sequence[Detector]) -> Self: + """Set typed detector specifications.""" + self._set_specs("detectors", "typed", tuple(specs)) + return self + + def with_observables(self, specs: Sequence[Observable]) -> Self: + """Set typed logical-observable specifications.""" + self._set_specs("observables", "typed", tuple(specs)) + return self + + def with_detectors_json(self, text: str) -> Self: + """Set raw JSON detector specifications.""" + self._set_specs("detectors", "json", text) + return self + + def with_observables_json(self, text: str) -> Self: + """Set raw JSON logical-observable specifications.""" + self._set_specs("observables", "json", text) + return self + + def with_num_measurements(self, count: int) -> Self: + """Set the measurement count used by raw JSON record references.""" + if self._detectors_kind == "typed" or self._observables_kind == "typed": + msg = "with_num_measurements() cannot be combined with typed with_detectors() or with_observables()" + raise ValueError(msg) + self._set_once("_num_measurements", count, "with_num_measurements") + return self + + def with_noise(self, noise_model: NoiseParameters) -> Self: + """Set the complete grouped noise configuration.""" + from pecos.qec.surface.decode import NoiseParameters + + if not isinstance(noise_model, NoiseParameters): + msg = f"noise() requires a NoiseParameters instance, got {type(noise_model).__name__}" + raise TypeError(msg) + self._set_once("_noise", noise_model, "with_noise") + return self + + def _legacy_noise(self, noise_model: NoiseParameters | None, flat_keywords: Mapping[str, Any]) -> Self: + """Carry the legacy entry points' flat noise keywords through the builder. + + Private: the flat keyword surface stays on ``from_guppy`` and + ``build_dem_from_guppy``; ``noise()`` accepts only a ``NoiseParameters``. + """ + self._set_once("_noise", (_LEGACY_NOISE, noise_model, dict(flat_keywords)), "with_noise") + return self + + def with_idle_after_2q(self, duration: float | None) -> Self: + """Set the idle duration inserted after every two-qubit gate.""" + self._set_once("_idle_after_2q", duration, "with_idle_after_2q") + return self + + def with_strip_traced_idles(self, flag: bool | None) -> Self: + """Choose whether runtime-emitted identity-like gates are stripped.""" + self._set_once("_strip_traced_idles", flag, "with_strip_traced_idles") + return self + + def with_runtime(self, runtime: object | None) -> Self: + """Set the Selene runtime that lowers the program into the traced QIS stream. + + The runtime produces the trace rather than annotating it: the Guppy + program is lowered and unrolled through it, and the ``TickCircuit`` this + DEM is built from comes out the far side. A runtime that models timing + therefore emits its own ``Idle`` gates, which is why + :meth:`with_idle_after_2q` strips traced idles first by default rather + than stacking a second convention on top. + + Accepts four forms: + + - ``None`` selects the default runtime, preferring a freshly built + artifact and falling back to the installed plugin package. + - A name without path separators is treated as a built runtime library. + - A path-like value is loaded as a shared library. + - A runtime plugin object is duck-typed. It must expose + ``library_file``; ``get_init_args()`` and ``library_search_dirs`` are + used when present and default to empty otherwise. An object without + ``library_file`` raises :class:`TypeError` at configuration time. + + See ``pecos._engine_builders._configure_selene_runtime`` for the + dispatch. + """ + self._set_once("_runtime", runtime, "with_runtime") + return self + + def with_seed(self, seed: int) -> Self: + """Set the ideal trace seed.""" + self._set_once("_seed", seed, "with_seed") + return self + + def with_residual_warning_threshold(self, fraction: float) -> Self: + """Accept channel-conversion residuals up to a relative physics tolerance. + + ``fraction`` is a fraction of each requested channel's total error + weight, not an absolute probability. At or below this tolerance the + channel remains an accepted inexact conversion, with the exact figures + retained in ``dem.idle_noise_residuals`` and the build audit. The default + is ``0.0``, so every nonzero residual warns. + + Use :func:`warnings.filterwarnings` when the intent is to silence a + warning category wholesale; this setter records a physics tolerance. + """ + try: + finite = math.isfinite(fraction) + except (TypeError, ValueError): + finite = False + if not finite or not isinstance(fraction, (int, float)) or fraction < 0.0: + msg = ( + "with_residual_warning_threshold() requires a finite fraction of " + "the channel's total error weight in [0.0, 1.0]; " + f"got {fraction!r}" + ) + raise ValueError(msg) + if fraction > 1.0: + msg = ( + "with_residual_warning_threshold() is a fraction of the channel's " + "total error weight in [0.0, 1.0], not an absolute probability; " + f"got {fraction!r}" + ) + raise ValueError(msg) + self._set_once("_residual_warning_threshold", float(fraction), "with_residual_warning_threshold") + return self + + def with_require_hosted_operation_order(self, flag: bool) -> Self: + """Choose whether hosted-operation ordering is validated.""" + self._set_once("_require_hosted_operation_order", flag, "with_require_hosted_operation_order") + return self + + def with_max_hosted_tick_separation(self, count: int | None) -> Self: + """Set the maximum hosted-operation tick separation.""" + self._set_once("_max_hosted_tick_separation", count, "with_max_hosted_tick_separation") + return self + + def _noise_parameters(self) -> dict[str, Any]: + if isinstance(self._noise, tuple) and len(self._noise) == 3 and self._noise[0] is _LEGACY_NOISE: + _, noise, call_arguments = self._noise + return _resolve_guppy_noise(noise, call_arguments) + noise = None if self._noise is _UNSET else self._noise + return _resolve_guppy_noise(noise, _builder_noise_defaults()) + + def _required(self, attribute: str, setter: str) -> Any: + value = getattr(self, attribute) + if value is _UNSET: + msg = f"build() requires {setter}()" + raise ValueError(msg) + return value + + def build(self) -> GuppyDemBuild: + """Trace the configured program once and return its audited DEM build.""" + from pecos.programs import Hugr as _HugrProgram + from pecos.tracing import _collect_program_result_traces, trace_program_to_tick_circuit + + program = self._required("_program", "with_program") + num_qubits = self._required("_qubits", "with_qubits") + if self._detectors_kind is _UNSET: + msg = "build() requires with_detectors() or with_detectors_json()" + raise ValueError(msg) + + noise_parameters = self._noise_parameters() + ( + p1, + p1_weights, + p2, + p2_weights, + p2_replacement_approximation, + p_meas, + p_prep, + p_idle_linear, + p_idle_linear_model, + p_idle_sin_squared, + p_idle_sin_squared_model, + p_idle_coherent, + p_idle_coherent_model, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) = (noise_parameters[name] for name in _GUPPY_NOISE_KEYWORDS) + ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ) = _translate_structured_idle_noise( + p_idle_linear=p_idle_linear, + p_idle_linear_model=p_idle_linear_model, + p_idle_sin_squared=p_idle_sin_squared, + p_idle_sin_squared_model=p_idle_sin_squared_model, + p_idle_coherent=p_idle_coherent, + p_idle_coherent_model=p_idle_coherent_model, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + typed_detectors = self._detectors_value if self._detectors_kind == "typed" else () + typed_observables = self._observables_value if self._observables_kind == "typed" else () + referenced_tags = sorted( + { + ref.tag + for item in (*typed_detectors, *typed_observables) + for ref in item.refs + if isinstance(ref, ResultRef) + }, + ) + raw_detectors_json = self._detectors_value if self._detectors_kind == "json" else "[]" + raw_observables_json = self._observables_value if self._observables_kind == "json" else "[]" + json_needs_tags = _result_tags_present(raw_detectors_json, raw_observables_json) + generator_layout, hugr_bytes = _preflight_guppy_static_schedule( + program, + required_tags=referenced_tags, + json_result_tags=json_needs_tags, + ) + with _collect_program_result_traces() as result_traces: + circuit = trace_program_to_tick_circuit( + _HugrProgram(hugr_bytes), + num_qubits, + seed=0 if self._seed is _UNSET else self._seed, + runtime=None if self._runtime is _UNSET else self._runtime, + require_hosted_operation_order=( + False if self._require_hosted_operation_order is _UNSET else self._require_hosted_operation_order + ), + max_hosted_tick_separation=( + None if self._max_hosted_tick_separation is _UNSET else self._max_hosted_tick_separation + ), + ) + normalize_traced_tick_circuit(circuit, context="GuppyDemBuilder.build") + + _apply_traced_idle_passes( + circuit, + strip_traced_idles=None if self._strip_traced_idles is _UNSET else self._strip_traced_idles, + idle_after_2q_duration=None if self._idle_after_2q is _UNSET else self._idle_after_2q, + idle_noise_parameters=( + p_idle_linear, + p_idle_sin_squared, + t1, + t2, + p_idle_linear_rate, + p_idle_quadratic_rate, + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, + ), + ) + result_traces = _compiler_certified_result_traces( + generator_layout, + hugr_bytes, + circuit, + result_traces, + required_tags=referenced_tags, + ) + typed_schema = _resolve_dem_specs( + typed_detectors, + typed_observables, + circuit=circuit, + result_traces=result_traces, + ) + detectors_json = typed_schema.detectors_json if self._detectors_kind == "typed" else raw_detectors_json + observables_json = typed_schema.observables_json if self._observables_kind == "typed" else raw_observables_json + if json_needs_tags: + from pecos_rslib import resolve_result_tags_for_guppy + + source_ids_json = circuit.get_meta("qis_source_measurement_ids") or circuit.get_meta( + "guppy_source_measurement_ids", + ) + source_measurement_ids = json.loads(source_ids_json) if source_ids_json else [] + detectors_json, observables_json = resolve_result_tags_for_guppy( + detectors_json, + observables_json, + hugr_bytes, + source_measurement_ids, + measurement_ids_in_execution_order(circuit), + ) + + circuit.set_meta("detectors", detectors_json) + circuit.set_meta("observables", observables_json) + measurement_count = circuit.num_measurements() if self._num_measurements is _UNSET else self._num_measurements + circuit.set_meta("num_measurements", str(measurement_count)) + dem = _from_circuit_with_noise( + circuit, + p1=p1, + p1_weights=p1_weights, + p2=p2, + p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, + p_meas=p_meas, + p_prep=p_prep, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + schema = _resolved_schema_from_validated_json( + detectors_json, + observables_json, + circuit=circuit, + result_traces=result_traces, + ) + circuit.set_meta("dem_schema_fingerprint", schema.schema_fingerprint) + if generator_layout is not None: + named_result_binding = "generator_layout_v2_program_bound" + else: + result_ids = [result_id for _, ids in schema.result_ids_by_tag for result_id in ids] + if not result_ids or all(result_id is None for result_id in result_ids): + named_result_binding = "none" + elif any(result_id is None for result_id in result_ids): + named_result_binding = "compiler_direct_scalar_partial" + else: + named_result_binding = "compiler_direct_scalar_complete" + residual_warning_threshold = ( + 0.0 if self._residual_warning_threshold is _UNSET else self._residual_warning_threshold + ) + _warn_on_noise_channel_residuals(dem, residual_warning_threshold) + return GuppyDemBuild( + dem=dem, + circuit=circuit, + detectors_json=schema.detectors_json, + observables_json=schema.observables_json, + measurement_ledger=schema.ledger, + schema_fingerprint=schema.schema_fingerprint, + named_result_binding=named_result_binding, + _detector_meas_ids=schema.detector_meas_ids, + _observable_meas_ids=schema.observable_meas_ids, + _result_ids_by_tag=schema.result_ids_by_tag, + ) + + +def _warn_on_noise_channel_residuals(dem: DetectorErrorModel, relative_threshold: float = 0.0) -> None: + """Warn about channel residuals above the accepted relative tolerance.""" + residuals = [entry for entry in dem.idle_noise_residuals if float(entry["relative_magnitude"]) > relative_threshold] + if not residuals: + return + by_kind: dict[str, list[tuple[float, float]]] = {} + for entry in residuals: + kind = str(entry["channel_kind"]) + by_kind.setdefault(kind, []).append( + (float(entry["relative_magnitude"]), float(entry["magnitude"])), + ) + kinds = ", ".join( + f"{len(magnitudes)} {kind} (largest relative {max(value[0] for value in magnitudes):.3e}; " + f"largest TV {max(value[1] for value in magnitudes):.3e})" + for kind, magnitudes in sorted(by_kind.items()) + ) + warnings.warn( + f"{len(residuals)} categorical noise channel(s) were approximated: {kinds}. " + "A non-negative boundary fit was emitted; relative magnitudes are fractions " + "of each requested channel's total error weight, and TV magnitudes are " + "total-variation distances. See dem.idle_noise_residuals for details.", + UserWarning, + stacklevel=3, + ) + + def build_dem_from_guppy( guppy: Any, *, num_qubits: int, detectors: Sequence[Detector], observables: Sequence[Observable] = (), - p1: float = 0.001, - p1_weights: P1Weights | None = None, - p2: float = 0.01, - p2_weights: P2Weights | None = None, - p2_replacement_approximation: str | None = None, - p_meas: float = 0.001, - p_prep: float = 0.001, - p_idle: float | None = None, - t1: float | None = None, - t2: float | None = None, - p_idle_linear_rate: float | None = None, - p_idle_quadratic_rate: float | None = None, - p_idle_x_linear_rate: float | None = None, - p_idle_y_linear_rate: float | None = None, - p_idle_z_linear_rate: float | None = None, - p_idle_x_quadratic_rate: float | None = None, - p_idle_y_quadratic_rate: float | None = None, - p_idle_z_quadratic_rate: float | None = None, - p_idle_quadratic_sine_rate: float | None = None, - p_idle_x_quadratic_sine_rate: float | None = None, - p_idle_y_quadratic_sine_rate: float | None = None, - p_idle_z_quadratic_sine_rate: float | None = None, + noise: NoiseParameters | None = None, + p1: float = _NOISE_DEFAULT_P1, + p1_weights: P1Weights | None = _NOISE_DEFAULT_NONE, + p2: float = _NOISE_DEFAULT_P2, + p2_weights: P2Weights | None = _NOISE_DEFAULT_NONE, + p2_replacement_approximation: str | None = _NOISE_DEFAULT_NONE, + p_meas: float = _NOISE_DEFAULT_P1, + p_prep: float = _NOISE_DEFAULT_P1, + p_idle_linear: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared: float | None = _NOISE_DEFAULT_NONE, + p_idle_sin_squared_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + p_idle_coherent: float | None = _NOISE_DEFAULT_NONE, + p_idle_coherent_model: Mapping[str, float] | None = _NOISE_DEFAULT_NONE, + t1: float | None = _NOISE_DEFAULT_NONE, + t2: float | None = _NOISE_DEFAULT_NONE, + p_idle_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_linear_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_x_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_y_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + p_idle_z_quadratic_sine_rate: float | None = _NOISE_DEFAULT_NONE, + strip_traced_idles: bool | None = None, + idle_after_2q_duration: float | None = None, runtime: object | None = None, seed: int = 0, require_hosted_operation_order: bool = False, @@ -809,98 +1445,145 @@ def build_dem_from_guppy( Measurement-dependent quantum control remains unsupported because one captured execution is not a static circuit model. - """ - from pecos.tracing import _trace_program_to_tick_circuit_with_result_traces - referenced_tags = sorted( - {ref.tag for item in (*detectors, *observables) for ref in item.refs if isinstance(ref, ResultRef)}, - ) - generator_layout, hugr_bytes = _preflight_guppy_static_schedule( - guppy, - required_tags=referenced_tags, - ) - has_generator_layout = generator_layout is not None - # Trace the EXACT bytes that were certified: re-compiling the original - # object for execution would let the audit and the execution diverge (and - # pays a second compile for nothing). - from pecos.programs import Hugr as _HugrProgram - - circuit, result_traces = _trace_program_to_tick_circuit_with_result_traces( - _HugrProgram(hugr_bytes), - num_qubits, - seed=seed, - runtime=runtime, - require_hosted_operation_order=require_hosted_operation_order, - max_hosted_tick_separation=max_hosted_tick_separation, - allow_raw_measurement_id_fallback=False, - ) - normalize_traced_tick_circuit(circuit, context="build_dem_from_guppy") - result_traces = _compiler_certified_result_traces( - generator_layout, - hugr_bytes, - circuit, - result_traces, - required_tags=referenced_tags, - ) - schema = _resolve_dem_specs( - detectors, - observables, - circuit=circuit, - result_traces=result_traces, - ) - if has_generator_layout: - named_result_binding = "generator_layout_v2_program_bound" - else: - result_ids = [result_id for _, ids in schema.result_ids_by_tag for result_id in ids] - if not result_ids or all(result_id is None for result_id in result_ids): - named_result_binding = "none" - elif any(result_id is None for result_id in result_ids): - named_result_binding = "compiler_direct_scalar_partial" - else: - named_result_binding = "compiler_direct_scalar_complete" - circuit.set_meta("detectors", schema.detectors_json) - circuit.set_meta("observables", schema.observables_json) - circuit.set_meta("num_measurements", str(circuit.num_measurements())) - circuit.set_meta("dem_schema_fingerprint", schema.schema_fingerprint) - - dem = _from_circuit_with_noise( - circuit, - p1=p1, - p1_weights=p1_weights, - p2=p2, - p2_weights=p2_weights, - p2_replacement_approximation=p2_replacement_approximation, - p_meas=p_meas, - p_prep=p_prep, - p_idle=p_idle, - t1=t1, - t2=t2, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, - ) - return GuppyDemBuild( - dem=dem, - circuit=circuit, - detectors_json=schema.detectors_json, - observables_json=schema.observables_json, - measurement_ledger=schema.ledger, - schema_fingerprint=schema.schema_fingerprint, - named_result_binding=named_result_binding, - _detector_meas_ids=schema.detector_meas_ids, - _observable_meas_ids=schema.observable_meas_ids, - _result_ids_by_tag=schema.result_ids_by_tag, + The three structured idle models contain relative-rate multipliers: each + axis rate is ``family_rate * axis_multiplier``. The linear law is additive, + so its multipliers must sum to 1.0 and coincide with the engines + relative-probability distribution. The nonlinear sine-squared and coherent + laws are not additive, so their finite, non-negative multipliers have no + sum constraint. + + Args: + guppy: A HUGR-certifiable Guppy program to trace once under the Selene + QIS engine. + num_qubits: Number of qubits to allocate for the trace. + detectors: Typed detector definitions using ``rec[...]`` or + ``result_ref(...)`` measurement references. + observables: Typed logical-observable definitions using the same + measurement-reference forms as ``detectors``. + noise: Complete grouped noise configuration. When supplied, its values + replace all flat noise keywords, including this entry point's + defaults. In particular, ``NoiseParameters`` defaults such as + ``p1=0.0`` apply instead of this function's ``p1=0.001``. Mixing + ``noise`` with any flat noise keyword is rejected. + p1: Single-qubit gate Pauli error rate. The categorical Pauli channel + is converted after equal propagated signatures merge. + p1_weights: Optional relative probabilities over single-qubit Pauli + error labels ``"X"``, ``"Y"``, and ``"Z"``. + p2: Two-qubit gate depolarizing rate. Its 15 categorical branches are + converted together after propagation. + p2_weights: Optional relative probabilities over two-qubit Pauli error + labels, including starred replacement branches. + p2_replacement_approximation: Approximation used for starred + replacement labels in ``p2_weights``. + p_meas: Measurement flip rate. + p_prep: Preparation (reset) error rate. + p_idle_linear: Optional total stochastic idle-noise rate linear in + duration. Uses the engines ``GeneralNoiseModel`` categorical-Pauli + convention. The DEM groups non-empty propagated flip signatures + before converting distinct signatures to independent mechanisms. + If exact conversion would require a negative mechanism, the build + uses a non-negative boundary fit and reports its quantified + both-fire residual through ``dem.idle_noise_residuals`` and the + audited build's ``audit["idle_noise_residuals"]`` entry. + p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, + ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be finite, + non-negative, and sum to 1.0 within ``1e-5``, including any + explicit ``"L"`` weight. Defaults to the engines' uniform Pauli + model. DEM fault propagation is Pauli-only, so ``"L"`` must be zero + here; the engines simulators support nonzero leakage weights. + p_idle_sin_squared: Optional stochastic sine-law idle rate. An + axis multiplier ``m`` gives probability + ``sin((p_idle_sin_squared * m) * t)^2``. By default X, Y, and Z + each use the full family rate. These axis mechanisms remain + separate from the linear family. + p_idle_sin_squared_model: Optional finite, non-negative relative-rate + multipliers over ``"X"``, ``"Y"``, ``"Z"``, and ``"L"``. There is + no sum constraint; the default is + ``{"X": 1.0, "Y": 1.0, "Z": 1.0}``. DEM fault propagation is + Pauli-only, so ``"L"`` must be zero here; the engines simulators + support nonzero leakage weights. + p_idle_coherent: Optional coherent-rotation rate. The standard DEM + builder cannot represent coherent idle noise and rejects every + nonzero rate rather than silently storing a Pauli twirl that + discards the requested coherence. Use the EEG coherent route in + ``exp/pecos-eeg`` for coherent idle noise; it supports only an RZ + generator. The honest stochastic equivalent—the exact Pauli twirl + of ``RZ(rate * t)``—is ``p_idle_sin_squared=rate/2`` with + ``p_idle_sin_squared_model={"Z": 1.0}``. Zero has no effect. + p_idle_coherent_model: Optional finite, non-negative relative-rate + multipliers over ``"RX"``, ``"RY"``, and ``"RZ"``, with no sum + constraint. Defaults to ``{"RX": 1.0, "RY": 1.0, "RZ": 1.0}``. + The keys are validation-only on this route because any nonzero + coherent family rate is rejected. ``"L"`` is not a coherent-model + key; ``"U"`` is reserved for future Hamiltonian-level support and + rejected. + t1: Optional T1 relaxation time for explicit idle gates. + t2: Optional T2 dephasing time for explicit idle gates. + p_idle_linear_rate: Deprecated bare Z-only alias for a stochastic rate + linear in idle duration. Use ``p_idle_linear`` with a Z-only model, + or ``p_idle_z_linear_rate`` for literal behavior. + p_idle_quadratic_rate: Deprecated bare Z-only coefficient-style rate + quadratic in idle duration. Use ``p_idle_sin_squared`` for the + structured engines-style dephasing interface, or + ``p_idle_z_quadratic_rate`` for literal behavior. + p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Deprecated bare Z-only alias for a + stochastic rate with probability ``sin(rate * duration)^2``. Use + ``p_idle_sin_squared`` or ``p_idle_z_quadratic_sine_rate``. + p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. + strip_traced_idles: If true, remove identity-like gates from the + normalized trace, including ``I``, ``Idle``, and zero-angle + rotations. This pass runs before idle insertion when both + idle-pass options are set. Defaults to ``None``, which strips + exactly when ``idle_after_2q_duration`` is set; pass ``False`` + explicitly to keep runtime-emitted idles alongside inserted + ones. + idle_after_2q_duration: If set, insert an ``Idle`` gate of this + duration on both qubits after every two-qubit gate. Insertion runs + after ``strip_traced_idles`` and before typed result-reference + resolution and detector/observable metadata attachment. + runtime: Optional Selene runtime selector/plugin. ``None`` selects the + default Selene runtime. + seed: Seed for the ideal trace run. + require_hosted_operation_order: If true, validate generic + hosted-operation metadata after trace replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. + + Raises: + ValueError: If ``idle_after_2q_duration`` is not a finite positive + number, if ``p_idle_coherent`` is nonzero, or if any representable + idle-noise parameter is set but the final traced circuit has no + ``Idle`` gates. Pass ``idle_after_2q_duration`` or use a Selene + runtime that emits scheduled idles to provide targets for idle + noise. + """ + noise_keywords = {name: value for name, value in locals().items() if name in _GUPPY_NOISE_KEYWORDS} + builder = ( + GuppyDemBuilder() + .with_program(guppy) + .with_qubits(num_qubits) + .with_detectors(detectors) + .with_observables(observables) + .with_strip_traced_idles(strip_traced_idles) + .with_idle_after_2q(idle_after_2q_duration) + .with_runtime(runtime) + .with_seed(seed) + .with_require_hosted_operation_order(require_hosted_operation_order) + .with_max_hosted_tick_separation(max_hosted_tick_separation) ) + # Same-module private seam: see the note in DetectorErrorModel.from_guppy. + return builder._legacy_noise(noise, noise_keywords).build() # noqa: SLF001 DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.builder = classmethod(_DetectorErrorModelMixin.__dict__["builder"].__func__) DetectorErrorModel.from_guppy = classmethod(_DetectorErrorModelMixin.__dict__["from_guppy"].__func__) diff --git a/python/quantum-pecos/src/pecos/qec/dem_spec.py b/python/quantum-pecos/src/pecos/qec/dem_spec.py index 60fdf4735..dc6d6120b 100644 --- a/python/quantum-pecos/src/pecos/qec/dem_spec.py +++ b/python/quantum-pecos/src/pecos/qec/dem_spec.py @@ -75,11 +75,20 @@ def result_ref(tag: str, *, occurrence: int = 0, element: int | None = None) -> MeasurementRef = RecordRef | ResultRef -def _validate_refs(refs: tuple[MeasurementRef, ...]) -> None: +def _coerce_refs(refs: tuple[MeasurementRef | str, ...]) -> tuple[MeasurementRef, ...]: + """Accept a bare tag string as shorthand for ``result_ref(tag)``.""" if not refs: raise ValueError("detectors and observables must reference at least one measurement") - if any(not isinstance(ref, (RecordRef, ResultRef)) for ref in refs): - raise TypeError("measurement references must be rec[...] or result_ref(...) values") + coerced: list[MeasurementRef] = [] + for ref in refs: + if isinstance(ref, str): + coerced.append(ResultRef(ref)) + elif isinstance(ref, (RecordRef, ResultRef)): + coerced.append(ref) + else: + msg = 'measurement references must be rec[...], result_ref(...), or a "tag" string' + raise TypeError(msg) + return tuple(coerced) @dataclass(frozen=True, slots=True, init=False) @@ -93,14 +102,17 @@ class Detector: def __init__( self, - *refs: MeasurementRef, + *refs: MeasurementRef | str, id: int | None = None, coords: Sequence[float] | None = None, metadata: Mapping[str, Any] | None = None, ) -> None: - """Create a detector from typed measurement references.""" - refs_tuple = tuple(refs) - _validate_refs(refs_tuple) + """Create a detector from measurement references. + + Each reference is ``rec[-k]``, ``result_ref(...)``, or a bare tag + string, which is shorthand for ``result_ref(tag)``. + """ + refs_tuple = _coerce_refs(tuple(refs)) object.__setattr__(self, "refs", refs_tuple) object.__setattr__(self, "id", id) object.__setattr__(self, "coords", tuple(float(value) for value in coords) if coords is not None else None) @@ -117,13 +129,16 @@ class Observable: def __init__( self, - *refs: MeasurementRef, + *refs: MeasurementRef | str, id: int | None = None, metadata: Mapping[str, Any] | None = None, ) -> None: - """Create an observable from typed measurement references.""" - refs_tuple = tuple(refs) - _validate_refs(refs_tuple) + """Create an observable from measurement references. + + Each reference is ``rec[-k]``, ``result_ref(...)``, or a bare tag + string, which is shorthand for ``result_ref(tag)``. + """ + refs_tuple = _coerce_refs(tuple(refs)) object.__setattr__(self, "refs", refs_tuple) object.__setattr__(self, "id", id) object.__setattr__(self, "metadata", dict(metadata) if metadata is not None else None) @@ -256,6 +271,7 @@ def audit(self) -> dict[str, Any]: "runtime_order_is_canonical": runtime_order == list(range(len(runtime_order))), "runtime_order_mismatch_count": sum(index != meas_id for index, meas_id in enumerate(runtime_order)), "measurement_ledger": [entry.to_dict() for entry in self.measurement_ledger], + "idle_noise_residuals": self.dem.idle_noise_residuals, } def evaluate_runtime_record(self, values: Sequence[int | bool]) -> tuple[list[int], int]: @@ -578,3 +594,83 @@ def _resolve_dem_specs( result_ids_by_tag=result_ids_by_tag, schema_fingerprint=fingerprint, ) + + +def _resolved_schema_from_validated_json( + detectors_json: str, + observables_json: str, + *, + circuit: Any, + result_traces: Sequence[Mapping[str, Any]], +) -> _ResolvedSchema: + """Build audit data from metadata already validated by the Rust DEM builder.""" + runtime_order = _measurement_ids_in_runtime_order(circuit) + result_calls, refs_by_id = _index_result_traces(result_traces) + detector_entries = json.loads(detectors_json) if detectors_json.strip() else [] + observable_entries = json.loads(observables_json) if observables_json.strip() else [] + + def normalized_id(raw_id: Any, *, prefix: str) -> int: + if isinstance(raw_id, str) and raw_id.startswith(prefix): + return int(raw_id[len(prefix) :]) + return int(raw_id) + + def entry_meas_ids(entry: Mapping[str, Any]) -> tuple[int, ...]: + records = entry.get("records", ()) + if records: + return tuple(runtime_order[len(runtime_order) + int(record)] for record in records) + return tuple(int(meas_id) for meas_id in entry["meas_ids"]) + + resolved_detectors = sorted( + ( + normalized_id(entry["id"] if "id" in entry else entry["detector_id"], prefix="D"), + entry_meas_ids(entry), + ) + for entry in detector_entries + ) + resolved_observables = sorted( + ( + normalized_id(entry["id"] if "id" in entry else entry["observable_id"], prefix="L"), + entry_meas_ids(entry), + ) + for entry in observable_entries + ) + result_ids_by_tag = tuple( + ( + tag, + tuple(result_id for occurrence in range(len(calls)) for result_id in calls[occurrence]), + ) + for tag, calls in sorted(result_calls.items()) + if calls + and sorted(calls) == list(range(len(calls))) + and all(len(call) == 1 or all(result_id is None for result_id in call) for call in calls.values()) + and all(result_id is None or isinstance(result_id, int) for call in calls.values() for result_id in call) + ) + fingerprint_payload = { + "detectors": detector_entries, + "observables": observable_entries, + "runtime_measurement_order": runtime_order, + "named_result_measurements": result_ids_by_tag, + } + fingerprint = hashlib.sha256( + json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":")).encode(), + ).hexdigest() + runtime_ids = set(runtime_order) + dense_ids = sorted(runtime_ids) == list(range(len(runtime_order))) + ledger = tuple( + MeasurementLedgerEntry( + meas_id=meas_id, + runtime_record_index=runtime_index, + canonical_record_index=meas_id if dense_ids else None, + result_refs=tuple(refs_by_id.get(meas_id, ())), + ) + for runtime_index, meas_id in enumerate(runtime_order) + ) + return _ResolvedSchema( + detectors_json=detectors_json, + observables_json=observables_json, + detector_meas_ids=tuple(meas_ids for _, meas_ids in resolved_detectors), + observable_meas_ids=tuple(resolved_observables), + ledger=ledger, + result_ids_by_tag=result_ids_by_tag, + schema_fingerprint=fingerprint, + ) diff --git a/python/quantum-pecos/src/pecos/qec/guppy_output_dem.py b/python/quantum-pecos/src/pecos/qec/guppy_output_dem.py new file mode 100644 index 000000000..42d16462d --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/guppy_output_dem.py @@ -0,0 +1,424 @@ +"""Prototype DEM annotations inferred from Guppy parity outputs. + +This module is deliberately code-agnostic. It treats a Guppy program as the +owner of its detector/observable post-processing and learns the corresponding +affine GF(2) functions from PECOS coin-toss executions. A QIS trace then binds +the program's raw-measurement output to stable ``MeasId`` values. + +The inference is empirical, not a compiler proof. It therefore validates the +learned functions on additional independent rows and fails unless the raw tag +covers every physical measurement with a unique result-ID binding. +""" + +# Dynamic Guppy/runtime values and local fail-loud messages are intentional at +# this experimental Python boundary. +# ruff: noqa: ANN401, EM101, EM102, TRY003 + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + + +def _bit(value: Any, *, context: str) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, int) and value in (0, 1): + return value + raise ValueError(f"{context} must be bool, 0, or 1; got {value!r}") + + +def _rows(values: Sequence[Any], *, tag: str) -> list[list[int]]: + rows: list[list[int]] = [] + width: int | None = None + for shot, value in enumerate(values): + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + row = [_bit(item, context=f"result {tag!r}, shot {shot}") for item in value] + else: + row = [_bit(value, context=f"result {tag!r}, shot {shot}")] + if not row: + raise ValueError(f"result {tag!r}, shot {shot} is empty") + if width is None: + width = len(row) + elif len(row) != width: + raise ValueError(f"result {tag!r} has inconsistent shot widths: {width} and {len(row)}") + rows.append(row) + if not rows: + raise ValueError(f"result {tag!r} contains no shots") + return rows + + +def _infer_affine_columns( + inputs: Sequence[Sequence[int]], + outputs: Sequence[Sequence[int]], + *, + validation_rows: int, +) -> tuple[tuple[int, tuple[int, ...]], ...]: + """Infer all output columns as ``constant XOR selected inputs``.""" + if len(inputs) != len(outputs): + raise ValueError("raw and derived output records contain different shot counts") + input_width = len(inputs[0]) + output_width = len(outputs[0]) + if any(len(row) != input_width for row in inputs): + raise ValueError("raw measurement records have inconsistent widths") + if any(len(row) != output_width for row in outputs): + raise ValueError("derived output records have inconsistent widths") + + variable_count = input_width + 1 # affine constant followed by raw bits + minimum_rows = variable_count + validation_rows + if len(inputs) < minimum_rows: + raise ValueError( + f"affine inference needs at least {minimum_rows} shots for {input_width} raw measurements " + f"and {validation_rows} validation rows; got {len(inputs)}", + ) + + matrix = [ + [1, *(_bit(value, context="raw measurement") for value in raw), *derived] + for raw, derived in zip(inputs, outputs, strict=True) + ] + pivot_rows: dict[int, int] = {} + next_row = 0 + for column in range(variable_count): + pivot = next((row for row in range(next_row, len(matrix)) if matrix[row][column]), None) + if pivot is None: + continue + matrix[next_row], matrix[pivot] = matrix[pivot], matrix[next_row] + for row in range(len(matrix)): + if row != next_row and matrix[row][column]: + matrix[row] = [left ^ right for left, right in zip(matrix[row], matrix[next_row], strict=True)] + pivot_rows[column] = next_row + next_row += 1 + + if len(pivot_rows) != variable_count: + raise ValueError( + f"coin-toss probes have GF(2) rank {len(pivot_rows)}; need {variable_count}. " + "Increase probe_shots or change the seed.", + ) + for row in matrix: + if not any(row[:variable_count]) and any(row[variable_count:]): + raise ValueError("derived Guppy outputs are not affine parities of the raw measurement record") + + inferred: list[tuple[int, tuple[int, ...]]] = [] + for output in range(output_width): + coefficients = tuple(matrix[pivot_rows[column]][variable_count + output] for column in range(variable_count)) + constant = coefficients[0] + support = tuple(index for index, coefficient in enumerate(coefficients[1:]) if coefficient) + inferred.append((constant, support)) + + for shot, (raw, derived) in enumerate(zip(inputs, outputs, strict=True)): + for output, (constant, support) in enumerate(inferred): + predicted = constant + for index in support: + predicted ^= raw[index] + if predicted != derived[output]: + raise ValueError( + f"derived output {output} is not affine in the raw measurements (failed at shot {shot})", + ) + return tuple(inferred) + + +def _named_trace_items(trace: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]: + return [item for chunk in trace for item in (chunk.get("named_result_traces") or []) if isinstance(item, Mapping)] + + +def _trace_shots(trace: Sequence[Mapping[str, Any]]) -> list[list[Mapping[str, Any]]]: + shots: dict[tuple[int, int], list[Mapping[str, Any]]] = {} + for chunk in trace: + engine_id = chunk.get("engine_trace_id") + shot_index = chunk.get("shot_index") + if isinstance(engine_id, bool) or not isinstance(engine_id, int): + raise TypeError("QIS provenance trace is missing a valid engine_trace_id") + if isinstance(shot_index, bool) or not isinstance(shot_index, int): + raise TypeError("QIS provenance trace is missing a valid shot_index") + shots.setdefault((engine_id, shot_index), []).append(chunk) + return [sorted(chunks, key=lambda item: int(item.get("chunk_index", -1))) for _, chunks in sorted(shots.items())] + + +def _lowered_schedule(shot: Sequence[Mapping[str, Any]]) -> tuple[str, ...]: + return tuple( + json.dumps(gate, sort_keys=True, separators=(",", ":")) + for chunk in shot + for gate in (chunk.get("lowered_quantum_ops") or []) + if isinstance(gate, Mapping) + ) + + +def _correlate_raw_measurement_ids( + trace: Sequence[Mapping[str, Any]], + *, + raw_tag: str, + source_ids: Sequence[int], +) -> list[int]: + """Recover aggregate raw-output identity by independent probe signatures.""" + shots = _trace_shots(trace) + if len(shots) < 2: + raise ValueError("measurement provenance correlation needs at least two trace shots") + expected_schedule = _lowered_schedule(shots[0]) + raw_rows: list[list[int]] = [] + physical_rows: list[list[int]] = [] + for shot_number, shot in enumerate(shots): + if _lowered_schedule(shot) != expected_schedule: + raise ValueError( + f"quantum operation schedule changed during provenance probing at shot {shot_number}; " + "a single static DEM cannot represent this program", + ) + raw_row = [ + _bit(value, context=f"raw result tag {raw_tag!r}, provenance shot {shot_number}") + for item in _named_trace_items(shot) + if item.get("name") == raw_tag + for value in (item.get("values") or []) + ] + terminal = [chunk for chunk in shot if chunk.get("stage") == "trace_complete"] + if len(terminal) != 1: + raise ValueError(f"provenance shot {shot_number} has {len(terminal)} terminal trace chunks") + raw_results = terminal[0].get("measurement_results") + if not isinstance(raw_results, Mapping): + raise TypeError("QIS terminal trace lacks result-ID keyed measurement outcomes") + try: + outcomes = { + int(result_id): _bit(value, context="QIS measurement outcome") + for result_id, value in raw_results.items() + } + except (TypeError, ValueError) as error: + raise ValueError("QIS terminal trace contains invalid measurement outcomes") from error + if set(outcomes) != set(source_ids): + raise ValueError( + f"provenance shot {shot_number} measurement ids differ from the source trace: " + f"shot={sorted(outcomes)[:12]}, source={list(source_ids)[:12]}", + ) + if len(raw_row) != len(source_ids): + raise ValueError( + f"result tag {raw_tag!r} emits {len(raw_row)} values during provenance probing, " + f"but the QIS trace has {len(source_ids)} measurements", + ) + raw_rows.append(raw_row) + physical_rows.append([outcomes[result_id] for result_id in source_ids]) + + physical_signatures: dict[tuple[int, ...], list[int]] = {} + for column, result_id in enumerate(source_ids): + signature = tuple(row[column] for row in physical_rows) + physical_signatures.setdefault(signature, []).append(result_id) + collisions = [ids for ids in physical_signatures.values() if len(ids) != 1] + if collisions: + raise ValueError( + "physical measurement signatures are ambiguous during provenance probing; " + f"increase provenance_shots (first collision: {collisions[0][:8]})", + ) + + raw_ids: list[int] = [] + for column in range(len(source_ids)): + signature = tuple(row[column] for row in raw_rows) + matches = physical_signatures.get(signature) + if matches is None: + raise ValueError( + f"raw output element {column} is not a direct physical measurement across provenance probes", + ) + raw_ids.append(matches[0]) + if len(set(raw_ids)) != len(source_ids) or set(raw_ids) != set(source_ids): + raise ValueError( + f"result tag {raw_tag!r} does not expose every physical measurement exactly once; " + f"correlated ids={raw_ids[:12]}, source ids={list(source_ids)[:12]}", + ) + return raw_ids + + +@dataclass(frozen=True, slots=True) +class InferredGuppyDemAnnotations: + """An annotated QIS trace and its inferred detector/observable schema.""" + + circuit: Any + detectors_json: str + observables_json: str + raw_measurement_ids: tuple[int, ...] + detector_supports: tuple[tuple[int, ...], ...] + observable_supports: tuple[tuple[int, ...], ...] + observable_labels: tuple[tuple[str, int], ...] + probe_shots: int + raw_binding: str + + def build_dem(self, **noise: Any) -> Any: + """Build a PECOS DEM from the annotated trace, without Stim.""" + from pecos.qec.dem import DetectorErrorModel # noqa: PLC0415 + + return DetectorErrorModel.from_circuit(self.circuit, **noise) + + +def infer_guppy_dem_annotations( + program: object, + *, + num_qubits: int, + raw_tag: str = "raw measurements", + detector_tag: str = "DETECTOR", + observable_tags: Sequence[str] = ("obs",), + probe_shots: int = 256, + provenance_shots: int = 32, + validation_rows: int = 32, + seed: int = 0, + runtime: object | None = None, + require_raw_provenance: bool = True, +) -> InferredGuppyDemAnnotations: + """Infer parity annotations from an untouched Guppy program. + + The program must emit every physical measurement, in QIS measurement + through one or more ``result(raw_tag, ...)`` calls. Detector and + observable outputs may be computed XOR expressions. Coin-toss execution + makes the physical results independent GF(2) variables; Gaussian + elimination recovers each emitted parity and extra rows validate it. + + This prototype is suitable only when measurement values do not alter the + quantum operation schedule. PECOS captures one QIS path for the returned + circuit; the affine checks certify classical parity processing, not static + quantum control flow. ``require_raw_provenance`` defaults to true. Its + opt-in false setting assumes raw output order equals QIS measurement order + and records that weaker binding in the returned object and circuit. When + direct runtime result IDs are unavailable, the default mode correlates raw + output columns with result-ID keyed physical outcomes across independent + trace probes and requires a unique complete bijection. + """ + import pecos_rslib # noqa: PLC0415 + + import pecos # noqa: PLC0415 + from pecos._traced_circuit import normalize_traced_tick_circuit # noqa: PLC0415 + from pecos.tracing import ( # noqa: PLC0415 + _capture_qis_operation_traces, + capture_qis_operation_trace, + qis_operation_trace_to_tick_circuit, + ) + + if not observable_tags: + raise ValueError("observable_tags must contain at least one result tag") + if probe_shots <= 0 or provenance_shots < 2 or validation_rows < 1: + raise ValueError("probe_shots must be positive, provenance_shots at least 2, and validation_rows at least 1") + + trace = capture_qis_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + circuit = qis_operation_trace_to_tick_circuit(trace) + normalize_traced_tick_circuit(circuit, context="infer_guppy_dem_annotations") + + raw_ids: list[int] = [] + raw_value_count = 0 + provenance_complete = True + for item in _named_trace_items(trace): + if item.get("name") != raw_tag: + continue + values = item.get("values") + result_ids = item.get("result_ids") + if not isinstance(values, list): + raise TypeError(f"raw result tag {raw_tag!r} has an invalid runtime trace value list") + raw_value_count += len(values) + if not isinstance(result_ids, list) or len(values) != len(result_ids): + provenance_complete = False + continue + if any(isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in result_ids): + raise ValueError(f"raw result tag {raw_tag!r} contains an invalid measurement id") + raw_ids.extend(result_ids) + + source_ids_json = circuit.get_meta("qis_source_measurement_ids") + source_ids = json.loads(source_ids_json) if source_ids_json else [] + if provenance_complete and (len(set(raw_ids)) != len(source_ids) or set(raw_ids) != set(source_ids)): + raise ValueError( + f"result tag {raw_tag!r} must expose every physical measurement exactly once; " + f"tag ids={raw_ids[:12]}, source ids={source_ids[:12]}", + ) + if not provenance_complete: + if require_raw_provenance: + provenance_trace = _capture_qis_operation_traces( + program, + num_qubits, + shots=provenance_shots, + seed=seed, + runtime=runtime, + ) + raw_ids = _correlate_raw_measurement_ids( + provenance_trace, + raw_tag=raw_tag, + source_ids=source_ids, + ) + raw_binding = "probe_correlated_result_ids" + else: + if raw_value_count != len(source_ids): + raise ValueError( + f"result tag {raw_tag!r} emits {raw_value_count} traced values, but the QIS trace has " + f"{len(source_ids)} measurements", + ) + raw_ids = list(source_ids) + raw_binding = "assumed_canonical_result_order" + else: + raw_binding = "runtime_result_ids" + + results = ( + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos_rslib.coin_toss()) + .qubits(num_qubits) + .seed(seed) + .run(probe_shots) + .to_dict() + ) + missing = [tag for tag in (raw_tag, detector_tag, *observable_tags) if tag not in results] + if missing: + raise ValueError(f"Guppy results are missing required tag(s): {missing}") + + raw_rows = _rows(results[raw_tag], tag=raw_tag) + if len(raw_rows[0]) != len(raw_ids): + raise ValueError( + f"result tag {raw_tag!r} emits {len(raw_rows[0])} values per shot, " + f"but the QIS trace has {len(raw_ids)} measurements", + ) + detector_rows = _rows(results[detector_tag], tag=detector_tag) + observable_parts = [(tag, _rows(results[tag], tag=tag)) for tag in observable_tags] + observable_rows = [[value for _, rows in observable_parts for value in rows[shot]] for shot in range(probe_shots)] + observable_labels = tuple((tag, element) for tag, rows in observable_parts for element in range(len(rows[0]))) + + detector_affine = _infer_affine_columns(raw_rows, detector_rows, validation_rows=validation_rows) + observable_affine = _infer_affine_columns(raw_rows, observable_rows, validation_rows=validation_rows) + nonzero_offsets = [ + *(f"detector {index}" for index, (constant, _) in enumerate(detector_affine) if constant), + *(f"observable {index}" for index, (constant, _) in enumerate(observable_affine) if constant), + ] + if nonzero_offsets: + raise ValueError( + "DEM annotations cannot represent affine constant-one outputs: " + ", ".join(nonzero_offsets[:8]), + ) + + detector_supports = tuple(tuple(raw_ids[index] for index in support) for _, support in detector_affine) + observable_supports = tuple(tuple(raw_ids[index] for index in support) for _, support in observable_affine) + if any(not support for support in (*detector_supports, *observable_supports)): + raise ValueError("detector and observable outputs must depend on at least one physical measurement") + + detectors = [ + {"id": index, "meas_ids": list(support), "inferred_from_result_tag": detector_tag} + for index, support in enumerate(detector_supports) + ] + observables = [ + { + "id": index, + "meas_ids": list(support), + "inferred_from_result_tag": tag, + "result_element": element, + } + for index, (support, (tag, element)) in enumerate(zip(observable_supports, observable_labels, strict=True)) + ] + detectors_json = json.dumps(detectors, separators=(",", ":")) + observables_json = json.dumps(observables, separators=(",", ":")) + circuit.set_meta("detectors", detectors_json) + circuit.set_meta("observables", observables_json) + circuit.set_meta("num_measurements", str(len(raw_ids))) + circuit.set_meta("guppy_dem_annotation_method", "coin_toss_affine_inference_v2") + circuit.set_meta("guppy_raw_measurement_binding", raw_binding) + + return InferredGuppyDemAnnotations( + circuit=circuit, + detectors_json=detectors_json, + observables_json=observables_json, + raw_measurement_ids=tuple(raw_ids), + detector_supports=detector_supports, + observable_supports=observable_supports, + observable_labels=observable_labels, + probe_shots=probe_shots, + raw_binding=raw_binding, + ) + + +__all__ = ["InferredGuppyDemAnnotations", "infer_guppy_dem_annotations"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index f2c0df689..3789be7a7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -17,6 +17,8 @@ parity_matrix_z: Generate Z parity check matrix """ +import warnings + # Circuit generation from geometry (unified abstraction) from pecos.qec.surface._clifford_deformation import ( LocalCliffordFrame, @@ -62,7 +64,7 @@ DecoderType, DecodingResult, NativeSampler, - NoiseModel, + NoiseParameters, SimulationResult, SurfaceDecoder, build_memory_circuit, @@ -124,6 +126,20 @@ get_stab_schedule, ) + +def __getattr__(name: str) -> type[NoiseParameters]: + """Resolve deprecated surface-code attributes lazily.""" + if name == "NoiseModel": + warnings.warn( + "NoiseModel is deprecated; use NoiseParameters instead (from pecos import NoiseParameters).", + DeprecationWarning, + stacklevel=2, + ) + return NoiseParameters + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + __all__ = [ # Twirling config (Pauli-frame randomization) "GuppyRngMaskConfig", @@ -170,6 +186,7 @@ "DecodingResult", "NativeSampler", "NoiseModel", + "NoiseParameters", "RUNTIME_IDLE_TIME_UNITS_PER_SECOND", "SimulationResult", "SurfaceDecoder", diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py index 0886f965e..742f997bf 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py @@ -468,13 +468,13 @@ def compare_dems( Returns: Dictionary with comparison results """ - from pecos.qec.surface.decode import NoiseModel, generate_surface_code_dem + from pecos.qec.surface.decode import NoiseParameters, generate_surface_code_dem # Generate circuit-level DEM via Stim stim_dem = generate_circuit_level_dem(patch, num_rounds, basis, p=p) # Generate phenomenological DEM - noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p) + noise = NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p) stab_type = "X" if basis.upper() == "X" else "Z" phenom_dem = generate_surface_code_dem(patch, num_rounds, noise, stab_type) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index c17455d2f..d6dc65c63 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -44,6 +44,7 @@ from __future__ import annotations import math +import warnings from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace from enum import Enum @@ -57,6 +58,7 @@ measurement_ids_in_execution_order, normalize_traced_tick_circuit, ) +from pecos.qec._idle_noise import _translate_structured_idle_noise from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan if TYPE_CHECKING: @@ -138,8 +140,8 @@ class DecoderType(str, Enum): @dataclass -class NoiseModel: - """Circuit-level noise parameters for QEC simulation. +class NoiseParameters: + """Noise parameters consumed during detector error model construction. Matches the Rust ``NoiseConfig`` type. All parameters are optional beyond the four base rates. @@ -174,19 +176,61 @@ class NoiseModel: p_idle: Idle noise rate per time unit (uniform depolarizing). t1: T1 relaxation time for idle noise (same units as idle duration). t2: T2 dephasing time (must satisfy t2 <= 2*t1). - p_idle_linear_rate: Legacy alias for stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Legacy alias for stochastic Z-memory rate quadratic in idle duration. - p_idle_x_linear_rate: Stochastic X-memory rate linear in idle duration. - p_idle_y_linear_rate: Stochastic Y-memory rate linear in idle duration. - p_idle_z_linear_rate: Stochastic Z-memory rate linear in idle duration. - p_idle_x_quadratic_rate: Stochastic X-memory rate quadratic in idle duration. - p_idle_y_quadratic_rate: Stochastic Y-memory rate quadratic in idle duration. - p_idle_z_quadratic_rate: Stochastic Z-memory rate quadratic in idle duration. - p_idle_quadratic_sine_rate: Legacy alias for stochastic Z-memory rate - with probability ``sin(rate * duration)^2``. - p_idle_x_quadratic_sine_rate: Stochastic X-memory sine-law rate. - p_idle_y_quadratic_sine_rate: Stochastic Y-memory sine-law rate. - p_idle_z_quadratic_sine_rate: Stochastic Z-memory sine-law rate. + p_idle_linear: Optional total stochastic idle-noise rate linear in idle + duration. By default, the total rate is split equally over X, Y, + and Z errors. DEM construction groups non-empty propagated flip + signatures before converting distinct signatures to independent + mechanisms. Infeasible exact conversions use a non-negative fit + and expose their quantified residual on the DEM. + p_idle_linear_model: Optional relative weights over ``"X"``, ``"Y"``, + ``"Z"``, and ``"L"`` for ``p_idle_linear``. Weights must be finite, + non-negative, and sum to 1.0; ``"L"`` must have zero weight because + DEM fault propagation is Pauli-only. + p_idle_sin_squared: Optional stochastic sine-law idle rate. An axis + multiplier ``m`` produces probability + ``sin((p_idle_sin_squared * m) * duration)^2``. By default X, Y, + and Z each use multiplier 1.0. These mechanisms remain separate + from the linear family. + p_idle_sin_squared_model: Optional relative-rate multipliers over + ``"X"``, ``"Y"``, ``"Z"``, and ``"L"`` for + ``p_idle_sin_squared``. Values must be finite and non-negative; + ``"L"`` must have zero weight because DEM fault propagation is + Pauli-only. + p_idle_coherent: Optional coherent-rotation rate. The standard DEM + route rejects nonzero coherent idle noise because it cannot + represent coherence. Zero has no effect. + p_idle_coherent_model: Optional relative-rate multipliers over ``"RX"``, + ``"RY"``, and ``"RZ"`` for ``p_idle_coherent``. Values must be + finite and non-negative. + _p_idle_linear_rate: Internal legacy canonical scalar for stochastic + Z-memory noise linear in idle duration. + _p_idle_quadratic_rate: Internal legacy canonical scalar for stochastic + Z-memory noise quadratic in idle duration. + _p_idle_x_linear_rate: Internal canonical X-memory rate linear in idle duration. + _p_idle_y_linear_rate: Internal canonical Y-memory rate linear in idle duration. + _p_idle_z_linear_rate: Internal canonical Z-memory rate linear in idle duration. + _p_idle_x_quadratic_rate: Internal canonical X-memory rate quadratic in idle duration. + _p_idle_y_quadratic_rate: Internal canonical Y-memory rate quadratic in idle duration. + _p_idle_z_quadratic_rate: Internal canonical Z-memory rate quadratic in idle duration. + _p_idle_quadratic_sine_rate: Internal legacy canonical scalar for stochastic + Z-memory noise with probability ``sin(rate * duration)^2``. + _p_idle_x_quadratic_sine_rate: Internal canonical X-memory sine-law rate. + _p_idle_y_quadratic_sine_rate: Internal canonical Y-memory sine-law rate. + _p_idle_z_quadratic_sine_rate: Internal canonical Z-memory sine-law rate. + + The internal canonical scalar fields are not user configuration. The + three structured family setters normalize into them during construction, + then clear the family fields. Migrate removed setters mechanically: + + - ``with_p_idle_z_linear_rate(r)`` becomes + ``with_p_idle_linear(r, {"Z": 1.0})``. + - ``with_p_idle_x_quadratic_sine_rate(r)`` becomes + ``with_p_idle_sin_squared(r, {"X": 1.0})``. + - ``with_p_idle_linear_rate(r)`` becomes + ``with_p_idle_linear(r, {"Z": 1.0})``. Despite its axis-free name, + the removed setter was Z-only. The identically named + ``general_noise()`` setter instead configures a total rate split by a + model, so values must not be copied between the two interfaces. Runtime idle units: For ``traced_qis`` DEMs, runtime idles are replayed as nanosecond @@ -206,18 +250,24 @@ class NoiseModel: p_idle: float | None = None t1: float | None = None t2: float | None = None - p_idle_linear_rate: float | None = None - p_idle_quadratic_rate: float | None = None - p_idle_x_linear_rate: float | None = None - p_idle_y_linear_rate: float | None = None - p_idle_z_linear_rate: float | None = None - p_idle_x_quadratic_rate: float | None = None - p_idle_y_quadratic_rate: float | None = None - p_idle_z_quadratic_rate: float | None = None - p_idle_quadratic_sine_rate: float | None = None - p_idle_x_quadratic_sine_rate: float | None = None - p_idle_y_quadratic_sine_rate: float | None = None - p_idle_z_quadratic_sine_rate: float | None = None + _p_idle_linear_rate: float | None = None + _p_idle_quadratic_rate: float | None = None + _p_idle_x_linear_rate: float | None = None + _p_idle_y_linear_rate: float | None = None + _p_idle_z_linear_rate: float | None = None + _p_idle_x_quadratic_rate: float | None = None + _p_idle_y_quadratic_rate: float | None = None + _p_idle_z_quadratic_rate: float | None = None + _p_idle_quadratic_sine_rate: float | None = None + _p_idle_x_quadratic_sine_rate: float | None = None + _p_idle_y_quadratic_sine_rate: float | None = None + _p_idle_z_quadratic_sine_rate: float | None = None + p_idle_linear: float | None = None + p_idle_linear_model: Mapping[str, float] | None = None + p_idle_sin_squared: float | None = None + p_idle_sin_squared_model: Mapping[str, float] | None = None + p_idle_coherent: float | None = None + p_idle_coherent_model: Mapping[str, float] | None = None def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" @@ -227,36 +277,148 @@ def __post_init__(self) -> None: self.p2_szz = _validate_probability("p2_szz", self.p2_szz) if self.p2_szzdg is not None: self.p2_szzdg = _validate_probability("p2_szzdg", self.p2_szzdg) + ( + self._p_idle_x_linear_rate, + self._p_idle_y_linear_rate, + self._p_idle_z_linear_rate, + self._p_idle_x_quadratic_sine_rate, + self._p_idle_y_quadratic_sine_rate, + self._p_idle_z_quadratic_sine_rate, + ) = _translate_structured_idle_noise( + p_idle_linear=self.p_idle_linear, + p_idle_linear_model=self.p_idle_linear_model, + p_idle_sin_squared=self.p_idle_sin_squared, + p_idle_sin_squared_model=self.p_idle_sin_squared_model, + p_idle_coherent=self.p_idle_coherent, + p_idle_coherent_model=self.p_idle_coherent_model, + p_idle_linear_rate=self._p_idle_linear_rate, + p_idle_quadratic_rate=self._p_idle_quadratic_rate, + p_idle_x_linear_rate=self._p_idle_x_linear_rate, + p_idle_y_linear_rate=self._p_idle_y_linear_rate, + p_idle_z_linear_rate=self._p_idle_z_linear_rate, + p_idle_quadratic_sine_rate=self._p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=self._p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=self._p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=self._p_idle_z_quadratic_sine_rate, + ) + self.p_idle_linear = None + self.p_idle_linear_model = None + self.p_idle_sin_squared = None + self.p_idle_sin_squared_model = None + self.p_idle_coherent = None + self.p_idle_coherent_model = None + + def with_p1(self, p1: float) -> NoiseParameters: + """Return a copy with ``p1`` set to the given value.""" + return replace(self, p1=p1) + + def with_p1_weights(self, p1_weights: P1Weights | None) -> NoiseParameters: + """Return a copy with ``p1_weights`` set to the given value.""" + return replace(self, p1_weights=p1_weights) + + def with_p2(self, p2: float) -> NoiseParameters: + """Return a copy with ``p2`` set to the given value.""" + return replace(self, p2=p2) + + def with_p2_szz(self, p2_szz: float | None) -> NoiseParameters: + """Return a copy with ``p2_szz`` set to the given value.""" + return replace(self, p2_szz=p2_szz) + + def with_p2_szzdg(self, p2_szzdg: float | None) -> NoiseParameters: + """Return a copy with ``p2_szzdg`` set to the given value.""" + return replace(self, p2_szzdg=p2_szzdg) + + def with_p2_weights(self, p2_weights: P2Weights | None) -> NoiseParameters: + """Return a copy with ``p2_weights`` set to the given value.""" + return replace(self, p2_weights=p2_weights) + + def with_p2_replacement_approximation( + self, + p2_replacement_approximation: str | None, + ) -> NoiseParameters: + """Return a copy with ``p2_replacement_approximation`` set to the given value.""" + return replace(self, p2_replacement_approximation=p2_replacement_approximation) + + def with_p_meas(self, p_meas: float) -> NoiseParameters: + """Return a copy with ``p_meas`` set to the given value.""" + return replace(self, p_meas=p_meas) + + def with_p_prep(self, p_prep: float) -> NoiseParameters: + """Return a copy with ``p_prep`` set to the given value.""" + return replace(self, p_prep=p_prep) + + def with_p_idle(self, p_idle: float | None) -> NoiseParameters: + """Return a copy with ``p_idle`` set to the given value.""" + return replace(self, p_idle=p_idle) + + def with_t1(self, t1: float | None) -> NoiseParameters: + """Return a copy with ``t1`` set to the given value.""" + return replace(self, t1=t1) + + def with_t2(self, t2: float | None) -> NoiseParameters: + """Return a copy with ``t2`` set to the given value.""" + return replace(self, t2=t2) + + # The idle families take their rate and model together: a model without a + # rate is inert and rejected, and __post_init__ translates a family into the + # canonical per-axis fields and then clears it -- so setting the two halves + # in separate calls would make the second call collide with the per-axis + # values the first one produced. + def with_p_idle_linear( + self, + p_idle_linear: float | None, + model: Mapping[str, float] | None = None, + ) -> NoiseParameters: + """Return a copy with the linear idle family set to the given rate and model.""" + return replace(self, p_idle_linear=p_idle_linear, p_idle_linear_model=model) + + def with_p_idle_sin_squared( + self, + p_idle_sin_squared: float | None, + model: Mapping[str, float] | None = None, + ) -> NoiseParameters: + """Return a copy with the sine-law idle family set to the given rate and model.""" + return replace(self, p_idle_sin_squared=p_idle_sin_squared, p_idle_sin_squared_model=model) + + def with_p_idle_coherent( + self, + p_idle_coherent: float | None, + model: Mapping[str, float] | None = None, + ) -> NoiseParameters: + """Return a copy with the coherent idle family set to the given rate and model.""" + return replace(self, p_idle_coherent=p_idle_coherent, p_idle_coherent_model=model) @property def effective_p_idle_z_linear_rate(self) -> float | None: - """Z-axis linear idle rate, accepting the legacy alias.""" - return self.p_idle_z_linear_rate if self.p_idle_z_linear_rate is not None else self.p_idle_linear_rate + """Return the internal Z-axis linear idle rate, accepting the legacy scalar.""" + return self._p_idle_z_linear_rate if self._p_idle_z_linear_rate is not None else self._p_idle_linear_rate @property def effective_p_idle_z_quadratic_rate(self) -> float | None: - """Z-axis quadratic idle rate, accepting the legacy alias.""" - return self.p_idle_z_quadratic_rate if self.p_idle_z_quadratic_rate is not None else self.p_idle_quadratic_rate + """Return the internal Z-axis quadratic idle rate, accepting the legacy scalar.""" + return ( + self._p_idle_z_quadratic_rate if self._p_idle_z_quadratic_rate is not None else self._p_idle_quadratic_rate + ) @property def effective_p_idle_z_quadratic_sine_rate(self) -> float | None: - """Z-axis sine-law quadratic idle rate, accepting the legacy alias.""" - if self.p_idle_z_quadratic_sine_rate is not None: - return self.p_idle_z_quadratic_sine_rate - return self.p_idle_quadratic_sine_rate + """Return the internal Z-axis sine-law rate, accepting the legacy scalar.""" + if self._p_idle_z_quadratic_sine_rate is not None: + return self._p_idle_z_quadratic_sine_rate + return self._p_idle_quadratic_sine_rate @property def idle_memory_rates(self) -> tuple[float | None, ...]: """All dedicated Pauli idle-memory rates that require explicit idles.""" return ( - self.p_idle_x_linear_rate, - self.p_idle_y_linear_rate, + self._p_idle_x_linear_rate, + self._p_idle_y_linear_rate, self.effective_p_idle_z_linear_rate, - self.p_idle_x_quadratic_rate, - self.p_idle_y_quadratic_rate, + self._p_idle_x_quadratic_rate, + self._p_idle_y_quadratic_rate, self.effective_p_idle_z_quadratic_rate, - self.p_idle_x_quadratic_sine_rate, - self.p_idle_y_quadratic_sine_rate, + self._p_idle_x_quadratic_sine_rate, + self._p_idle_y_quadratic_sine_rate, self.effective_p_idle_z_quadratic_sine_rate, ) @@ -269,7 +431,7 @@ def for_runtime_idle_time_units( self, *, time_units_per_second: float = RUNTIME_IDLE_TIME_UNITS_PER_SECOND, - ) -> NoiseModel: + ) -> NoiseParameters: """Return a copy whose idle noise is expressed in runtime replay units. Selene-compatible runtimes emit idle durations in seconds, but the @@ -293,25 +455,25 @@ def for_runtime_idle_time_units( p_idle=_convert_optional_rate(self.p_idle, units), t1=_convert_optional_time(self.t1, units), t2=_convert_optional_time(self.t2, units), - p_idle_linear_rate=_convert_optional_rate(self.p_idle_linear_rate, units), - p_idle_x_linear_rate=_convert_optional_rate(self.p_idle_x_linear_rate, units), - p_idle_y_linear_rate=_convert_optional_rate(self.p_idle_y_linear_rate, units), - p_idle_z_linear_rate=_convert_optional_rate(self.p_idle_z_linear_rate, units), - p_idle_quadratic_rate=_convert_optional_rate(self.p_idle_quadratic_rate, units_squared), - p_idle_x_quadratic_rate=_convert_optional_rate(self.p_idle_x_quadratic_rate, units_squared), - p_idle_y_quadratic_rate=_convert_optional_rate(self.p_idle_y_quadratic_rate, units_squared), - p_idle_z_quadratic_rate=_convert_optional_rate(self.p_idle_z_quadratic_rate, units_squared), - p_idle_quadratic_sine_rate=_convert_optional_rate(self.p_idle_quadratic_sine_rate, units), - p_idle_x_quadratic_sine_rate=_convert_optional_rate(self.p_idle_x_quadratic_sine_rate, units), - p_idle_y_quadratic_sine_rate=_convert_optional_rate(self.p_idle_y_quadratic_sine_rate, units), - p_idle_z_quadratic_sine_rate=_convert_optional_rate(self.p_idle_z_quadratic_sine_rate, units), + _p_idle_linear_rate=_convert_optional_rate(self._p_idle_linear_rate, units), + _p_idle_x_linear_rate=_convert_optional_rate(self._p_idle_x_linear_rate, units), + _p_idle_y_linear_rate=_convert_optional_rate(self._p_idle_y_linear_rate, units), + _p_idle_z_linear_rate=_convert_optional_rate(self._p_idle_z_linear_rate, units), + _p_idle_quadratic_rate=_convert_optional_rate(self._p_idle_quadratic_rate, units_squared), + _p_idle_x_quadratic_rate=_convert_optional_rate(self._p_idle_x_quadratic_rate, units_squared), + _p_idle_y_quadratic_rate=_convert_optional_rate(self._p_idle_y_quadratic_rate, units_squared), + _p_idle_z_quadratic_rate=_convert_optional_rate(self._p_idle_z_quadratic_rate, units_squared), + _p_idle_quadratic_sine_rate=_convert_optional_rate(self._p_idle_quadratic_sine_rate, units), + _p_idle_x_quadratic_sine_rate=_convert_optional_rate(self._p_idle_x_quadratic_sine_rate, units), + _p_idle_y_quadratic_sine_rate=_convert_optional_rate(self._p_idle_y_quadratic_sine_rate, units), + _p_idle_z_quadratic_sine_rate=_convert_optional_rate(self._p_idle_z_quadratic_sine_rate, units), ) @staticmethod - def uniform(physical_error_rate: float) -> NoiseModel: + def uniform(physical_error_rate: float) -> NoiseParameters: """Create a uniform circuit-level noise model from one physical error rate.""" p = _validate_probability("physical_error_rate", physical_error_rate) - return NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p) + return NoiseParameters(p1=p, p2=p, p_meas=p, p_prep=p) @property def is_noiseless(self) -> bool: @@ -337,6 +499,19 @@ def physical_error_rate(self) -> float: return max(rates) +def __getattr__(name: str) -> Any: + """Resolve deprecated module attributes lazily.""" + if name == "NoiseModel": + warnings.warn( + "NoiseModel is deprecated; use NoiseParameters instead (from pecos import NoiseParameters).", + DeprecationWarning, + stacklevel=2, + ) + return NoiseParameters + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + def _normalize_pauli_weights(weights: P1Weights | P2Weights | None) -> tuple[tuple[str, float], ...] | None: if weights is None: return None @@ -362,7 +537,7 @@ def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: return None if normalized is None else dict(normalized) -def _p2_gate_rates_dict(noise: NoiseModel) -> dict[str, float] | None: +def _p2_gate_rates_dict(noise: NoiseParameters) -> dict[str, float] | None: rates: dict[str, float] = {} if noise.p2_szz is not None: rates["SZZ"] = noise.p2_szz @@ -538,7 +713,7 @@ def det_id(round_: int, check: int) -> int: def generate_surface_code_dem( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, stab_type: str = "Z", ) -> str: """Generate a phenomenological DEM for surface code decoding. @@ -1297,29 +1472,29 @@ def _uses_dedicated_idle_noise( ) -def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: +def _noise_uses_dedicated_idle_noise(noise: NoiseParameters) -> bool: """Return True when this noise model requires explicit idle locations.""" return _uses_dedicated_idle_noise( p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + p_idle_linear_rate=noise._p_idle_linear_rate, + p_idle_quadratic_rate=noise._p_idle_quadratic_rate, + p_idle_x_linear_rate=noise._p_idle_x_linear_rate, + p_idle_y_linear_rate=noise._p_idle_y_linear_rate, + p_idle_z_linear_rate=noise._p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise._p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise._p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise._p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise._p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise._p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise._p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise._p_idle_z_quadratic_sine_rate, ) def _reject_szz_unlowered_physical_noise( - noise: NoiseModel, + noise: NoiseParameters, interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> None: @@ -1341,7 +1516,7 @@ def _reject_szz_unlowered_physical_noise( def _use_szz_physical_prefixes( - noise: NoiseModel, + noise: NoiseParameters, interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> bool: @@ -1368,7 +1543,7 @@ def _szz_z_frame_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[s def _with_noise_compat( builder: Any, - noise: NoiseModel, + noise: NoiseParameters, *, p1_gate_rates: Mapping[str, float] | None = None, ) -> Any: @@ -1377,18 +1552,18 @@ def _with_noise_compat( "p_idle": noise.p_idle, "t1": noise.t1, "t2": noise.t2, - "p_idle_linear_rate": noise.p_idle_linear_rate, - "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, - "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, - "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, - "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, - "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, - "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, - "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, - "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, - "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, - "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, - "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "p_idle_linear_rate": noise._p_idle_linear_rate, + "p_idle_quadratic_rate": noise._p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise._p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise._p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise._p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise._p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise._p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise._p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise._p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise._p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise._p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise._p_idle_z_quadratic_sine_rate, "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } @@ -1583,7 +1758,7 @@ def _cached_surface_native_topology( def _dem_string_from_cached_surface_topology( topology: _CachedNativeSurfaceTopology, - noise: NoiseModel, + noise: NoiseParameters, *, decompose_errors: bool, dem_decomposition: NativeDemDecomposition = "source_graphlike", @@ -1710,7 +1885,7 @@ def _cached_surface_native_dem_string( ) return _dem_string_from_cached_surface_topology( topology, - NoiseModel( + NoiseParameters( p1=p1, p1_weights=p1_weights, p2=p2, @@ -1723,18 +1898,18 @@ def _cached_surface_native_dem_string( p_idle=p_idle, t1=t1, t2=t2, - p_idle_linear_rate=p_idle_linear_rate, - p_idle_quadratic_rate=p_idle_quadratic_rate, - p_idle_x_linear_rate=p_idle_x_linear_rate, - p_idle_y_linear_rate=p_idle_y_linear_rate, - p_idle_z_linear_rate=p_idle_z_linear_rate, - p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + _p_idle_linear_rate=p_idle_linear_rate, + _p_idle_quadratic_rate=p_idle_quadratic_rate, + _p_idle_x_linear_rate=p_idle_x_linear_rate, + _p_idle_y_linear_rate=p_idle_y_linear_rate, + _p_idle_z_linear_rate=p_idle_z_linear_rate, + _p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + _p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + _p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + _p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + _p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + _p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + _p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ), decompose_errors=decompose_errors, dem_decomposition=dem_decomposition, @@ -1751,7 +1926,7 @@ def _cached_parsed_dem(dem_str: str) -> Any: def _build_native_sampler_from_cached_surface_topology( topology: _CachedNativeSurfaceTopology, - noise: NoiseModel, + noise: NoiseParameters, *, sampling_model: Literal[ "dem", @@ -1809,7 +1984,7 @@ def _build_native_sampler_from_cached_surface_topology( def generate_circuit_level_dem_from_builder( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", *, decompose_errors: bool = False, @@ -1894,10 +2069,10 @@ def generate_circuit_level_dem_from_builder( DEM string in standard format Example: - >>> from pecos.qec.surface import SurfacePatch, NoiseModel + >>> from pecos.qec.surface import SurfacePatch, NoiseParameters >>> from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder >>> patch = SurfacePatch.create(distance=3) - >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01) + >>> noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01) >>> dem = generate_circuit_level_dem_from_builder(patch, num_rounds=3, noise=noise) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) @@ -1940,18 +2115,18 @@ def generate_circuit_level_dem_from_builder( "p_idle": noise.p_idle, "t1": noise.t1, "t2": noise.t2, - "p_idle_linear_rate": noise.p_idle_linear_rate, - "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, - "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, - "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, - "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, - "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, - "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, - "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, - "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, - "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, - "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, - "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "p_idle_linear_rate": noise._p_idle_linear_rate, + "p_idle_quadratic_rate": noise._p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise._p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise._p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise._p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise._p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise._p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise._p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise._p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise._p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise._p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise._p_idle_z_quadratic_sine_rate, "twirl": twirl, "interaction_basis": interaction_basis, "check_plan": resolved_plan.plan_id, @@ -1985,7 +2160,7 @@ def generate_circuit_level_dem_from_builder( def generate_circuit_level_dem( distance: int, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", ) -> str: """Generate a circuit-level DEM using Stim's surface code generator. @@ -2009,8 +2184,8 @@ def generate_circuit_level_dem( DEM string in Stim format Example: - >>> from pecos.qec.surface import generate_circuit_level_dem, NoiseModel - >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01) + >>> from pecos.qec.surface import generate_circuit_level_dem, NoiseParameters + >>> noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01) >>> dem = generate_circuit_level_dem(distance=3, num_rounds=3, noise=noise, basis="Z") """ import stim @@ -2041,7 +2216,7 @@ def generate_circuit_level_dem( def build_stim_circuit_from_patch( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel | None = None, + noise: NoiseParameters | None = None, basis: str = "Z", ) -> stim.Circuit: """Build a Stim circuit from our patch geometry and CNOT schedule. @@ -2072,11 +2247,11 @@ def build_stim_circuit_from_patch( Example: >>> from pecos.qec.surface import ( ... SurfacePatch, - ... NoiseModel, + ... NoiseParameters, ... build_stim_circuit_from_patch, ... ) >>> patch = SurfacePatch.create(distance=3) - >>> noise = NoiseModel(p2=0.01, p_meas=0.01) + >>> noise = NoiseParameters(p2=0.01, p_meas=0.01) >>> circuit = build_stim_circuit_from_patch(patch, num_rounds=3, noise=noise) >>> dem = circuit.detector_error_model() """ @@ -2283,7 +2458,7 @@ def stab_coords(stab: Stabilizer) -> tuple[float, float]: def generate_dem_from_patch( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", *, decompose_errors: bool = True, @@ -2309,11 +2484,11 @@ def generate_dem_from_patch( Example: >>> from pecos.qec.surface import ( ... SurfacePatch, - ... NoiseModel, + ... NoiseParameters, ... generate_dem_from_patch, ... ) >>> patch = SurfacePatch.create(distance=3) - >>> noise = NoiseModel(p2=0.01, p_meas=0.01) + >>> noise = NoiseParameters(p2=0.01, p_meas=0.01) >>> dem = generate_dem_from_patch(patch, num_rounds=3, noise=noise) """ circuit = build_stim_circuit_from_patch(patch, num_rounds, noise, basis) @@ -2331,7 +2506,7 @@ class SurfaceDecoder: >>> from pecos.qec.surface import SurfacePatch, SurfaceDecoder >>> patch = SurfacePatch.create(distance=3) >>> # Default: PyMatching MWPM - >>> decoder = SurfaceDecoder(patch, num_rounds=3, noise=NoiseModel(p2=0.01, p_meas=0.01)) + >>> decoder = SurfaceDecoder(patch, num_rounds=3, noise=NoiseParameters(p2=0.01, p_meas=0.01)) >>> # Alternative: FusionBlossom MWPM >>> decoder = SurfaceDecoder(patch, num_rounds=3, decoder_type="fusion_blossom") >>> # Alternative: BP+OSD (LDPC) @@ -2343,7 +2518,7 @@ def __init__( self, patch: SurfacePatch, num_rounds: int = 1, - noise: NoiseModel | None = None, + noise: NoiseParameters | None = None, decoder_type: Literal[ "pymatching", "pymatching_correlated", @@ -2417,7 +2592,7 @@ def __init__( self.patch = patch self.num_rounds = num_rounds - self.noise = noise or NoiseModel(p2=0.01, p_meas=0.01) + self.noise = noise or NoiseParameters(p2=0.01, p_meas=0.01) self.decoder_type = DecoderType(decoder_type) self.use_circuit_level_dem = use_circuit_level_dem if circuit_level_dem_mode not in { @@ -2842,22 +3017,22 @@ def decode_z_syndrome( if self.decoder_type == DecoderType.TESSERACT: # Tesseract takes sparse detection indices detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) - # Tesseract returns observables_mask, not per-qubit correction + result = decoder.decode_from_defects(detection_indices) + # Tesseract returns observable flips, not per-qubit correction # We return a dummy correction and encode logical flip in first element num_data = self._get_z_check_matrix().shape[1] correction = np.zeros(num_data, dtype=np.uint8) - if result.observables_mask & 1: # L0 flipped + if len(result.observable_flips) > 0 and result.observable_flips[0]: # L0 flipped correction[0] = 1 # Mark that logical was predicted flipped weight = result.cost else: - result = decoder.decode(events_flat.tolist()) + result = decoder.decode_syndrome(events_flat.tolist()) # For FusionBlossom, need to clear state for next decode if self.decoder_type == DecoderType.FUSION_BLOSSOM: decoder.clear() - correction = np.array(result.correction, dtype=np.uint8) + correction = np.array(list(result.observable_flips), dtype=np.uint8) weight = result.weight else: # LDPC: use raw syndrome (last round) @@ -2869,7 +3044,10 @@ def decode_z_syndrome( else: raw_syndrome = detection_events.ravel() - result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + if self.decoder_type == DecoderType.BP_LSD: + result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + else: + result = decoder.decode_syndrome(raw_syndrome.astype(np.uint8).tolist()) correction = np.array(result.decoding, dtype=np.uint8) weight = 0.0 if result.converged else 1.0 # LDPC doesn't have weight @@ -2901,21 +3079,21 @@ def decode_x_syndrome( if self.decoder_type == DecoderType.TESSERACT: # Tesseract takes sparse detection indices detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) - # Tesseract returns observables_mask, not per-qubit correction + result = decoder.decode_from_defects(detection_indices) + # Tesseract returns observable flips, not per-qubit correction num_data = self._get_x_check_matrix().shape[1] correction = np.zeros(num_data, dtype=np.uint8) - if result.observables_mask & 1: # L0 flipped + if len(result.observable_flips) > 0 and result.observable_flips[0]: # L0 flipped correction[0] = 1 # Mark that logical was predicted flipped weight = result.cost else: - result = decoder.decode(events_flat.tolist()) + result = decoder.decode_syndrome(events_flat.tolist()) # For FusionBlossom, need to clear state for next decode if self.decoder_type == DecoderType.FUSION_BLOSSOM: decoder.clear() - correction = np.array(result.correction, dtype=np.uint8) + correction = np.array(list(result.observable_flips), dtype=np.uint8) weight = result.weight else: # LDPC: use raw syndrome (last round) @@ -2927,7 +3105,10 @@ def decode_x_syndrome( else: raw_syndrome = detection_events.ravel() - result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + if self.decoder_type == DecoderType.BP_LSD: + result = decoder.decode(raw_syndrome.astype(np.uint8).tolist()) + else: + result = decoder.decode_syndrome(raw_syndrome.astype(np.uint8).tolist()) correction = np.array(result.decoding, dtype=np.uint8) weight = 0.0 if result.converged else 1.0 # LDPC doesn't have weight @@ -3112,12 +3293,12 @@ def decode_memory_z( if self.decoder_type == DecoderType.TESSERACT: detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) - predicted_obs = result.observables_mask & 1 + result = decoder.decode_from_defects(detection_indices) + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.cost else: - result = decoder.decode(events_flat.tolist()) - predicted_obs = result.correction[0] if len(result.correction) > 0 else 0 + result = decoder.decode_syndrome(events_flat.tolist()) + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.weight corrected_parity = (final_parity + predicted_obs) % 2 @@ -3208,12 +3389,12 @@ def decode_memory_x( if self.decoder_type == DecoderType.TESSERACT: detection_indices = [i for i, v in enumerate(events_flat) if v != 0] - result = decoder.decode(detection_indices) - predicted_obs = result.observables_mask & 1 + result = decoder.decode_from_defects(detection_indices) + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.cost else: - result = decoder.decode(events_flat.tolist()) - predicted_obs = result.correction[0] if len(result.correction) > 0 else 0 + result = decoder.decode_syndrome(events_flat.tolist()) + predicted_obs = result.observable_flips[0] if len(result.observable_flips) > 0 else 0 weight = result.weight corrected_parity = (final_parity + predicted_obs) % 2 @@ -3358,16 +3539,16 @@ class SimulationResult: def _memory_noise_model( physical_error_rate: float | None, - noise_model: NoiseModel | None, -) -> NoiseModel: - """Resolve the surface-memory noise inputs into an explicit NoiseModel.""" + noise_model: NoiseParameters | None, +) -> NoiseParameters: + """Resolve the surface-memory noise inputs into explicit noise parameters.""" if noise_model is not None: if physical_error_rate is not None: msg = "pass either physical_error_rate or noise_model, not both" raise ValueError(msg) return noise_model p = 0.001 if physical_error_rate is None else physical_error_rate - return NoiseModel.uniform(p) + return NoiseParameters.uniform(p) def _recommended_graphlike_decomposition_for_decoder(decoder_type: str) -> NativeDemDecomposition: @@ -3381,7 +3562,7 @@ def surface_code_memory( *, distance: int = 3, physical_error_rate: float | None = None, - noise_model: NoiseModel | None = None, + noise_model: NoiseParameters | None = None, shots: int = 1000, rounds: int | None = None, basis: str = "Z", @@ -3483,7 +3664,7 @@ def surface_code_memory( max_hosted_tick_separation=max_hosted_tick_separation, ) batch = ParsedDem.from_string(dem).to_dem_sampler().sample_batch(shots, seed) - num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) + num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_flips(shot).mask != 0) num_logical_errors = batch.decode_count(dem, decoder_type) if decode else num_raw_errors return SimulationResult( @@ -3509,7 +3690,7 @@ def run_noisy_memory_experiment( num_rounds: int, num_shots: int, basis: str, - noise: NoiseModel, + noise: NoiseParameters, *, decode: bool = True, decoder_type: str = "pymatching", @@ -3542,8 +3723,8 @@ def run_noisy_memory_experiment( SimulationResult with error rate statistics Example: - >>> from pecos.qec.surface import run_noisy_memory_experiment, NoiseModel - >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + >>> from pecos.qec.surface import run_noisy_memory_experiment, NoiseParameters + >>> noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) >>> result = run_noisy_memory_experiment( ... distance=3, ... num_rounds=3, @@ -3772,7 +3953,7 @@ def sample( def build_native_sampler( patch: SurfacePatch, num_rounds: int, - noise: NoiseModel, + noise: NoiseParameters, basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", @@ -3846,9 +4027,9 @@ def build_native_sampler( NativeSampler that can generate samples for threshold estimation Example: - >>> from pecos.qec.surface import SurfacePatch, NoiseModel, build_native_sampler + >>> from pecos.qec.surface import SurfacePatch, NoiseParameters, build_native_sampler >>> patch = SurfacePatch.create(distance=5) - >>> noise = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001) + >>> noise = NoiseParameters(p1=0.001, p2=0.001, p_meas=0.001) >>> sampler = build_native_sampler(patch, num_rounds=5, noise=noise) >>> detection_events, observable_flips = sampler.sample(num_shots=10000) """ @@ -3925,18 +4106,18 @@ def build_native_sampler( p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + p_idle_linear_rate=noise._p_idle_linear_rate, + p_idle_quadratic_rate=noise._p_idle_quadratic_rate, + p_idle_x_linear_rate=noise._p_idle_x_linear_rate, + p_idle_y_linear_rate=noise._p_idle_y_linear_rate, + p_idle_z_linear_rate=noise._p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise._p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise._p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise._p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise._p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise._p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise._p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise._p_idle_z_quadratic_sine_rate, twirl=twirl, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, @@ -4087,8 +4268,7 @@ def demask_pauli_frame_records( raise ValueError(msg) if obs_arr.ndim != 2: msg = ( - f"raw_obs must be 2-D of shape (num_shots, num_observables); " - f"got ndim={obs_arr.ndim}, shape={obs_arr.shape}" + f"raw_obs must be 2-D of shape (num_shots, num_observables); got ndim={obs_arr.ndim}, shape={obs_arr.shape}" ) raise ValueError(msg) if masks_arr.ndim != 2: diff --git a/python/quantum-pecos/src/pecos/tracing.py b/python/quantum-pecos/src/pecos/tracing.py index b850f9c54..f7004f949 100644 --- a/python/quantum-pecos/src/pecos/tracing.py +++ b/python/quantum-pecos/src/pecos/tracing.py @@ -13,6 +13,8 @@ import json from collections import Counter +from contextlib import contextmanager +from contextvars import ContextVar from typing import TYPE_CHECKING, Any from pecos._qis_trace_replay import ( @@ -24,9 +26,43 @@ from pecos._traced_circuit import measurement_ids_in_execution_order if TYPE_CHECKING: + from collections.abc import Iterator + from pecos.quantum import TickCircuit +_RESULT_TRACE_COLLECTOR: ContextVar[list[dict[str, Any]] | None] = ContextVar( + "pecos_result_trace_collector", + default=None, +) + + +def _capture_qis_operation_traces( + program: object, + num_qubits: int, + *, + shots: int, + seed: int = 0, + runtime: object | None = None, +) -> list[dict[str, Any]]: + """Capture one or more QIS trace shots for internal certification.""" + if shots <= 0: + msg = "trace shots must be greater than zero" + raise ValueError(msg) + import pecos_rslib # noqa: PLC0415 + + import pecos # noqa: PLC0415 + + sim_builder = ( + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos_rslib.coin_toss()) + .qubits(num_qubits) + .seed(seed) + ) + return list(sim_builder.capture_operation_trace(shots)) + + def capture_qis_operation_trace( program: object, num_qubits: int, @@ -53,20 +89,13 @@ def capture_qis_operation_trace( Returns: The structured operation-trace chunks for one completed shot. """ - import pecos_rslib # noqa: PLC0415 - - import pecos # noqa: PLC0415 - - # Trace capture records runtime-lowered operations and provenance. Use a - # permissive backend because no quantum-state evolution is needed here. - sim_builder = ( - pecos.sim(program) - .classical(pecos.selene_engine(runtime)) - .quantum(pecos_rslib.coin_toss()) - .qubits(num_qubits) - .seed(seed) + return _capture_qis_operation_traces( + program, + num_qubits, + shots=1, + seed=seed, + runtime=runtime, ) - return list(sim_builder.capture_operation_trace()) def _qis_operation_trace_to_tick_circuit( @@ -182,6 +211,17 @@ def _trace_program_to_tick_circuit_with_result_traces( return tick_circuit, named_result_traces_from_operation_trace(chunks) +@contextmanager +def _collect_program_result_traces() -> Iterator[list[dict[str, Any]]]: + """Collect result provenance when tracing through the stable public helper.""" + result_traces: list[dict[str, Any]] = [] + token = _RESULT_TRACE_COLLECTOR.set(result_traces) + try: + yield result_traces + finally: + _RESULT_TRACE_COLLECTOR.reset(token) + + def trace_program_to_tick_circuit( program: object, num_qubits: int, @@ -223,14 +263,20 @@ def trace_program_to_tick_circuit( not use a trace from measurement-dependent branches or loops as though it represented all possible executions. """ - trace = capture_qis_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _qis_operation_trace_to_tick_circuit( - trace, + tick_circuit, result_traces = _trace_program_to_tick_circuit_with_result_traces( + program, + num_qubits, + seed=seed, + runtime=runtime, measurement_crosstalk_topology=measurement_crosstalk_topology, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, - context="trace_program_to_tick_circuit", + allow_raw_measurement_id_fallback=False, ) + collector = _RESULT_TRACE_COLLECTOR.get() + if collector is not None: + collector.extend(result_traces) + return tick_circuit # Compatibility aliases for the original surface-code-internal names. These diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs index b143bfa86..b4dece83b 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs @@ -152,10 +152,10 @@ let _depol = DepolarizingNoiseModel::builder() // Custom depolarizing per operation type let _custom = DepolarizingNoiseModel::builder() - .with_prep_probability(0.001) // State preparation error - .with_meas_probability(0.002) // Measurement error - .with_p1_probability(0.003) // Single-qubit gate error - .with_p2_probability(0.004); // Two-qubit gate error + .with_p_prep(0.001) // State preparation error + .with_p_meas(0.002) // Measurement error + .with_p1(0.003) // Single-qubit gate error + .with_p2(0.004); // Two-qubit gate error // Biased depolarizing (asymmetric error distribution) let _biased = BiasedDepolarizingNoiseModel::builder() @@ -170,6 +170,7 @@ fn test_user_guide_qasm_simulation_rust_5() -> Result<(), Box Result<(), Box → |1> - .with_meas_1_probability(0.01) // Measurement error |1> → |0> - .with_p1_probability(0.0001) // Single-qubit gate error - .with_p2_probability(0.01) // Two-qubit gate error - .with_p_idle_linear_rate(0.0001) // Idle noise rate + .with_p_prep(0.001) // State prep error + .with_p_meas_0(0.005) // Measurement error |0> → |1> + .with_p_meas_1(0.01) // Measurement error |1> → |0> + .with_p1(0.0001) // Single-qubit gate error + .with_p2(0.01) // Two-qubit gate error + .with_p_idle_linear(0.0001, &idle_model) // Idle noise rate + .with_idle_after_2q(1.0) // Idle duration after two-qubit gates .with_seed(42); // Deterministic noise // Use with sim() @@ -363,11 +370,11 @@ fn ghz_noise_example() -> Result<(), PecosError> { // Create advanced noise model with builder let noise = GeneralNoiseModelBuilder::new() - .with_prep_probability(0.001) // 0.1% state prep error - .with_p1_probability(0.0001) // 0.01% single-qubit gate error - .with_p2_probability(0.01) // 1% two-qubit gate error - .with_meas_0_probability(0.02) // 2% false positive rate - .with_meas_1_probability(0.03) // 3% false negative rate + .with_p_prep(0.001) // 0.1% state prep error + .with_p1(0.0001) // 0.01% single-qubit gate error + .with_p2(0.01) // 1% two-qubit gate error + .with_p_meas_0(0.02) // 2% false positive rate + .with_p_meas_1(0.03) // 3% false negative rate .with_seed(12345); // Deterministic noise // Run simulation diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index 7ff726727..b2cbbb992 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -152,13 +152,13 @@ def prep_measure_circuit() -> bool: # Custom noise: high prep error, low measurement error noise = ( general_noise() - .with_preparation_probability(0.2) # 20% preparation error + .with_p_prep(0.2) # 20% preparation error .with_measurement_probability( 0.01, 0.01, ) - .with_p1_probability(0.05) # 5% single-qubit gate error - .with_p2_probability(0.1) # 10% two-qubit gate error + .with_p1(0.05) # 5% single-qubit gate error + .with_p2(0.1) # 10% two-qubit gate error ) results = sim(prep_measure_circuit).qubits(1).quantum(state_vector()).seed(456).noise(noise).run(100).to_dict() diff --git a/python/quantum-pecos/tests/guppy/test_noise_models.py b/python/quantum-pecos/tests/guppy/test_noise_models.py index b79d40cf7..5bca21ed8 100644 --- a/python/quantum-pecos/tests/guppy/test_noise_models.py +++ b/python/quantum-pecos/tests/guppy/test_noise_models.py @@ -48,10 +48,10 @@ def simple_circuit() -> bool: # Create depolarizing noise - must chain all probability setters noise = ( depolarizing_noise() - .with_prep_probability(0.0) # No prep errors - .with_p1_probability(0.2) # 20% chance of error on single-qubit gates - .with_p2_probability(0.0) # No two-qubit gate errors - .with_meas_probability(0.0) + .with_p_prep(0.0) # No prep errors + .with_p1(0.2) # 20% chance of error on single-qubit gates + .with_p2(0.0) # No two-qubit gate errors + .with_p_meas(0.0) ) # No measurement errors # High depolarizing probability to see effect @@ -77,11 +77,11 @@ def simple_circuit() -> bool: # Use biased depolarizing - must chain all probability setters noise = ( biased_depolarizing_noise() - .with_prep_probability(0.05) # State prep errors - .with_p1_probability(0.1) # Single-qubit gate errors - .with_p2_probability(0.0) # No two-qubit gate errors - .with_meas_0_probability(0.05) # Measurement errors for |0⟩ - .with_meas_1_probability(0.05) + .with_p_prep(0.05) # State prep errors + .with_p1(0.1) # Single-qubit gate errors + .with_p2(0.0) # No two-qubit gate errors + .with_p_meas_0(0.05) # Measurement errors for |0⟩ + .with_p_meas_1(0.05) ) # Measurement errors for |1⟩ results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() @@ -104,7 +104,7 @@ def simple_circuit() -> bool: # Use general noise model with multiple error types noise_builder = ( - general_noise().with_p1_probability(0.01).with_prep_probability(0.01) # Single-qubit gate errors + general_noise().with_p1(0.01).with_p_prep(0.01) # Single-qubit gate errors ) # Preparation errors results = ( @@ -137,10 +137,10 @@ def bell_circuit() -> tuple[bool, bool]: # Run with depolarizing noise - chain all probability setters noise = ( depolarizing_noise() - .with_prep_probability(0.0) # No prep errors - .with_p1_probability(0.05) # 5% error on single-qubit gates - .with_p2_probability(0.05) # 5% error on two-qubit gates - .with_meas_probability(0.0) + .with_p_prep(0.0) # No prep errors + .with_p1(0.05) # 5% error on single-qubit gates + .with_p2(0.05) # 5% error on two-qubit gates + .with_p_meas(0.0) ) # No measurement errors results_noisy = sim(bell_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() @@ -171,26 +171,12 @@ def simple_x_circuit() -> bool: return measure(q) # Test that builder pattern works - chain all probability setters - noise1 = ( - depolarizing_noise() - .with_prep_probability(0.0) - .with_p1_probability(0.1) - .with_p2_probability(0.0) - .with_meas_probability(0.0) - .with_seed(1) - ) + noise1 = depolarizing_noise().with_p_prep(0.0).with_p1(0.1).with_p2(0.0).with_p_meas(0.0).with_seed(1) results1 = sim(simple_x_circuit).qubits(10).quantum(state_vector()).noise(noise1).seed(42).run(10).to_dict() # Different seed should give different results - noise2 = ( - depolarizing_noise() - .with_prep_probability(0.0) - .with_p1_probability(0.1) - .with_p2_probability(0.0) - .with_meas_probability(0.0) - .with_seed(2) - ) + noise2 = depolarizing_noise().with_p_prep(0.0).with_p1(0.1).with_p2(0.0).with_p_meas(0.0).with_seed(2) results2 = sim(simple_x_circuit).qubits(10).quantum(state_vector()).noise(noise2).seed(43).run(10).to_dict() @@ -205,6 +191,15 @@ def simple_x_circuit() -> bool: assert len(measurements2) == 10 +def test_general_noise_idle_after_2q_api() -> None: + """The after-2q idle API accepts a duration and has no probability alias.""" + builder = general_noise() + + assert callable(builder.with_idle_after_2q) + assert builder.with_p_idle_linear(0.01, {"X": 1 / 3, "Y": 1 / 3, "Z": 1 / 3}).with_idle_after_2q(1.0) is not None + assert not hasattr(builder, "with_p2_idle") + + def test_noise_on_single_qubit_gates() -> None: """Test noise specifically on single-qubit gates.""" @@ -216,7 +211,7 @@ def multi_gate_circuit() -> bool: return measure(q) # Configure noise only for single-qubit gates - noise = general_noise().with_p1_probability(0.3) # High error rate to see effect + noise = general_noise().with_p1(0.3) # High error rate to see effect results = sim(multi_gate_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() @@ -239,7 +234,7 @@ def simple_circuit() -> bool: return measure(q) # Configure noise only for measurements - noise = general_noise().with_meas_probability(0.2) # High measurement error + noise = general_noise().with_p_meas(0.2) # High measurement error results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() diff --git a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py index 490e373fd..979165f9e 100644 --- a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py +++ b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py @@ -29,29 +29,29 @@ class TestMwpmResult: def test_result_attributes(self) -> None: """Test that MwpmResult has the expected attributes.""" - from pecos_rslib.decoders import CheckMatrix, PyMatchingDecoder + from pecos_rslib.decoders import CheckMatrix, ObservableFlips, PyMatchingDecoder matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) # Check attributes exist - assert hasattr(result, "correction") + assert hasattr(result, "observable_flips") assert hasattr(result, "weight") # Check types - assert isinstance(result.correction, list) + assert isinstance(result.observable_flips, ObservableFlips) assert isinstance(result.weight, float) - def test_result_to_list(self) -> None: - """Test MwpmResult.to_list() method.""" + def test_observable_flips_materializes_to_list(self) -> None: + """Test materializing MwpmResult.observable_flips as a list.""" from pecos_rslib.decoders import CheckMatrix, PyMatchingDecoder matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) - assert result.to_list() == result.correction + assert list(result.observable_flips) == [bool(value) for value in result] def test_result_indexing(self) -> None: """Test MwpmResult supports indexing like a list.""" @@ -59,11 +59,11 @@ def test_result_indexing(self) -> None: matrix = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(matrix) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) - assert len(result) == len(result.correction) + assert len(result) == len(result.observable_flips) if len(result) > 0: - assert result[0] == result.correction[0] + assert bool(result[0]) == result.observable_flips[0] class TestCheckMatrix: @@ -127,7 +127,7 @@ def test_decode_trivial(self) -> None: H = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(H) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) # No errors - should have zero weight assert result.weight == 0.0 @@ -140,7 +140,7 @@ def test_decode_single_error(self) -> None: H = CheckMatrix.from_dense([[1, 1, 0], [0, 1, 1]]) decoder = PyMatchingDecoder.from_check_matrix(H) - result = decoder.decode([1, 1]) + result = decoder.decode_syndrome([1, 1]) assert result is not None assert result.weight > 0 @@ -164,8 +164,8 @@ def test_from_dem_with_correlations(self) -> None: dem = "error(0.1) D0 D1 ^ D2 L0" decoder = PyMatchingDecoder.from_dem_with_correlations(dem) - result = decoder.decode([0, 0, 0]) - assert result.correction == [0] + result = decoder.decode_syndrome([0, 0, 0]) + assert list(result.observable_flips) == [False] class TestFusionBlossomDecoder: @@ -192,7 +192,7 @@ def test_decode_trivial(self) -> None: from pecos_rslib.decoders import FusionBlossomDecoder decoder = FusionBlossomDecoder.from_check_matrix([[1, 1, 0], [0, 1, 1]]) - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) assert result.weight == 0.0 @@ -216,7 +216,7 @@ def test_clear_for_reuse(self) -> None: # Decode multiple syndromes with clear for _ in range(3): - result = decoder.decode([0, 0]) + result = decoder.decode_syndrome([0, 0]) assert result is not None decoder.clear() @@ -230,7 +230,7 @@ def test_result_attributes(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder = BpOsdBuilder(H, error_rate=0.01).build() - result = decoder.decode([0, 0, 0]) + result = decoder.decode_syndrome([0, 0, 0]) assert hasattr(result, "decoding") assert hasattr(result, "converged") @@ -289,7 +289,7 @@ def test_decode_trivial(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder = BpOsdBuilder(H, error_rate=0.01).build() - result = decoder.decode([0, 0, 0]) + result = decoder.decode_syndrome([0, 0, 0]) assert result.converged def test_bp_methods(self) -> None: @@ -300,12 +300,12 @@ def test_bp_methods(self) -> None: # product_sum decoder1 = BpOsdBuilder(H, error_rate=0.01).bp_method("product_sum").build() - result1 = decoder1.decode([0, 0, 0]) + result1 = decoder1.decode_syndrome([0, 0, 0]) assert result1 is not None # minimum_sum decoder2 = BpOsdBuilder(H, error_rate=0.01).bp_method("minimum_sum").build() - result2 = decoder2.decode([0, 0, 0]) + result2 = decoder2.decode_syndrome([0, 0, 0]) assert result2 is not None @@ -351,7 +351,7 @@ def test_decode_trivial(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder = UnionFindBuilder(H).build() - result = decoder.decode([0, 0, 0]) + result = decoder.decode_syndrome([0, 0, 0]) assert result is not None def test_methods(self) -> None: @@ -361,11 +361,11 @@ def test_methods(self) -> None: H = SparseMatrix([[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) decoder_inv = UnionFindBuilder(H).method("inversion").build() - result_inv = decoder_inv.decode([0, 0, 0]) + result_inv = decoder_inv.decode_syndrome([0, 0, 0]) assert result_inv is not None decoder_peel = UnionFindBuilder(H).method("peeling").build() - result_peel = decoder_peel.decode([0, 0, 0]) + result_peel = decoder_peel.decode_syndrome([0, 0, 0]) assert result_peel is not None diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py index 5456ba489..1192b903a 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_comprehensive.py @@ -43,8 +43,10 @@ def test_general_noise(self) -> None: measure q -> c; """ - # GeneralNoise uses default configuration - results = qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(general_noise()).run(1000) + # Preserve the historical demonstration preset for this broad integration smoke test. + results = ( + qasm_engine().program(Qasm.from_string(qasm)).to_sim().seed(42).noise(general_noise().auto()).run(1000) + ) results_dict = results.to_dict() assert isinstance(results_dict, dict) @@ -258,11 +260,7 @@ def test_all_noise_models_builder(self) -> None: GeneralNoiseModelBuilder(), depolarizing_noise().with_uniform_probability(0.1), biased_depolarizing_noise().with_uniform_probability(0.033), - depolarizing_noise() - .with_prep_probability(0.1) - .with_meas_probability(0.1) - .with_p1_probability(0.1) - .with_p2_probability(0.1), + depolarizing_noise().with_p_prep(0.1).with_p_meas(0.1).with_p1(0.1).with_p2(0.1), ] for noise_builder in noise_builders: diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py index 911d15109..bf2a3dd90 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_config.py @@ -143,11 +143,7 @@ def test_custom_noise_config(self) -> None: .to_sim() .seed(42) .noise( - depolarizing_noise() - .with_prep_probability(0.001) - .with_meas_probability(0.002) - .with_p1_probability(0.003) - .with_p2_probability(0.004), + depolarizing_noise().with_p_prep(0.001).with_p_meas(0.002).with_p1(0.003).with_p2(0.004), ) .build() ) @@ -221,7 +217,7 @@ def test_structured_config(self) -> None: """ # Create noise using functional API - pass it directly to noise() method - noise_builder = general_noise().with_seed(42).with_p1_probability(0.001).with_p2_probability(0.01) + noise_builder = general_noise().with_seed(42).with_p1(0.001).with_p2(0.01) # Use builder pattern instead of config dict sim = ( @@ -263,11 +259,11 @@ def test_general_noise_config(self) -> None: noise_builder = ( general_noise() .with_seed(42) - .with_p1_probability(0.001) - .with_p2_probability(0.01) - .with_prep_probability(0.001) - .with_meas_0_probability(0.002) - .with_meas_1_probability(0.002) + .with_p1(0.001) + .with_p2(0.01) + .with_p_prep(0.001) + .with_p_meas_0(0.002) + .with_p_meas_1(0.002) # TODO: Add these methods to Python bindings: # .with_noiseless_gates(["H"]) # .with_p1_pauli_model(x=0.5, y=0.3, z=0.2) diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py index 68f66d0f3..983d433ef 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_custom_noise.py @@ -13,17 +13,11 @@ def test_built_in_noise_builders(self) -> None: ) # Test depolarizing noise builder - dep = depolarizing_noise().with_p1_probability(0.05) + dep = depolarizing_noise().with_p1(0.05) assert dep is not None # Test depolarizing noise with multiple parameters - dep_custom = ( - depolarizing_noise() - .with_prep_probability(0.002) - .with_meas_probability(0.001) - .with_p1_probability(0.003) - .with_p2_probability(0.002) - ) + dep_custom = depolarizing_noise().with_p_prep(0.002).with_p_meas(0.001).with_p1(0.003).with_p2(0.002) assert dep_custom is not None # Test BiasedDepolarizingNoise @@ -106,11 +100,7 @@ def test_noise_builder_validation(self) -> None: .program(Qasm.from_string(qasm_valid)) .to_sim() .noise( - depolarizing_noise() - .with_prep_probability(0.1) - .with_meas_probability(0.2) - .with_p1_probability(0.3) - .with_p2_probability(0.4), + depolarizing_noise().with_p_prep(0.1).with_p_meas(0.2).with_p1(0.3).with_p2(0.4), ) .build() ) diff --git a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py index d4c10d65c..69a74b556 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py +++ b/python/quantum-pecos/tests/pecos/integration/test_qasm_sim_defaults.py @@ -68,14 +68,14 @@ def test_noise_model_defaults(self) -> None: # Test default values for noise models using builder pattern # Note: depolarizing_noise() builder requires explicit probability - depolarizing_noise().with_p1_probability(0.001) + depolarizing_noise().with_p1(0.001) # Can't directly assert on builder properties # General noise model has defaults that can be overridden GeneralNoiseModelBuilder() # Default values are set when building - (biased_depolarizing_noise().with_p1_probability(0.001).with_p2_probability(0.001).with_prep_probability(0.001)) + (biased_depolarizing_noise().with_p1(0.001).with_p2(0.001).with_p_prep(0.001)) # Builder pattern requires explicit values def test_builder_defaults_new_api(self) -> None: @@ -141,9 +141,9 @@ def test_default_summary(self) -> None: # - bit_format: BigInt (integers, not binary strings) # # Noise model builders: - # - depolarizing_noise(): requires explicit .with_p1_probability() + # - depolarizing_noise(): requires explicit .with_p1() # - biased_depolarizing_noise(): requires probability settings - # - GeneralNoiseModelBuilder(): has internal defaults + # - GeneralNoiseModelBuilder(): no-effect defaults; .auto() opts into the legacy preset # # New unified API defaults: # - All optional fields use builder defaults when not specified diff --git a/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py new file mode 100644 index 000000000..5e8bea105 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_noise_builder_setter_names.py @@ -0,0 +1,309 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The simulator noise builders name each setter after the field it sets. + +The suffixed spellings (``with_p1_probability``, ``with_meas_probability``, ...) were +replaced outright rather than aliased, so this pins both halves: the field-name setters +work through the pyo3 surface, and the old spellings are gone. +""" + +import math + +import pytest +from guppylang import guppy +from guppylang.std.quantum import measure, qubit, x +from pecos import Qasm, qasm_engine, sim +from pecos_rslib import ( + biased_depolarizing_noise, + depolarizing_noise, + general_noise, + state_vector, +) + +# (builder factory, setters that builder is expected to expose) +BUILDER_SETTERS = [ + (general_noise, ("with_p1", "with_p2", "with_p_prep", "with_p_meas", "with_p_meas_0", "with_p_meas_1")), + (depolarizing_noise, ("with_p1", "with_p2", "with_p_prep", "with_p_meas")), + (biased_depolarizing_noise, ("with_p1", "with_p2", "with_p_prep", "with_p_meas_0", "with_p_meas_1")), +] + +REMOVED_SETTERS = ( + "with_single_qubit_probability", + "with_two_qubit_probability", + "with_preparation_probability", + "with_p1_probability", + "with_p2_probability", + "with_prep_probability", + "with_meas_probability", + "with_meas_0_probability", + "with_meas_1_probability", + "with_average_p1_probability", + "with_average_p2_probability", +) + +RETIRED_IDLE_SETTERS = ( + "with_p_idle_linear_rate", + "with_p_idle_linear_model", + "with_p_idle_quadratic_rate", + "with_p_idle_quadratic_coherent", + "with_p_idle_coherent_to_incoherent_factor", + "with_average_p_idle_linear_rate", + "with_average_p_idle_quadratic_rate", +) + +SYMMETRIC_LINEAR_MODEL = {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0} + +_AFTER_2Q_QASM = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +cx q[0], q[1]; +measure q -> c; +""" + + +def _run_after_2q_noise(noise, shots: int = 1, seed: int = 424) -> list[int]: + results = qasm_engine().program(Qasm.from_string(_AFTER_2Q_QASM)).to_sim().noise(noise).seed(seed).run(shots) + return results.to_dict()["c"] + + +def _idle_family_noise(*, sine_rate: float, sine_model: dict[str, float], seed: int = 424): + return ( + general_noise() + .with_seed(seed) + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, {"Z": 1.0}) + .with_p_idle_sin_squared(sine_rate, sine_model) + .with_idle_after_2q(1.0) + ) + + +def _coherent_idle_noise(*, rate: float, model: dict[str, float] | None = None): + noise = ( + general_noise() + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, {"Z": 1.0}) + .with_idle_after_2q(1.0) + ) + if model is None: + return noise.with_p_idle_coherent(rate) + return noise.with_p_idle_coherent(rate, model) + + +@pytest.mark.parametrize(("factory", "setters"), BUILDER_SETTERS) +def test_field_name_setters_are_chainable(factory, setters) -> None: + """Every field-name setter exists and returns a builder that keeps chaining.""" + builder = factory() + for setter in setters: + builder = getattr(builder, setter)(0.01) + assert builder is not None + + +@pytest.mark.parametrize(("factory", "_setters"), BUILDER_SETTERS) +def test_suffixed_setters_are_gone(factory, _setters) -> None: + """The replaced spellings must not linger as aliases.""" + builder = factory() + for removed in REMOVED_SETTERS: + assert not hasattr(builder, removed), f"{removed} should have been renamed away" + + +def test_asymmetric_measurement_probability_sets_both_rates() -> None: + """The surviving two-argument helper preserves distinct 0→1 and 1→0 rates.""" + qasm = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +x q[1]; +measure q -> c; +""" + program = Qasm.from_string(qasm) + + zero_only = sim(program).noise(general_noise().with_measurement_probability(1.0, 0.0)).run(8).to_dict() + one_only = sim(program).noise(general_noise().with_measurement_probability(0.0, 1.0)).run(8).to_dict() + + assert zero_only["c"] == [3] * 8 + assert one_only["c"] == [0] * 8 + + +def test_retired_idle_setters_are_gone() -> None: + """The unpaired and unit-converting idle spellings are absent from pyo3.""" + builder = general_noise() + for removed in RETIRED_IDLE_SETTERS: + assert not hasattr(builder, removed), f"{removed} should have been retired" + + +def test_average_setters_keep_their_conversion() -> None: + """``with_average_p*`` survives the rename; it converts from average gate error.""" + builder = general_noise() + assert callable(builder.with_average_p1) + assert callable(builder.with_average_p2) + assert builder.with_average_p1(0.01).with_average_p2(0.02) is not None + + +def test_auto_is_chainable_and_explicit_zeros_win_in_both_orders() -> None: + """The pyo3 preset preserves explicit zero rates before and after ``auto``.""" + + @guppy + def deterministic_x() -> bool: + q = qubit() + x(q) + return measure(q) + + auto_then_zeros = ( + general_noise() + .auto() + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, SYMMETRIC_LINEAR_MODEL) + ) + zeros_then_auto = ( + general_noise() + .with_p_prep(0.0) + .with_p1(0.0) + .with_p2(0.0) + .with_p_meas(0.0) + .with_p_idle_linear(0.0, SYMMETRIC_LINEAR_MODEL) + .auto() + ) + + for noise in (auto_then_zeros, zeros_then_auto): + results = sim(deterministic_x).qubits(1).quantum(state_vector()).noise(noise).seed(42).run(20).to_dict() + raw = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw] + assert measurements == [1] * 20 + + +def test_auto_matches_explicit_legacy_preset_at_python_surface() -> None: + """The pyo3 ``auto`` method delegates to the complete Rust legacy preset.""" + + @guppy + def deterministic_x() -> bool: + q = qubit() + x(q) + return measure(q) + + explicit = ( + general_noise() + .with_p_prep(0.01) + .with_p_meas_0(0.01) + .with_p_meas_1(0.01) + .with_p1(0.001) + .with_p2(0.01) + .with_p_idle_linear(0.001, SYMMETRIC_LINEAR_MODEL) + .with_p1_emission_ratio(0.5) + .with_p2_emission_ratio(0.5) + .with_prep_leak_ratio(0.5) + .with_p1_seepage_prob(0.5) + .with_p2_seepage_prob(0.5) + ) + + def run(noise) -> list[bool]: + results = sim(deterministic_x).qubits(1).quantum(state_vector()).noise(noise).seed(424).run(512).to_dict() + raw = results["measurements"] + return [m[-1] if isinstance(m, list) else m for m in raw] + + auto_results = run(general_noise().auto()) + explicit_results = run(explicit) + + assert auto_results == explicit_results + assert auto_results != [1] * 512 + + +def test_idle_family_setters_are_chainable() -> None: + """All structured idle families are fluent.""" + builder = general_noise().with_p_idle_linear(0.01, {"X": 0.5, "L": 0.5}) + builder = builder.with_p_idle_sin_squared(0.02, {"X": 1.0, "Z": 2.0, "L": 0.25}) + builder = builder.with_p_idle_coherent(0.03, {"RX": 1.0, "RZ": 2.0}) + assert builder is not None + + +def test_retired_coherent_bool_switch_is_not_an_alias() -> None: + """The old one-bool call cannot silently become a zero/one coherent-family rate.""" + with pytest.raises(TypeError, match=r"coherent idling rate.*not bool"): + general_noise().with_p_idle_coherent(False) + + +def test_coherent_idle_default_model_is_available() -> None: + """Omitting the pyo3 model selects the documented symmetric RX/RY/RZ multipliers.""" + implicit = _coherent_idle_noise(rate=math.pi) + explicit = _coherent_idle_noise(rate=math.pi, model={"RX": 1.0, "RY": 1.0, "RZ": 1.0}) + assert _run_after_2q_noise(implicit, 64, seed=424) == _run_after_2q_noise(explicit, 64, seed=424) + + +def test_coherent_idle_rx_reaches_runtime_deterministically() -> None: + """A pi RX idle rotation after CX flips both measured qubits for every seed.""" + noise = _coherent_idle_noise(rate=math.pi, model={"RX": 1.0}) + assert _run_after_2q_noise(noise, 10, seed=1) == [3] * 10 + assert _run_after_2q_noise(noise, 10, seed=999) == [3] * 10 + + +def test_linear_idle_family_rejects_unnormalized_model() -> None: + """The linear family reuses the normalized weighted-sampler contract.""" + with pytest.raises(BaseException, match=r"total weight 2.*deviates from 1.0"): + general_noise().with_p_idle_linear(0.01, {"X": 1.0, "Z": 1.0}) + + +def test_sine_idle_family_x_axis_reaches_runtime() -> None: + """A certain X sine event after CX flips both measured qubits; a Z-only path would not.""" + noise = _idle_family_noise(sine_rate=math.pi / 2, sine_model={"X": 1.0}) + assert _run_after_2q_noise(noise, 10) == [3] * 10 + + +def test_sine_idle_multipliers_are_not_normalized() -> None: + """X=Z=1 keeps a certain X event at rate pi/2 instead of reducing it to probability 1/2.""" + noise = _idle_family_noise(sine_rate=math.pi / 2, sine_model={"X": 1.0, "Z": 1.0}) + assert _run_after_2q_noise(noise, 32) == [3] * 32 + + +@pytest.mark.parametrize("model", [{"L": 1.0}, {"A": 1.0}]) +def test_coherent_idle_family_rejects_non_rotation_keys(model: dict[str, float]) -> None: + """Leakage and unknown generators are rejected instead of being treated as rotations.""" + with pytest.raises(BaseException, match=r"invalid key.*expected RX, RY, or RZ"): + general_noise().with_p_idle_coherent(0.02, model) + + +def test_sine_idle_family_is_deterministic_for_same_seed() -> None: + """The pyo3 surface preserves the Rust model's fixed-seed draw sequence.""" + first = _run_after_2q_noise(_idle_family_noise(sine_rate=0.6, sine_model={"X": 1.0}), 128) + second = _run_after_2q_noise(_idle_family_noise(sine_rate=0.6, sine_model={"X": 1.0}), 128) + assert first == second + assert 0 in first + assert 3 in first + + +def test_with_p_meas_actually_configures_measurement_noise() -> None: + """A renamed setter still reaches the model: certain measurement flips flip every shot.""" + + @guppy + def prepare_and_measure() -> bool: + q = qubit() + return measure(q) + + noise = general_noise().with_p_prep(0.0).with_p1(0.0).with_p2(0.0).with_p_meas(1.0) + results = sim(prepare_and_measure).qubits(1).quantum(state_vector()).noise(noise).seed(42).run(20).to_dict() + + raw = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw] + assert all(m == 1 for m in measurements), "p_meas=1.0 should flip every |0> measurement" diff --git a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py index d8b7bd5ce..5d2510747 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py +++ b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py @@ -270,6 +270,105 @@ def test_selene_engine_uses_plugin_when_cargo_target_is_empty(tmp_path: Path) -> _run_selene_cwd_probe(tmp_path, empty_cargo_target) +def test_qis_trace_capture_supports_leakage_measurement_futures(tmp_path: Path) -> None: + """The Helios leakage symbols must resolve instead of calling address zero.""" + probe = tmp_path / "measure_leaked_trace_probe.py" + probe.write_text( + textwrap.dedent( + """ + import pecos + from guppylang import guppy + from guppylang.std.builtins import result + from guppylang.std.qsystem import measure_leaked + from guppylang.std.quantum import qubit + + @guppy + def measure_leakage() -> None: + measured = measure_leaked(qubit()) + result("not leaked", not measured.is_leaked()) + measured.discard() + result("constant detector", 0) + + trace = pecos.capture_qis_operation_trace(measure_leakage, 1, seed=17) + assert trace + assert any( + item.get("name") == "not leaked" + for chunk in trace + for item in chunk.get("named_result_traces", []) + ) + assert any( + item.get("name") == "constant detector" and item.get("values") == [False] + for chunk in trace + for item in chunk.get("named_result_traces", []) + ) + """, + ), + encoding="utf-8", + ) + + completed = subprocess.run( + [sys.executable, str(probe)], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_guppy_leakage_outcome_round_trips_through_qis(tmp_path: Path) -> None: + """A PECOS leakage outcome of 2 must reach Guppy's unsigned future intact.""" + probe = tmp_path / "measure_leaked_outcome_probe.py" + probe.write_text( + textwrap.dedent( + """ + import pecos + from guppylang import guppy + from guppylang.std.builtins import result + from guppylang.std.qsystem import measure_leaked + from guppylang.std.quantum import qubit, z + + @guppy + def measure_forced_leakage() -> None: + q = qubit() + z(q) + measured = measure_leaked(q) + result("leaked", measured.is_leaked()) + measured.discard() + + noise = ( + pecos.general_noise() + .with_p1(1.0) + .with_p1_emission_ratio(1.0) + .with_p1_emission_model({"L": 1.0}) + .with_leakage_scale(1.0) + ) + results = ( + pecos.sim(measure_forced_leakage) + .classical(pecos.selene_engine()) + .qubits(1) + .quantum(pecos.state_vector()) + .noise(noise) + .seed(19) + .run(4) + .to_dict() + ) + assert all(results["leaked"]), results + """, + ), + encoding="utf-8", + ) + + completed = subprocess.run( + [sys.executable, str(probe)], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + def test_sim_guppy_reuses_physical_slot_after_measurement() -> None: """Test that a recycled physical slot is reinitialized when Guppy reallocates a qubit.""" import pecos diff --git a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py index 849bb31cc..df49f3d35 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -200,6 +200,27 @@ def test_capture_operation_trace_includes_named_result_provenance() -> None: assert len(named_trace["result_ids"]) == len(named_trace["values"]) +def test_capture_operation_trace_includes_result_id_keyed_outcomes_per_shot() -> None: + """Aggregate-output provenance can correlate values with physical IDs.""" + import pecos + import pecos_rslib + + _require_selene_runtime() + trace = ( + pecos.sim(make_tiny_x_syndrome_memory(1)) + .classical(pecos.selene_engine()) + .quantum(pecos_rslib.coin_toss()) + .qubits(2) + .seed(321) + .capture_operation_trace(3) + ) + + terminal = [chunk for chunk in trace if chunk.get("stage") == "trace_complete"] + assert len(terminal) == 3 + assert {chunk["shot_index"] for chunk in terminal} == {1, 2, 3} + assert all(set(chunk["measurement_results"]) == {"0", "1"} for chunk in terminal) + + def _collect_selene_named_results( instance: object, *, @@ -507,11 +528,7 @@ def test_tiny_syndrome_memory_p2_only_matches_between_selene_backends_statistica .quantum(pecos.stabilizer()) .qubits(2) .noise( - pecos.depolarizing_noise() - .with_p1_probability(0.0) - .with_p2_probability(p2) - .with_meas_probability(0.0) - .with_prep_probability(0.0), + pecos.depolarizing_noise().with_p1(0.0).with_p2(p2).with_p_meas(0.0).with_p_prep(0.0), ) .seed(123) .run(shots) diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py index 23954c2af..5fa5d8f8d 100644 --- a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -64,13 +64,7 @@ def test_neo_stack_measurement_noise_rate_matches_engines() -> None: shots = 4000 def rate_of_zero(stack: str) -> float: - noise = ( - depolarizing_noise() - .with_prep_probability(0.0) - .with_meas_probability(p_meas) - .with_p1_probability(0.0) - .with_p2_probability(0.0) - ) + noise = depolarizing_noise().with_p_prep(0.0).with_p_meas(p_meas).with_p1(0.0).with_p2(0.0) builder = sim(Qasm.from_string(X_MEASURE)).noise(noise).seed(42) if stack == "neo": builder = builder.stack("neo") diff --git a/python/quantum-pecos/tests/pecos/test_tracing.py b/python/quantum-pecos/tests/pecos/test_tracing.py index ca3171a54..5194e65f5 100644 --- a/python/quantum-pecos/tests/pecos/test_tracing.py +++ b/python/quantum-pecos/tests/pecos/test_tracing.py @@ -106,6 +106,18 @@ def fake_capture( assert replayed.get_meta("qis_source_measurement_ids") == "[7]" +def test_leakage_measurement_replays_as_accepted_path_mz() -> None: + trace = _completed_trace() + trace[0]["operations"][-1] = {"Quantum": {"MeasureLeaked": [0, 7]}} + trace[0]["lowered_quantum_ops"][-1]["gate_type"] = "MeasureLeaked" + + replayed = pecos.qis_operation_trace_to_tick_circuit(trace) + + assert "MZ" in _gate_names(replayed) + assert "MeasureLeaked" not in _gate_names(replayed) + assert replayed.get_meta("qis_source_measurement_ids") == "[7]" + + def test_qis_operation_trace_conversion_rejects_an_incomplete_trace() -> None: with pytest.raises(ValueError, match="terminal trace_complete"): pecos.qis_operation_trace_to_tick_circuit(_completed_trace()[:-1]) @@ -225,7 +237,8 @@ def seed(self, seed): calls.append(("seed", seed)) return self - def capture_operation_trace(self): + def capture_operation_trace(self, shots): + calls.append(("shots", shots)) return iter(trace) program = object() @@ -240,6 +253,7 @@ def capture_operation_trace(self): ("quantum", "trace-backend"), ("qubits", 3), ("seed", 11), + ("shots", 1), ] diff --git a/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_linear.dem b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_linear.dem new file mode 100644 index 000000000..ff7532bf8 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_linear.dem @@ -0,0 +1,37 @@ +detector(0, 0, 0) D0 +detector(1, 0, 0) D1 +detector(2, 0, 0) D2 +detector(3, 0, 0) D3 +detector(0, 0, 1) D4 +detector(1, 0, 1) D5 +detector(2, 0, 1) D6 +detector(3, 0, 1) D7 +detector(4, 1, 0) D8 +detector(5, 1, 0) D9 +detector(6, 1, 0) D10 +detector(7, 1, 0) D11 +detector(4, 1, 1) D12 +detector(5, 1, 1) D13 +detector(6, 1, 1) D14 +detector(7, 1, 1) D15 +detector(4, 1, 2) D16 +detector(5, 1, 2) D17 +detector(6, 1, 2) D18 +detector(7, 1, 2) D19 +logical_observable L0 +error(0.023502) D0 +error(0.014821) D0 D1 +error(0.005982) D0 D4 +error(0.037626) D1 +error(0.011892) D1 D2 +error(0.040401) D2 +error(0.017732) D2 D3 +error(0.026361) D3 +error(0.005982) D3 D7 +error(0.023502) D4 +error(0.014821) D4 D5 +error(0.032028) D5 +error(0.011892) D5 D6 +error(0.032028) D6 +error(0.014821) D6 D7 +error(0.023502) D7 diff --git a/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_sin_squared.dem b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_sin_squared.dem new file mode 100644 index 000000000..5ec1e1b8c --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/fixtures/idle_z_sin_squared.dem @@ -0,0 +1,37 @@ +detector(0, 0, 0) D0 +detector(1, 0, 0) D1 +detector(2, 0, 0) D2 +detector(3, 0, 0) D3 +detector(0, 0, 1) D4 +detector(1, 0, 1) D5 +detector(2, 0, 1) D6 +detector(3, 0, 1) D7 +detector(4, 1, 0) D8 +detector(5, 1, 0) D9 +detector(6, 1, 0) D10 +detector(7, 1, 0) D11 +detector(4, 1, 1) D12 +detector(5, 1, 1) D13 +detector(6, 1, 1) D14 +detector(7, 1, 1) D15 +detector(4, 1, 2) D16 +detector(5, 1, 2) D17 +detector(6, 1, 2) D18 +detector(7, 1, 2) D19 +logical_observable L0 +error(0.007153) D0 +error(0.004482) D0 D1 +error(0.001798) D0 D4 +error(0.011571) D1 +error(0.003589) D1 D2 +error(0.01245) D2 +error(0.005374) D2 D3 +error(0.00804) D3 +error(0.001798) D3 D7 +error(0.007153) D4 +error(0.004482) D4 D5 +error(0.009808) D5 +error(0.003589) D5 D6 +error(0.009808) D6 +error(0.004482) D6 D7 +error(0.007153) D7 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 6b4f451b1..1809c6cd3 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -316,11 +316,11 @@ def test_surface_code_memory_rejects_plan_basis_mismatch() -> None: def test_check_plan_does_not_change_current_szz_dem() -> None: - from pecos.qec.surface import NoiseModel, SurfacePatch + from pecos.qec.surface import NoiseParameters, SurfacePatch from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.001, p_meas=0.001, p_prep=0.001) + noise = NoiseParameters(p2=0.001, p_meas=0.001, p_prep=0.001) by_basis = generate_circuit_level_dem_from_builder( patch, @@ -689,13 +689,13 @@ def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: def test_native_sampler_records_resolved_check_plan() -> None: - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler patch = SurfacePatch.create(distance=3) sampler = build_native_sampler( patch, num_rounds=1, - noise=NoiseModel(p2=0.001), + noise=NoiseParameters(p2=0.001), check_plan="szz_current_v1", sampling_model="influence_dem", ) 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..4e966df65 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -3,7 +3,7 @@ import pytest from pecos.qec.surface import ( LocalCliffordFrame, - NoiseModel, + NoiseParameters, OpType, SignedPauli, SurfacePatch, @@ -206,7 +206,7 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), basis="Z", circuit_source="abstract", interaction_basis="szz", @@ -223,7 +223,7 @@ def test_checkerboard_native_abstract_dem_path_accepts_frame_policy(policy: str) dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), basis="Z", circuit_source="abstract", interaction_basis="szz", diff --git a/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py new file mode 100644 index 000000000..ad3a9916d --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_idle_noise_families.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from pecos.qec.surface import NoiseParameters, SurfacePatch, TwirlConfig +from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + +_FIXTURES = Path(__file__).with_name("fixtures") + + +def _native_surface_dem(noise: NoiseParameters) -> str: + patch = SurfacePatch.create(distance=3) + return generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(), + ) + + +def _pre_change_dem_fixture(name: str) -> str: + return (_FIXTURES / name).read_text().removesuffix("\n") + + +def test_z_linear_family_matches_removed_z_only_setter_dem_fixture() -> None: + rate = 0.003 + + actual = _native_surface_dem( + NoiseParameters().with_p_idle_linear(rate, {"Z": 1.0}), + ) + + assert actual == _pre_change_dem_fixture("idle_z_linear.dem") + + +def test_z_sin_squared_family_matches_removed_z_only_setter_dem_fixture() -> None: + rate = 0.03 + + actual = _native_surface_dem(NoiseParameters().with_p_idle_sin_squared(rate, {"Z": 1.0})) + + assert actual == _pre_change_dem_fixture("idle_z_sin_squared.dem") + + +def test_linear_family_default_is_symmetric_end_to_end() -> None: + rate = 0.003 + implicit = NoiseParameters().with_p_idle_linear(rate) + explicit = NoiseParameters().with_p_idle_linear( + rate, + {"X": 1.0 / 3.0, "Y": 1.0 / 3.0, "Z": 1.0 / 3.0}, + ) + + assert implicit.idle_memory_rates[:3] == pytest.approx((rate / 3.0,) * 3) + assert _native_surface_dem(implicit) == _native_surface_dem(explicit) + + +def test_sin_squared_family_default_is_symmetric_end_to_end() -> None: + rate = 0.03 + implicit = NoiseParameters().with_p_idle_sin_squared(rate) + explicit = NoiseParameters().with_p_idle_sin_squared(rate, {"X": 1.0, "Y": 1.0, "Z": 1.0}) + + assert implicit.idle_memory_rates[6:] == pytest.approx((rate,) * 3) + assert _native_surface_dem(implicit) == _native_surface_dem(explicit) + + +def test_structured_families_survive_runtime_idle_unit_conversion() -> None: + noise = NoiseParameters( + p_idle_linear=0.3, + p_idle_sin_squared=0.2, + p_idle_sin_squared_model={"Z": 1.0}, + ) + + converted = noise.for_runtime_idle_time_units(time_units_per_second=10.0) + + assert converted.idle_memory_rates[:3] == pytest.approx((0.01, 0.01, 0.01)) + assert converted.idle_memory_rates[6:] == (None, None, pytest.approx(0.02)) + assert converted.p_idle_linear is None + assert converted.p_idle_linear_model is None + assert converted.p_idle_sin_squared is None + assert converted.p_idle_sin_squared_model is None + assert converted.p_idle_coherent is None + assert converted.p_idle_coherent_model is None + + +@pytest.mark.parametrize( + "kwargs", + [ + {"p_idle_linear": 0.01, "_p_idle_x_linear_rate": 0.02}, + {"p_idle_sin_squared": 0.01, "_p_idle_y_quadratic_sine_rate": 0.02}, + ], +) +def test_structured_family_conflicts_with_corresponding_primitive(kwargs: dict[str, object]) -> None: + with pytest.raises(ValueError, match="cannot be combined"): + NoiseParameters(**kwargs) + + +def test_idle_memory_rates_include_translated_family_values() -> None: + noise = NoiseParameters( + p_idle_linear=0.3, + p_idle_sin_squared=0.2, + p_idle_sin_squared_model={"Z": 1.0}, + ) + + assert noise.idle_memory_rates[:3] == pytest.approx((0.1, 0.1, 0.1)) + assert noise.idle_memory_rates[3:8] == (None, None, None, None, None) + assert noise.idle_memory_rates[8] == pytest.approx(0.2) + + +def test_nonzero_coherent_family_is_rejected_by_standard_dem_model() -> None: + with pytest.raises(ValueError, match="cannot represent coherent idle noise"): + NoiseParameters().with_p_idle_coherent(0.01) 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 cc06ef5c7..ea7640276 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 @@ -404,7 +404,7 @@ def test_decode_each_matches_decode_count(): batch = ParsedDem.from_string(dem).to_dem_sampler().sample_batch(n, seed=5) preds = batch.decode_each(dem, "pecos_uf:bp") assert len(preds) == n - wrong = sum(1 for i, p in enumerate(preds) if p != batch.get_observable_mask(i)) + wrong = sum(1 for i, p in enumerate(preds) if p != batch.get_observable_flips(i).mask) assert wrong == batch.decode_count(dem, "pecos_uf:bp") diff --git a/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py new file mode 100644 index 000000000..545ee6ada --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_noise_parameters.py @@ -0,0 +1,205 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Contract tests for DEM-construction noise parameters.""" + +from __future__ import annotations + +from dataclasses import fields + +import pecos.qec.surface as surface +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit +from pecos import NoiseParameters +from pecos.qec import DetectorErrorModel + + +@guppy +def _two_qubit_program() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +def _dem_bytes(noise: NoiseParameters) -> bytes: + build = ( + DetectorErrorModel.builder() + .with_program(_two_qubit_program) + .with_qubits(2) + .with_detectors_json('[{"id":0,"result_tags":["m0"]}]') + .with_observables_json('[{"id":0,"result_tags":["m1"]}]') + .with_noise(noise) + .build() + ) + return build.dem.to_string().encode() + + +def test_fluent_chain_matches_constructor_and_dem() -> None: + constructor = NoiseParameters( + p1=0.001, + p1_weights={"X": 0.2, "Y": 0.3, "Z": 0.5}, + p2=0.01, + p2_weights={"IX": 1.0}, + p2_replacement_approximation="ignore_gate_removal", + p_meas=0.002, + p_prep=0.003, + ) + fluent = ( + NoiseParameters() + .with_p1(0.001) + .with_p1_weights({"X": 0.2, "Y": 0.3, "Z": 0.5}) + .with_p2(0.01) + .with_p2_weights({"IX": 1.0}) + .with_p2_replacement_approximation("ignore_gate_removal") + .with_p_meas(0.002) + .with_p_prep(0.003) + ) + + assert fluent == constructor + assert _dem_bytes(fluent) == _dem_bytes(constructor) + + +# The idle-family model fields are the one deliberate exception to the +# mechanical rule: they are set through their family's rate setter, because a +# model without a rate is inert and the two cannot be set in separate calls. +_FAMILY_MODEL_FIELDS = { + "p_idle_linear_model", + "p_idle_sin_squared_model", + "p_idle_coherent_model", +} +_INTERNAL_IDLE_FIELDS = { + "_p_idle_linear_rate", + "_p_idle_quadratic_rate", + "_p_idle_x_linear_rate", + "_p_idle_y_linear_rate", + "_p_idle_z_linear_rate", + "_p_idle_x_quadratic_rate", + "_p_idle_y_quadratic_rate", + "_p_idle_z_quadratic_rate", + "_p_idle_quadratic_sine_rate", + "_p_idle_x_quadratic_sine_rate", + "_p_idle_y_quadratic_sine_rate", + "_p_idle_z_quadratic_sine_rate", +} +_REMOVED_IDLE_SETTERS = tuple(f"with_{name.removeprefix('_')}" for name in sorted(_INTERNAL_IDLE_FIELDS)) + + +def test_every_field_has_a_mechanical_fluent_setter() -> None: + field_names = {field.name for field in fields(NoiseParameters)} + public_field_names = field_names - _INTERNAL_IDLE_FIELDS + + assert len(field_names) == 30 + assert len(public_field_names) == 18 + for field_name in public_field_names - _FAMILY_MODEL_FIELDS: + assert callable(getattr(NoiseParameters, f"with_{field_name}")), field_name + + +def test_per_axis_and_legacy_idle_fields_are_internal() -> None: + noise = NoiseParameters() + + for internal_name in _INTERNAL_IDLE_FIELDS: + assert hasattr(noise, internal_name), internal_name + assert not hasattr(noise, internal_name.removeprefix("_")), internal_name + + +def test_per_axis_and_legacy_idle_setters_are_removed() -> None: + noise = NoiseParameters() + + for setter_name in _REMOVED_IDLE_SETTERS: + assert not hasattr(noise, setter_name), setter_name + + +def test_per_axis_idle_constructor_keyword_fails_loudly() -> None: + kwargs = {"p_idle_z_linear_rate": 0.01} + with pytest.raises(TypeError, match=r"unexpected keyword argument 'p_idle_z_linear_rate'"): + NoiseParameters(**kwargs) + + +def test_family_models_are_set_through_their_rate_setter() -> None: + import inspect + + for family in ("p_idle_linear", "p_idle_sin_squared", "p_idle_coherent"): + signature = inspect.signature(getattr(NoiseParameters, f"with_{family}")) + assert "model" in signature.parameters, family + + +def test_fluent_setter_returns_a_new_object() -> None: + original = NoiseParameters(p1=0.001) + + updated = original.with_p1(0.002) + + assert updated is not original + assert original.p1 == 0.001 + assert updated.p1 == 0.002 + + +def test_structured_family_survives_fluent_chain_and_runtime_conversion() -> None: + noise = NoiseParameters().with_p_idle_linear(0.3).with_p1(0.001).with_p_meas(0.002) + + converted = noise.for_runtime_idle_time_units(time_units_per_second=10.0) + + assert converted.idle_memory_rates[:3] == pytest.approx((0.01, 0.01, 0.01)) + assert converted.p_idle_linear is None + assert converted.p_idle_linear_model is None + + +def test_idle_family_rate_and_model_set_together() -> None: + # The family halves must be settable in ONE call: __post_init__ translates a + # family into per-axis fields and clears it, so a separate model-setting call + # would collide with the per-axis values the rate call just produced. + noise = NoiseParameters().with_p_idle_linear(0.01, {"Z": 1.0}).with_p1(0.001) + + assert noise.idle_memory_rates[2] == pytest.approx(0.01) + assert noise.idle_memory_rates[0] in (None, 0.0) + assert noise.p_idle_linear is None + assert noise.p1 == pytest.approx(0.001) + + +def test_idle_families_have_no_separate_model_setters() -> None: + # A model without a rate is inert and rejected, so exposing a lone model + # setter would only ever produce an error or a collision. + for name in ( + "with_p_idle_linear_model", + "with_p_idle_sin_squared_model", + "with_p_idle_coherent_model", + ): + assert not hasattr(NoiseParameters, name), name + + +def test_each_idle_family_round_trips_through_runtime_conversion() -> None: + linear = NoiseParameters().with_p_idle_linear(0.3, {"Z": 1.0}) + sine = NoiseParameters().with_p_idle_sin_squared(0.2, {"X": 1.0}) + + assert linear.for_runtime_idle_time_units(time_units_per_second=10.0).idle_memory_rates[2] == pytest.approx(0.03) + converted_sine = sine.for_runtime_idle_time_units(time_units_per_second=10.0) + assert converted_sine.idle_memory_rates[6] == pytest.approx(0.02) + + +def test_deprecated_alias_warns_and_returns_noise_parameters() -> None: + with pytest.warns( + DeprecationWarning, + match=r"NoiseModel.*NoiseParameters.*from pecos import NoiseParameters", + ): + legacy = surface.NoiseModel(p1=0.001) + + assert type(legacy) is NoiseParameters + assert legacy == NoiseParameters(p1=0.001) + + +def test_public_import_paths_refer_to_the_same_class() -> None: + from pecos import NoiseParameters as TopLevelNoiseParameters + from pecos.qec.surface import NoiseParameters as SurfaceNoiseParameters + + assert TopLevelNoiseParameters is NoiseParameters + assert SurfaceNoiseParameters is NoiseParameters + + +def test_chaining_order_does_not_matter() -> None: + first = NoiseParameters().with_p1(0.001).with_p2(0.01).with_p_meas(0.002).with_p_prep(0.003) + second = NoiseParameters().with_p_prep(0.003).with_p_meas(0.002).with_p2(0.01).with_p1(0.001) + + assert first == second 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 889eb3d6e..be59734b7 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 @@ -4,7 +4,7 @@ import pytest from pecos.qec.surface import ( GuppyRngMaskConfig, - NoiseModel, + NoiseParameters, SurfacePatch, TwirlConfig, build_memory_circuit, @@ -482,7 +482,7 @@ def test_runtime_twirled_theta0_demask_null( sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=TwirlConfig(), ) @@ -542,7 +542,7 @@ def test_runtime_gate_local_twirled_theta0_demask_null( sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=twirl, ) @@ -608,7 +608,7 @@ def _assert_canonical_frame_output_matches_lookup( sampler = build_native_sampler( patch, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=TwirlConfig(), ) @@ -673,7 +673,7 @@ def test_runtime_gate_local_canonical_frame_output_matches_lookup( sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis=basis, twirl=abstract_twirl, ) @@ -763,7 +763,7 @@ def test_harvested_runtime_masks_drive_fixed_dem_sampler_null(patch_d3: SurfaceP sampler = build_native_sampler( patch_d3, num_rounds=num_rounds, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=twirl, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index a0c44873e..d1f086286 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -4,7 +4,7 @@ import pytest from pecos.qec.surface import ( GuppyRngMaskConfig, - NoiseModel, + NoiseParameters, SurfacePatch, TwirlConfig, build_memory_circuit, @@ -178,7 +178,7 @@ def test_demask_helper_cancels_known_pauli_frame_xor() -> None: sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(), ) @@ -213,7 +213,7 @@ def test_native_sampler_accepts_harvested_uint8_pauli_masks() -> None: sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(), ) @@ -234,7 +234,7 @@ def test_sample_batch_with_pauli_masks_returns_sample_batch() -> None: sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(), ) @@ -286,21 +286,21 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: raw = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(), ) canonical = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(frame_output="canonical"), ) scaled = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=TwirlConfig(twirl_probability=0.5), ) @@ -351,7 +351,7 @@ def test_abstract_twirl_builders_reject_unsupported_config( build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=twirl, ) @@ -360,7 +360,7 @@ def test_abstract_twirl_builders_reject_unsupported_config( generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(), + noise=NoiseParameters(), basis="Z", twirl=twirl, ) @@ -368,7 +368,7 @@ def test_abstract_twirl_builders_reject_unsupported_config( def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p_idle_x_quadratic_sine_rate=0.03) + noise = NoiseParameters().with_p_idle_sin_squared(0.03, {"X": 1.0}) twirl = TwirlConfig() dem = generate_circuit_level_dem_from_builder( @@ -398,17 +398,17 @@ def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: @pytest.mark.parametrize( ("label", "noise"), [ - ("depolarizing", NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), - ("uniform_idle", NoiseModel(p_idle=0.002)), - ("t1_t2", NoiseModel(t1=1000.0, t2=800.0)), - ("linear_idle", NoiseModel(p_idle_linear_rate=0.001)), - ("quadratic_idle", NoiseModel(p_idle_quadratic_rate=0.01)), - ("sine_law_idle", NoiseModel(p_idle_x_quadratic_sine_rate=0.03)), + ("depolarizing", NoiseParameters(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), + ("uniform_idle", NoiseParameters(p_idle=0.002)), + ("t1_t2", NoiseParameters(t1=1000.0, t2=800.0)), + ("linear_idle", NoiseParameters().with_p_idle_linear(0.001, {"Z": 1.0})), + ("z_sine_law_idle", NoiseParameters().with_p_idle_sin_squared(0.01, {"Z": 1.0})), + ("x_sine_law_idle", NoiseParameters().with_p_idle_sin_squared(0.03, {"X": 1.0})), ], ) def test_twirling_does_not_change_canonical_dem( label: str, - noise: NoiseModel, + noise: NoiseParameters, ) -> None: del label patch = SurfacePatch.create(distance=3) @@ -434,7 +434,7 @@ def test_twirling_does_not_change_canonical_dem( def test_gate_local_twirling_does_not_change_canonical_dem() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) untwirled = generate_circuit_level_dem_from_builder( patch, 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 d818436ba..f1841c335 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -13,7 +13,7 @@ import numpy as np import pytest from pecos.qec.surface import ( - NoiseModel, + NoiseParameters, SurfaceDecoder, SurfacePatch, generate_dem_from_tick_circuit, @@ -51,11 +51,11 @@ def _count_singleton_error_parts(dem: str) -> int: class TestNoiseModel: - """Tests for NoiseModel dataclass.""" + """Tests for NoiseParameters dataclass.""" def test_default_values(self) -> None: """Default noise model should have zero error rates.""" - noise = NoiseModel() + noise = NoiseParameters() assert noise.p1 == 0.0 assert noise.p2 == 0.0 assert noise.p_meas == 0.0 @@ -63,15 +63,15 @@ def test_default_values(self) -> None: def test_is_noiseless(self) -> None: """Test is_noiseless property.""" - assert NoiseModel().is_noiseless - assert not NoiseModel(p1=0.01).is_noiseless - assert not NoiseModel(p2=0.01).is_noiseless - assert not NoiseModel(p_meas=0.01).is_noiseless - assert not NoiseModel(p_prep=0.01).is_noiseless + assert NoiseParameters().is_noiseless + assert not NoiseParameters(p1=0.01).is_noiseless + assert not NoiseParameters(p2=0.01).is_noiseless + assert not NoiseParameters(p_meas=0.01).is_noiseless + assert not NoiseParameters(p_prep=0.01).is_noiseless def test_physical_error_rate(self) -> None: """Test physical_error_rate property.""" - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.005, p_prep=0.002) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.005, p_prep=0.002) assert noise.physical_error_rate == 0.01 # max of all rates @@ -154,7 +154,7 @@ class TestSurfaceDecoder: def test_create_decoder_d3(self) -> None: """Create decoder for distance-3 patch.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=1, noise=noise) assert decoder.patch == patch @@ -164,7 +164,7 @@ def test_create_decoder_d3(self) -> None: def test_create_decoder_d5(self) -> None: """Create decoder for distance-5 patch.""" patch = SurfacePatch.create(distance=5) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=3, noise=noise) assert decoder.patch == patch @@ -173,7 +173,7 @@ def test_create_decoder_d5(self) -> None: def test_decoder_types(self) -> None: """Test different decoder type options.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) # PyMatching (default) d1 = SurfaceDecoder(patch, decoder_type="pymatching", noise=noise) @@ -200,7 +200,7 @@ def test_circuit_level_pymatching_uses_correlations_by_default(self, monkeypatch import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **_kwargs: object) -> str: @@ -242,7 +242,7 @@ def test_circuit_level_uncorrelated_pymatching_uses_plain_dem(self, monkeypatch: import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **_kwargs: object) -> str: @@ -279,7 +279,7 @@ def from_dem(cls, dem: str) -> object: def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: """The correlated option needs DEM metadata and should fail without it.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder( patch, decoder_type="pymatching_correlated", @@ -293,7 +293,7 @@ def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: """The explicit correlated option needs decomposed DEM metadata.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder( patch, decoder_type="pymatching_correlated", @@ -315,7 +315,7 @@ def test_recommended_memory_workflow_uses_terminal_graphlike_for_pymatching(self def test_get_dem(self) -> None: """Test DEM generation via decoder.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=3, noise=noise) # Test circuit-level DEM (default) @@ -337,7 +337,7 @@ def test_get_dem_caches_circuit_level_dem(self, monkeypatch: pytest.MonkeyPatch) import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) decoder = SurfaceDecoder( patch, num_rounds=3, @@ -366,7 +366,7 @@ def test_get_dem_passes_interaction_basis_to_native_builder(self, monkeypatch: p import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **kwargs: object) -> str: @@ -395,7 +395,7 @@ def test_get_dem_passes_terminal_graphlike_mode_to_native_builder( import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) seen: dict[str, object] = {} def wrapped_generate(*_args: object, **kwargs: object) -> str: @@ -421,7 +421,7 @@ def wrapped_generate(*_args: object, **kwargs: object) -> str: def test_decode_trivial_syndrome_z(self) -> None: """Decode trivial Z syndrome (no errors).""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=1, noise=noise) # All-zero syndrome @@ -446,7 +446,7 @@ def test_decode_trivial_syndrome_z(self) -> None: def test_decode_trivial_syndrome_x(self) -> None: """Decode trivial X syndrome (no errors).""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) decoder = SurfaceDecoder(patch, num_rounds=1, noise=noise) num_x_stab = len(patch.geometry.x_stabilizers) @@ -536,7 +536,7 @@ class TestDemGeneration: def test_generate_surface_code_dem_z(self) -> None: """Generate Z-stabilizer DEM.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) dem = generate_surface_code_dem(patch, num_rounds=3, noise=noise, stab_type="Z") @@ -548,7 +548,7 @@ def test_generate_surface_code_dem_z(self) -> None: def test_generate_surface_code_dem_x(self) -> None: """Generate X-stabilizer DEM.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) dem = generate_surface_code_dem(patch, num_rounds=3, noise=noise, stab_type="X") @@ -560,7 +560,7 @@ def test_generate_dem_from_patch_can_skip_stim_decomposition(self) -> None: from pecos.qec.surface.decode import generate_dem_from_patch patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) full_dem = generate_dem_from_patch(patch, num_rounds=4, noise=noise, basis="X", decompose_errors=False) decomposed_dem = generate_dem_from_patch(patch, num_rounds=4, noise=noise, basis="X", decompose_errors=True) @@ -586,7 +586,7 @@ def test_native_circuit_level_dem_threads_ancilla_budget(self) -> None: from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep} full_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") @@ -636,7 +636,7 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: ) patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep} # abstract source @@ -682,7 +682,7 @@ def test_unconstrained_budget_spellings_collapse_to_one_dem(self) -> None: patch = SurfacePatch.create(distance=3) total = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) # Canonicalization: every unconstrained spelling -> None; a real # constraint passes through unchanged. @@ -716,7 +716,7 @@ def test_constrained_budget_sampler_builds_for_all_models(self) -> None: from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) abstract_tc = _build_surface_tick_circuit_for_native_model( patch, 2, @@ -864,7 +864,7 @@ def test_native_circuit_level_dem_cache_respects_patch_geometry(self) -> None: from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(dx=3, dz=5) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep} tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") @@ -884,7 +884,7 @@ def test_native_circuit_level_dem_cache_inserts_idle_gates_only_for_idle_noise(s from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(distance=3) - base_noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + base_noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) base_params = { "p1": base_noise.p1, "p2": base_noise.p2, @@ -902,7 +902,7 @@ def test_native_circuit_level_dem_cache_inserts_idle_gates_only_for_idle_noise(s basis="X", ) - idle_noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001, p_idle=0.002) + idle_noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001, p_idle=0.002) idle_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") idle_tc.fill_idle_gates() expected_idle_dem = generate_dem_from_tick_circuit( @@ -929,7 +929,7 @@ def test_traced_qis_native_dem_and_sampler_build(self) -> None: _require_selene_runtime() patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) dem = generate_circuit_level_dem_from_builder( patch, @@ -1014,7 +1014,7 @@ def extract_errors(dem_str: str) -> dict[str, float]: return errors patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.003, p2=0.003, p_meas=0.003, p_prep=0.003) + noise = NoiseParameters(p1=0.003, p2=0.003, p_meas=0.003, p_prep=0.003) for basis in ("X", "Z"): tc = _build_surface_tick_circuit_for_native_model( @@ -1069,8 +1069,8 @@ def test_traced_qis_native_topology_cache_is_shared_across_public_apis(self) -> _require_selene_runtime() patch = SurfacePatch.create(distance=3) - noise_a = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) - noise_b = NoiseModel(p1=0.002, p2=0.002, p_meas=0.002, p_prep=0.002) + noise_a = NoiseParameters(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) + noise_b = NoiseParameters(p1=0.002, p2=0.002, p_meas=0.002, p_prep=0.002) _cached_surface_native_topology.cache_clear() _cached_surface_native_dem_string.cache_clear() @@ -1136,7 +1136,7 @@ def test_generate_dem_from_tick_circuit_maximal_decomposition_prefers_singletons def test_dem_detector_count(self) -> None: """DEM should have correct number of detectors.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) num_rounds = 3 dem = generate_surface_code_dem( @@ -1156,7 +1156,7 @@ def test_dem_detector_count(self) -> None: def test_dem_single_round(self) -> None: """DEM with single round should have boundary measurement errors.""" patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p2=0.01, p_meas=0.01) + noise = NoiseParameters(p2=0.01, p_meas=0.01) dem = generate_surface_code_dem(patch, num_rounds=1, noise=noise, stab_type="Z") 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 83a9e7799..e34ecb99b 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 @@ -11,7 +11,7 @@ import pytest import stim from pecos._traced_circuit import normalize_traced_tick_circuit -from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig +from pecos.qec.surface import NoiseParameters, SurfacePatch, TwirlConfig from pecos.qec.surface.circuit_builder import ( OpType, SurfaceCircuitStep, @@ -492,7 +492,7 @@ def test_szz_runtime_barriers_allow_strict_traced_hosted_order() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0), circuit_source="traced_qis", interaction_basis="szz", szz_runtime_barriers="data-prefix", @@ -581,7 +581,7 @@ def test_round_order_szz_noiseless_detector_record_equivalence( 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) + noise = NoiseParameters(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}, p_meas=0.001, p_prep=0.001) cx_dem = generate_circuit_level_dem_from_builder( patch, @@ -606,25 +606,25 @@ def test_szz_native_dem_respects_gate_specific_p2_overrides() -> None: inherited_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}), interaction_basis="szz", ) no_szz_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.01, p2_szz=0.0, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.01, p2_szz=0.0, p2_weights={"ZI": 1.0}), interaction_basis="szz", ) no_szzdg_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.01, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.01, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), interaction_basis="szz", ) override_only_dem = generate_circuit_level_dem_from_builder( patch, num_rounds=2, - noise=NoiseModel( + noise=NoiseParameters( p1=0.0, p2=0.0, p2_szz=0.01, @@ -645,14 +645,14 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: zero_sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.0, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.0, p2_szz=0.0, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), interaction_basis="szz", sampling_model="influence_dem", ) active_sampler = build_native_sampler( patch, num_rounds=2, - noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.01, p2_szzdg=0.01, p2_weights={"ZI": 1.0}), + noise=NoiseParameters(p1=0.0, p2=0.0, p2_szz=0.01, p2_szzdg=0.01, p2_weights={"ZI": 1.0}), interaction_basis="szz", sampling_model="influence_dem", ) @@ -665,7 +665,7 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: patch = SurfacePatch.create(distance=3) patch_key = _surface_patch_cache_key(patch) - noise = NoiseModel(p1=0.0, p2=0.01, p_meas=0.0, p_prep=0.0) + noise = NoiseParameters(p1=0.0, p2=0.01, p_meas=0.0, p_prep=0.0) plain = _surface_native_topology( patch_key, @@ -831,7 +831,7 @@ def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_mo sampler = build_native_sampler( patch, num_rounds=1, - noise=NoiseModel(p1=0.001), + noise=NoiseParameters(p1=0.001), interaction_basis="szz", sampling_model=sampling_model, ) @@ -853,7 +853,7 @@ def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p_idle=0.001), + noise=NoiseParameters(p_idle=0.001), interaction_basis="szz", circuit_source="traced_qis", ) @@ -973,7 +973,7 @@ def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.001), + noise=NoiseParameters(p1=0.001), interaction_basis="szz", circuit_source="traced_qis", ) @@ -995,7 +995,7 @@ def test_szz_public_traced_qis_dem_matches_stim_with_z_frame_p1_free(basis: str) interaction_basis="szz", ) normalize_traced_tick_circuit(tick_circuit, context="SZZ public traced-QIS p1 test") - noise = NoiseModel(p1=0.001) + noise = NoiseParameters(p1=0.001) native_errors = _raw_dem_errors( generate_circuit_level_dem_from_builder( @@ -1052,7 +1052,7 @@ def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p1=0.001), + noise=NoiseParameters(p1=0.001), interaction_basis="szz", ) @@ -1067,7 +1067,7 @@ def test_szz_native_sampler_accepts_idle_with_physical_prefix_lowering(sampling_ sampler = build_native_sampler( patch, num_rounds=1, - noise=NoiseModel(p_idle=0.001), + noise=NoiseParameters(p_idle=0.001), interaction_basis="szz", sampling_model=sampling_model, ) @@ -1084,7 +1084,7 @@ def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: dem = generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=NoiseModel(p_idle=0.001), + noise=NoiseParameters(p_idle=0.001), interaction_basis="szz", ) @@ -1096,7 +1096,7 @@ def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: def test_szz_idle_dem_uses_lowered_prefix_topology(basis: str) -> None: patch = SurfacePatch.create(distance=3) patch_key = _surface_patch_cache_key(patch) - noise = NoiseModel(p_idle_z_linear_rate=0.01) + noise = NoiseParameters().with_p_idle_linear(0.01, {"Z": 1.0}) actual = generate_circuit_level_dem_from_builder( patch, @@ -1178,7 +1178,7 @@ def test_szz_virtual_prefix_ticks_do_not_contribute_idle_dem() -> None: patch, num_rounds=1, basis="Z", - noise=NoiseModel(p_idle_z_linear_rate=0.01), + noise=NoiseParameters().with_p_idle_linear(0.01, {"Z": 1.0}), interaction_basis="szz", decompose_errors=False, ) diff --git a/python/quantum-pecos/tests/qec/test_decoder_comparison.py b/python/quantum-pecos/tests/qec/test_decoder_comparison.py new file mode 100644 index 000000000..9d06316b1 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_decoder_comparison.py @@ -0,0 +1,29 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Python coverage for paired DUT/reference decoder comparison.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("pecos_rslib") + +from pecos_rslib.qec import SampleBatch + + +def test_sample_batch_compare_decoders_exposes_joint_counts() -> None: + dem = "error(0.1) D0 L0\n" + batch = SampleBatch([[0], [1], [0], [1]], [0, 1, 0, 1]) + + first = batch.compare_decoders(dem, "pymatching", "pymatching") + second = batch.compare_decoders(dem, "pymatching", "pymatching") + + assert first.total_shots == 4 + assert first.counts == [[4, 0, 0], [0, 0, 0], [0, 0, 0]] + assert first.dut_correct_reference_correct == 4 + assert first.dut_only_failures == 0 + assert first.both_failed == 0 + assert first.dut_only_failure_interval[0] >= 0.0 + assert first.dut_only_failure_interval[1] <= 1.0 + assert second.counts == first.counts diff --git a/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py new file mode 100644 index 000000000..d9a10f796 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_decoder_surface_defects.py @@ -0,0 +1,281 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Regression tests for the decoder surface defects from issue #431.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pecos.decoders import ( + MWPM2D, + BpLsdDecoder, + BpOsdBuilder, + BpOsdDecoder, + DemAwareDecoder, + DemAwareResult, + DummyDecoder, + FusionBlossomDecoder, + MinSumBpDecoder, + PyMatchingDecoder, + RelayBpDecoder, + SparseMatrix, + TesseractDecoder, + UnionFindBuilder, + UnionFindDecoder, +) +from pecos_rslib.qec import decoder_dem_requirement + +if TYPE_CHECKING: + from collections.abc import Callable + +_DEM = """error(0.1) D0 D1 L0 +error(0.1) D1 L0 +detector D0 +detector D1 +logical_observable L0""" + +_ENCODING_DEM = """detector D0 +detector D1 +logical_observable L0 +error(0.1) D0 +error(0.1) D1 L0 +""" + +_SYNDROMES = ([0, 0], [1, 0], [0, 1], [1, 1]) +_OBSERVABLE_MASKS_BEFORE = [0, 0, 1, 1] +_FAMILY_RESULTS_BEFORE = [(0, True, 1), (0, True, 1), (1, True, 1), (1, True, 1)] +_FAMILY_DECODERS = [ + (BpOsdDecoder, "bp_osd"), + (BpLsdDecoder, "bp_lsd"), + (UnionFindDecoder, "union_find"), + (RelayBpDecoder, "relay_bp"), + (MinSumBpDecoder, "min_sum_bp"), +] +_ITERATIVE_FAMILY_DECODERS = [BpOsdDecoder, BpLsdDecoder, RelayBpDecoder, MinSumBpDecoder] + + +def test_dem_aware_result_is_importable() -> None: + # Decoding returns this type, so users must be able to name it. + assert DemAwareResult.__name__ == "DemAwareResult" + + +def test_fusion_blossom_builds_from_a_dem() -> None: + assert FusionBlossomDecoder.from_dem(_DEM) is not None + assert FusionBlossomDecoder.from_dem(_DEM, correlated=True) is not None + + +def test_dense_and_sparse_names_disambiguate_the_same_list() -> None: + decoder = TesseractDecoder.from_dem(_ENCODING_DEM) + dense_result = decoder.decode_syndrome([1, 0]) + sparse_result = decoder.decode_from_defects([1, 0]) + + assert dense_result.observable_flips.mask == 0 + assert dense_result.cost == pytest.approx(2.197224577336219) + assert not dense_result.low_confidence + assert sparse_result.observable_flips.mask == 1 + assert sparse_result.cost == pytest.approx(4.394449154672438) + assert not sparse_result.low_confidence + + +def test_renamed_methods_preserve_captured_results() -> None: + pymatching = PyMatchingDecoder.from_dem(_ENCODING_DEM) + pymatching_result = pymatching.decode_syndrome([1, 0]) + assert list(pymatching_result.observable_flips) == [False] + assert pymatching_result.weight == pytest.approx(4.394449154672439) + + fusion_blossom = FusionBlossomDecoder.from_dem(_ENCODING_DEM) + fusion_blossom_result = fusion_blossom.decode_syndrome([1, 0]) + assert list(fusion_blossom_result.observable_flips) == [False] + assert fusion_blossom_result.weight == pytest.approx(2.196) + + parity_check_matrix = SparseMatrix([[1, 0], [0, 1]]) + bp_osd = BpOsdBuilder(parity_check_matrix, error_rate=0.1).build() + bp_osd_result = bp_osd.decode_syndrome([1, 0]) + assert (bp_osd_result.decoding, bp_osd_result.converged, bp_osd_result.iterations) == ([1, 0], True, 1) + + union_find = UnionFindBuilder(parity_check_matrix).build() + union_find_result = union_find.decode_syndrome([1, 0]) + assert (union_find_result.decoding, union_find_result.converged, union_find_result.iterations) == ([1, 0], True, 1) + + +def test_affected_decoder_classes_do_not_expose_bare_decode() -> None: + parity_check_matrix = SparseMatrix([[1, 0], [0, 1]]) + decoders = [ + PyMatchingDecoder.from_dem(_ENCODING_DEM), + TesseractDecoder.from_dem(_ENCODING_DEM), + FusionBlossomDecoder.from_dem(_ENCODING_DEM), + BpOsdBuilder(parity_check_matrix, error_rate=0.1).build(), + UnionFindBuilder(parity_check_matrix).build(), + ] + + for decoder in decoders: + assert not hasattr(decoder, "decode") + with pytest.raises(AttributeError, match=r"decode_syndrome.*decode_from_defects"): + decoder.decode() + + +@pytest.mark.parametrize(("decoder_class", "decoder_type"), _FAMILY_DECODERS) +def test_family_from_dem_matches_existing_wrapper(decoder_class: type, decoder_type: str) -> None: + named_decoder = decoder_class.from_dem(_ENCODING_DEM) + existing_decoder = DemAwareDecoder.from_dem(_ENCODING_DEM, decoder_type=decoder_type) + + named_results = [named_decoder.decode_syndrome(list(syndrome)) for syndrome in _SYNDROMES] + existing_results = [existing_decoder.decode_syndrome(list(syndrome)) for syndrome in _SYNDROMES] + + assert all(isinstance(result, DemAwareResult) for result in named_results) + named_masks = [result.observable_flips.mask for result in named_results] + existing_masks = [result.observable_flips.mask for result in existing_results] + assert named_masks == existing_masks == _OBSERVABLE_MASKS_BEFORE + assert [(result.observable_flips.mask, result.converged, result.iterations) for result in named_results] == ( + _FAMILY_RESULTS_BEFORE + ) + assert isinstance(named_decoder, DemAwareDecoder) + assert named_decoder.num_detectors == existing_decoder.num_detectors == 2 + assert named_decoder.num_mechanisms == existing_decoder.num_mechanisms == 2 + assert named_decoder.num_observables == existing_decoder.num_observables == 1 + assert f"type={decoder_type}" in repr(named_decoder) + assert not hasattr(named_decoder, "decode") + + +@pytest.mark.parametrize("decoder_class", _ITERATIVE_FAMILY_DECODERS) +def test_iterative_family_from_dem_accepts_tuning(decoder_class: type) -> None: + named_decoder = decoder_class.from_dem(_ENCODING_DEM, error_rate=0.2, max_iter=1) + named_result = named_decoder.decode_syndrome([0, 1]) + assert named_result.observable_flips.mask == 1 + + +def test_each_family_accepts_only_its_real_tuning_surface() -> None: + assert BpOsdDecoder.from_dem( + _ENCODING_DEM, + max_iter=5, + bp_schedule="serial", + ms_scaling_factor=0.75, + osd_order=1, + random_schedule_seed=42, + ) + assert BpLsdDecoder.from_dem( + _ENCODING_DEM, + max_iter=5, + bp_schedule="serial_relative", + ms_scaling_factor=0.625, + random_schedule_seed=43, + ) + assert UnionFindDecoder.from_dem(_ENCODING_DEM, method="peeling") + assert RelayBpDecoder.from_dem(_ENCODING_DEM, max_iter=5, alpha=0.8, seed=44) + assert MinSumBpDecoder.from_dem(_ENCODING_DEM, max_iter=5, alpha=0.7) + + +@pytest.mark.parametrize( + ("build", "parameter"), + [ + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, preset="quick"), "preset"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_beam=0), "det_beam"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_beam=2**16), "det_beam"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, pqlimit=-1), "pqlimit"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, pqlimit=0), "pqlimit"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_penalty=-0.1), "det_penalty"), + (lambda: TesseractDecoder.from_dem(_ENCODING_DEM, det_penalty=float("nan")), "det_penalty"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, max_iter=2**31), "max_iter"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, bp_schedule="random"), "bp_schedule"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, ms_scaling_factor=-0.1), "ms_scaling_factor"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, ms_scaling_factor=float("nan")), "ms_scaling_factor"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, osd_order=-1), "osd_order"), + (lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, osd_order=2**31), "osd_order"), + ( + lambda: BpOsdDecoder.from_dem(_ENCODING_DEM, random_schedule_seed=2**31), + "random_schedule_seed", + ), + (lambda: BpLsdDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + ( + lambda: BpLsdDecoder.from_dem(_ENCODING_DEM, random_schedule_seed=-(2**31) - 1), + "random_schedule_seed", + ), + (lambda: UnionFindDecoder.from_dem(_ENCODING_DEM, method="fast"), "method"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, alpha=-0.1), "alpha"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, alpha=float("nan")), "alpha"), + (lambda: RelayBpDecoder.from_dem(_ENCODING_DEM, seed=-1), "seed"), + (lambda: MinSumBpDecoder.from_dem(_ENCODING_DEM, max_iter=-1), "max_iter"), + (lambda: MinSumBpDecoder.from_dem(_ENCODING_DEM, alpha=-0.1), "alpha"), + (lambda: PyMatchingDecoder.from_dem(_ENCODING_DEM, error_probability=0.0), "error_probability"), + (lambda: PyMatchingDecoder.from_dem(_ENCODING_DEM, error_probability=1.0), "error_probability"), + (lambda: PyMatchingDecoder.from_dem(_ENCODING_DEM, error_probability=1.1), "error_probability"), + (lambda: FusionBlossomDecoder.from_dem(_ENCODING_DEM, solver_type="parallel"), "solver_type"), + (lambda: FusionBlossomDecoder.from_dem(_ENCODING_DEM, solver_type="fast"), "solver_type"), + ], +) +def test_invalid_dem_tuning_names_parameter(build: Callable[[], object], parameter: str) -> None: + with pytest.raises((ValueError, RuntimeError), match=parameter): + build() + + +@pytest.mark.parametrize("decoder_type", [decoder_type for _, decoder_type in _FAMILY_DECODERS]) +def test_decoder_type_still_accepts_all_five_family_values(decoder_type: str) -> None: + decoder = DemAwareDecoder.from_dem(_ENCODING_DEM, decoder_type=decoder_type) + assert decoder.decode_syndrome([0, 1]).observable_flips.mask == 1 + + +def test_legacy_measurement_protocol_decoders_keep_decode() -> None: + assert callable(MWPM2D.decode) + assert callable(DummyDecoder.decode) + + +# Every name `create_observable_decoder` accepts must also classify here. Add to +# both places when adding a decoder; the two lists drifted apart before. +@pytest.mark.parametrize( + ("decoder_type", "requirement"), + [ + ("pymatching", "graphlike"), + ("fusion_blossom", "graphlike"), + ("k_mwpm", "graphlike"), + ("windowed", "graphlike"), + ("beamsearch", "graphlike"), + ("belief_matching", "graphlike"), + ("belief_matching_correlated", "graphlike"), + ("belief_matching_mgbp", "graphlike"), + ("belief_matching_hybrid:inner=pymatching", "graphlike"), + ("tesseract", "any"), + ("astar", "any"), + ("bp_osd", "any"), + ("bp_lsd", "any"), + ("belief_find", "any"), + ("union_find", "any"), + ("relay_bp", "any"), + ("min_sum_bp", "any"), + ("mwpf", "any"), + ("pecos_uf:bp", "graphlike"), + ], +) +def test_registry_names_have_a_dem_requirement(decoder_type: str, requirement: str) -> None: + assert decoder_dem_requirement(decoder_type) == requirement + + +@pytest.mark.parametrize( + ("spec", "requirement"), + [ + pytest.param("perturbed", "graphlike", id="default-inner-pymatching"), + pytest.param("perturbed:K=5,inner=pymatching", "graphlike", id="matching-inner"), + pytest.param("perturbed:K=5,inner=tesseract", "any", id="hyperedge-inner"), + pytest.param("perturbed:K=5,inner=bp_osd", "any", id="check-matrix-inner"), + ], +) +def test_perturbed_requirement_follows_its_inner_decoder(spec: str, requirement: str) -> None: + # "perturbed" wraps an arbitrary inner decoder, so a fixed classification + # would be wrong for half its uses. + assert decoder_dem_requirement(spec) == requirement + + +def test_unknown_decoder_still_raises() -> None: + with pytest.raises(ValueError, match="Unknown decoder type"): + decoder_dem_requirement("not_a_decoder") diff --git a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py index 45db9a669..619382971 100644 --- a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py +++ b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py @@ -85,39 +85,6 @@ def singleton_l0_edges(direct_targets: set[tuple[tuple[int, ...], tuple[int, ... return {dets[0] for dets, logs in direct_targets if len(dets) == 1 and len(logs) == 1} -def xor_lists(left: list[int], right: list[int]) -> list[int]: - """XOR two integer lists interpreted as parity sets.""" - out = set(left) - for value in right: - if value in out: - out.remove(value) - else: - out.add(value) - return sorted(out) - - -def xor_effect_rows(left: dict[str, list[int]], right: dict[str, list[int]]) -> tuple[list[int], list[int]]: - """XOR two structured detector/DEM-output rows.""" - return ( - xor_lists(left["detectors"], right["detectors"]), - xor_lists(left["dem_outputs"], right["dem_outputs"]), - ) - - -def xor_source_components(row: dict[str, object]) -> tuple[list[int], list[int]]: - """XOR a structured row's source component effects.""" - dets: list[int] = [] - outputs: list[int] = [] - for part_dets, part_outputs in zip( - row["source_component_detectors"], - row["source_component_dem_outputs"], - strict=True, - ): - dets = xor_lists(dets, list(part_dets)) - outputs = xor_lists(outputs, list(part_outputs)) - return dets, outputs - - def parse_dem_error_probabilities(dem_str: str) -> dict[str, float]: """Map DEM target strings to their stated error probabilities.""" out: dict[str, float] = {} @@ -147,7 +114,7 @@ def build_source_tracked_dem(distance: int, basis: str, rounds: int = 20) -> obj """Build and cache a source-tracked native DEM for one surface-code shape.""" from pecos.qec import DagFaultAnalyzer, DemBuilder from pecos.qec.surface import ( - NoiseModel, + NoiseParameters, SurfacePatch, generate_tick_circuit_from_patch, get_measurement_order_from_tick_circuit, @@ -158,7 +125,7 @@ def build_source_tracked_dem(distance: int, basis: str, rounds: int = 20) -> obj dag = tc.to_dag_circuit() analyzer = DagFaultAnalyzer(dag) influence_map = analyzer.build_influence_map() - noise = NoiseModel(p1=0.01, p2=0.01, p_meas=0.01, p_prep=0.01) + noise = NoiseParameters(p1=0.01, p2=0.01, p_meas=0.01, p_prep=0.01) builder = DemBuilder(influence_map) builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep) @@ -175,7 +142,7 @@ def test_dem_builder_accepts_public_surface_descriptor_json() -> None: """Public surface descriptor JSON should reproduce the legacy builder output.""" from pecos.qec import DagFaultAnalyzer, DemBuilder from pecos.qec.surface import ( - NoiseModel, + NoiseParameters, SurfacePatch, generate_tick_circuit_from_patch, get_detector_descriptors_from_tick_circuit, @@ -187,7 +154,7 @@ def test_dem_builder_accepts_public_surface_descriptor_json() -> None: tc = generate_tick_circuit_from_patch(patch, num_rounds=4, basis="X") dag = tc.to_dag_circuit() influence_map = DagFaultAnalyzer(dag).build_influence_map() - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + noise = NoiseParameters(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) def _build(detectors_json: str, observables_json: str | None) -> object: """Build one source-tracked DEM from serialized detector metadata.""" @@ -490,11 +457,18 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> total_probability = sum(float(row["probability"]) for row in contributions) direct_rows = [row for row in contributions if row["source_type"] in DIRECT_SOURCE_TYPES] y_rows = [row for row in contributions if row["source_type"] == "YDecomposed"] + signature_rows = [row for row in contributions if row["direct_source_family"] == "ExclusiveSignature"] assert all(row["location_indices"] for row in contributions) - assert all(row["pauli_labels"] for row in contributions) + assert signature_rows + assert all(not row["pauli_labels"] for row in signature_rows) + assert all(row["pauli_labels"] or row["direct_source_family"] == "ExclusiveSignature" for row in contributions) assert all("gate_type_labels" in row for row in contributions) assert all("before_flags" in row for row in contributions) - assert all(len(row["location_indices"]) == len(row["pauli_labels"]) for row in contributions) + assert all( + len(row["location_indices"]) == len(row["pauli_labels"]) + or (not row["pauli_labels"] and row["direct_source_family"] == "ExclusiveSignature") + for row in contributions + ) assert all(len(row["location_indices"]) == len(row["gate_type_labels"]) for row in contributions) assert all(len(row["location_indices"]) == len(row["before_flags"]) for row in contributions) assert all(all(label in {"I", "X", "Y", "Z"} for label in row["pauli_labels"]) for row in contributions) @@ -511,75 +485,54 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_source_component_rows_xor_back_to_effect(basis: str) -> None: - """Source component rows should XOR back to their parent effect.""" +def test_exclusive_signature_rows_do_not_claim_source_frame_components(basis: str) -> None: + """Converted signature mechanisms must not claim an original Pauli decomposition.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) rows = [] for summary in dem.contribution_effect_summaries(): for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): - if "source_component_detectors" not in row: + if row.get("direct_source_family") != "ExclusiveSignature": continue rows.append((summary, row)) assert rows - - for summary, row in rows[:100]: - dets, outputs = xor_source_components(row) - assert dets == summary["detectors"] - assert outputs == summary["dem_outputs"] + assert all("source_component_detectors" not in row for _, row in rows) + assert all("source_component_dem_outputs" not in row for _, row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_direct_component_rows_xor_back_to_effect(basis: str) -> None: - """Stored direct components should reconstruct the parent effect via XOR.""" +def test_exclusive_signature_rows_do_not_claim_legacy_direct_components(basis: str) -> None: + """Converted signature mechanisms must not expose fabricated two-location components.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) rows = [] for summary in dem.contribution_effect_summaries(): for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): - if row["source_type"] not in DIRECT_SOURCE_TYPES: - continue - if "component_1_detectors" not in row or "component_2_detectors" not in row: + if row.get("direct_source_family") != "ExclusiveSignature": continue rows.append((summary, row)) assert rows - - for summary, row in rows[:100]: - left = { - "detectors": row["component_1_detectors"], - "dem_outputs": row["component_1_dem_outputs"], - } - right = { - "detectors": row["component_2_detectors"], - "dem_outputs": row["component_2_dem_outputs"], - } - dets, logs = xor_effect_rows(left, right) - assert dets == summary["detectors"] - assert logs == summary["dem_outputs"] + assert all("component_1_detectors" not in row for _, row in rows) + assert all("component_2_detectors" not in row for _, row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_one_sided_direct_component_rows_are_exposed(basis: str) -> None: - """One-sided direct components should remain visible in the structured bindings.""" +def test_exclusive_signature_rows_stay_direct_without_one_sided_subtypes(basis: str) -> None: + """Converted gate signatures are direct mechanisms, including aliased effects.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) rows = [] for summary in dem.contribution_effect_summaries(): for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): - if row["source_type"] != "DirectOneSidedComponent": + if row.get("direct_source_family") != "ExclusiveSignature": continue rows.append((summary, row)) assert rows - - for summary, row in rows[:100]: - assert "source_component_detectors" in row - assert "source_component_dem_outputs" in row - direct_dets, direct_logs = xor_source_components(row) - assert direct_dets == summary["detectors"] - assert direct_logs == summary["dem_outputs"] + assert all(row["source_type"] == "Direct" for _, row in rows) + assert all(len(row["location_indices"]) in {1, 2} for _, row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -597,9 +550,9 @@ def test_structured_direct_source_families_are_exposed_for_direct_rows(basis: st assert rows assert all("direct_source_family" in row for row in rows) - assert any(row["direct_source_family"] == "SingleLocationY" for row in rows) - assert any(row["direct_source_family"] == "TwoLocationComponent" for row in rows) - assert any(row["source_type"] == "DirectOneSidedComponent" for row in rows) + assert any(row["direct_source_family"] == "ExclusiveSignature" for row in rows) + assert {row["direct_source_family"] for row in rows} <= {"ExclusiveSignature", "SingleLocation"} + assert all(row["source_type"] == "Direct" for row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -649,7 +602,8 @@ def test_structured_render_summaries_reproduce_decomposed_regrouping(basis: str) assert probability == pytest.approx(decomposed_by_targets[targets], abs=5e-7) assert all("source_type_counts" in row for row in render_summaries) - assert any("DirectOneSidedComponent" in row["source_type_counts"] for row in render_summaries) + assert any("ExclusiveSignature" in row["direct_source_family_counts"] for row in render_summaries) + assert all("DirectOneSidedComponent" not in row["source_type_counts"] for row in render_summaries) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -664,7 +618,8 @@ def test_structured_render_records_reproduce_render_summaries(basis: str) -> Non assert len(render_records) == dem.num_contributions assert all("rendered_targets" in row for row in render_records) assert all("render_strategy" in row for row in render_records) - assert any("recorded_component_targets" in row for row in render_records) + assert any(row.get("direct_source_family") == "ExclusiveSignature" for row in render_records) + assert all("recorded_component_targets" not in row for row in render_records) regrouped: dict[tuple[tuple[int, ...], tuple[int, ...], str], dict[str, object]] = {} for row in render_records: @@ -747,8 +702,8 @@ def test_structured_keep_direct_policy_matches_default_render_outputs(basis: str @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_recorded_component_policy_exposes_alternative_records(basis: str) -> None: - """Recorded-component policy should expose alternate render strategies and targets.""" +def test_structured_recorded_component_policy_leaves_signature_rows_direct(basis: str) -> None: + """Recorded-component policy cannot invent components for converted signatures.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) default_records = dem.contribution_render_records() @@ -757,8 +712,5 @@ def test_structured_recorded_component_policy_exposes_alternative_records(basis: ) assert len(policy_records) == len(default_records) - assert any(row["render_strategy"] == "RecordedComponents" for row in policy_records) - assert any( - policy_row["rendered_targets"] != default_row["rendered_targets"] - for default_row, policy_row in zip(default_records, policy_records, strict=False) - ) + assert policy_records == default_records + assert all(row["render_strategy"] != "RecordedComponents" for row in policy_records) diff --git a/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py new file mode 100644 index 000000000..c9ddf21b4 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_dem_aware_decoder_width.py @@ -0,0 +1,54 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""`DemAwareDecoder` must not wrap observable bits at 64 (issue #430).""" + +from __future__ import annotations + +import pytest +from pecos.decoders import DemAwareDecoder + +# Observable 70 is the probe: a u64 mask would fold it onto bit 70 % 64 == 6. +_WIDE_OBSERVABLE = 70 +_WRAPPED_BIT = _WIDE_OBSERVABLE % 64 + + +def _wide_dem(num_observables: int = _WIDE_OBSERVABLE + 1) -> str: + lines = ["error(0.1) D0 L0", f"error(0.1) D1 L{_WIDE_OBSERVABLE}"] + lines += [f"detector D{index}" for index in range(2)] + lines += [f"logical_observable L{index}" for index in range(num_observables)] + return "\n".join(lines) + + +@pytest.fixture +def wide_decoder() -> DemAwareDecoder: + return DemAwareDecoder.from_dem(_wide_dem(), decoder_type="bp_osd") + + +def test_observable_past_64_sets_its_own_bit(wide_decoder: DemAwareDecoder) -> None: + mask = wide_decoder.decode_syndrome([0, 1]).observable_flips.mask + + assert mask >> _WIDE_OBSERVABLE & 1, "observable 70 must set bit 70" + assert not mask >> _WRAPPED_BIT & 1, "observable 70 must not wrap onto bit 6" + assert mask == 1 << _WIDE_OBSERVABLE + + +def test_narrow_observables_are_unchanged(wide_decoder: DemAwareDecoder) -> None: + # Values that fit in 64 bits must stay exactly what the previous u64 + # field held, so widening is not a behavior change for existing users. + assert wide_decoder.decode_syndrome([1, 0]).observable_flips.mask == 1 + + +def test_repr_reports_wide_masks(wide_decoder: DemAwareDecoder) -> None: + text = repr(wide_decoder.decode_syndrome([0, 1])) + + assert "DemAwareResult(" in text + assert str(_WIDE_OBSERVABLE) in text 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 eda9434dd..986bea8ab 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -3,13 +3,15 @@ """Regression tests for the Guppy-to-DEM convenience path.""" +import inspect import json +import warnings from typing import ClassVar import pytest from guppylang import guppy from guppylang.std.builtins import barrier, owned, result -from guppylang.std.quantum import h, measure, qubit, x +from guppylang.std.quantum import cx, h, measure, qubit, x from pecos._qis_trace_replay import ( _reject_partially_lowered_trace, _replay_lowered_qis_trace_into_tick_circuit, @@ -21,8 +23,8 @@ normalize_traced_tick_circuit, ) from pecos.guppy_gen import get_num_qubits, make_surface_code -from pecos.qec import DetectorErrorModel -from pecos.qec.surface import RUNTIME_IDLE_TIME_UNITS_PER_SECOND, NoiseModel, SurfacePatch +from pecos.qec import Detector, DetectorErrorModel, Observable, build_dem_from_guppy, rec +from pecos.qec.surface import RUNTIME_IDLE_TIME_UNITS_PER_SECOND, NoiseParameters, SurfacePatch from pecos.qec.surface.circuit_builder import ( generate_tick_circuit_from_patch, ) @@ -46,6 +48,28 @@ def _single_measurement() -> None: result("m", b) +@guppy +def _two_qubit_idle_target() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + m0 = measure(q0) + m1 = measure(q1) + result("m0", m0) + result("m1", m1) + + +@guppy +def _structured_idle_noise_target() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + h(q0) + h(q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + @guppy def _measurement_feedback() -> None: q0 = qubit() @@ -167,6 +191,716 @@ def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> st return dem.to_string() +_TWO_QUBIT_DETECTORS_JSON = '[{"id":0,"records":[-2]}]' +_TWO_QUBIT_OBSERVABLES_JSON = '[{"id":0,"records":[-1]}]' +_NO_GATE_NOISE = {"p1": 0.0, "p2": 0.0, "p_meas": 0.0, "p_prep": 0.0} + + +def _two_qubit_dem(**kwargs): + return DetectorErrorModel.from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + observables_json=_TWO_QUBIT_OBSERVABLES_JSON, + num_measurements=2, + seed=0, + **_NO_GATE_NOISE, + **kwargs, + ) + + +def _structured_idle_dem(entrypoint: str, **kwargs): + if entrypoint == "from_guppy": + return DetectorErrorModel.from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + observables_json=_TWO_QUBIT_OBSERVABLES_JSON, + num_measurements=2, + seed=0, + **_NO_GATE_NOISE, + **kwargs, + ) + return build_dem_from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + seed=0, + **_NO_GATE_NOISE, + **kwargs, + ).dem + + +def test_all_idle_laws_match_pre_rust_family_dem_bytes() -> None: + actual = _structured_idle_dem( + "from_guppy", + idle_after_2q_duration=2.0, + p_idle_x_linear_rate=0.002, + p_idle_y_linear_rate=0.003, + p_idle_z_linear_rate=0.005, + p_idle_x_quadratic_rate=0.0001, + p_idle_y_quadratic_rate=0.0002, + p_idle_z_quadratic_rate=0.0003, + p_idle_x_quadratic_sine_rate=0.01, + p_idle_y_quadratic_sine_rate=0.02, + p_idle_z_quadratic_sine_rate=0.03, + ).to_string() + + assert actual == "detector D0\nlogical_observable L0\nerror(0.013129) L0\nerror(0.019423) D0" + + +def test_z_linear_family_matches_pre_removed_axis_dem_bytes() -> None: + actual = _structured_idle_dem( + "from_guppy", + idle_after_2q_duration=2.0, + p_idle_z_linear_rate=0.005, + ).to_string() + + assert actual == "detector D0\nlogical_observable L0\nerror(0.01) D0" + + +def test_guppy_dem_entrypoints_do_not_expose_p_idle_shorthand() -> None: + assert "p_idle" not in inspect.signature(DetectorErrorModel.from_guppy).parameters + assert "p_idle" not in inspect.signature(build_dem_from_guppy).parameters + + +def _noise_model_entrypoint_dem(entrypoint: str, **kwargs): + if entrypoint == "from_guppy": + return DetectorErrorModel.from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + observables_json=_TWO_QUBIT_OBSERVABLES_JSON, + num_measurements=2, + **kwargs, + ) + return build_dem_from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + **kwargs, + ).dem + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_matches_flat_gate_noise(entrypoint: str) -> None: + rates = {"p1": 0.003, "p2": 0.007, "p_meas": 0.011, "p_prep": 0.013} + + grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**rates)) + flat = _noise_model_entrypoint_dem(entrypoint, **rates) + + assert grouped.to_string() == flat.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_matches_flat_pauli_weights(entrypoint: str) -> None: + noise_kwargs = { + "p1": 0.003, + "p1_weights": {"X": 0.6, "Y": 0.3, "Z": 0.1}, + "p2": 0.007, + "p2_weights": {"IX": 0.4, "XI": 0.6}, + "p_meas": 0.011, + "p_prep": 0.013, + } + + with pytest.warns(UserWarning, match=r"two-qubit gate .*largest TV 1\.184e-05"): + grouped = _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**noise_kwargs)) + with pytest.warns(UserWarning, match=r"two-qubit gate .*largest TV 1\.184e-05"): + flat = _noise_model_entrypoint_dem(entrypoint, **noise_kwargs) + + assert grouped.to_string() == flat.to_string() + assert grouped.idle_noise_residuals == flat.idle_noise_residuals + assert len(grouped.idle_noise_residuals) == 1 + residual = grouped.idle_noise_residuals[0] + assert residual["channel_kind"] == "two-qubit gate" + assert residual["magnitude"] == pytest.approx(1.1843041548472428e-05) + assert residual["channel_weight"] == pytest.approx(0.007) + assert residual["relative_magnitude"] == pytest.approx(0.001691863078353204) + assert residual["relative_magnitude"] == pytest.approx( + residual["magnitude"] / residual["channel_weight"], + ) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_structured_idle_family_matches_flat_axis_rates(entrypoint: str) -> None: + rate = 0.03 + model = {"X": 0.25, "Z": 0.75} + + grouped = _noise_model_entrypoint_dem( + entrypoint, + noise=NoiseParameters(p_idle_linear=rate, p_idle_linear_model=model), + idle_after_2q_duration=2.0, + ) + flat = _noise_model_entrypoint_dem( + entrypoint, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_x_linear_rate=rate * model["X"], + p_idle_z_linear_rate=rate * model["Z"], + idle_after_2q_duration=2.0, + ) + + assert grouped.to_string() == flat.to_string() + assert grouped.idle_noise_residuals == [] + assert flat.idle_noise_residuals == [] + + +def test_guppy_build_audit_surfaces_idle_conversion_residuals() -> None: + build = build_dem_from_guppy( + _structured_idle_noise_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_linear=0.03, + p_idle_linear_model={"X": 0.25, "Z": 0.75}, + idle_after_2q_duration=2.0, + ) + + assert build.audit["idle_noise_residuals"] == build.dem.idle_noise_residuals + assert build.audit["idle_noise_residuals"] == [] + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("keyword", ["p1", "p2", "p_meas", "p_idle_linear"]) +def test_noise_model_rejects_flat_noise_keyword(entrypoint: str, keyword: str) -> None: + with pytest.raises(ValueError, match=keyword): + _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(), **{keyword: 0.01}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("field", ["p_idle", "p2_szz", "p2_szzdg"]) +def test_noise_model_rejects_fields_not_supported_by_guppy_dem(entrypoint: str, field: str) -> None: + with pytest.raises(ValueError, match=field): + _noise_model_entrypoint_dem(entrypoint, noise=NoiseParameters(**{field: 0.01})) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_noise_model_combines_with_non_noise_keywords(entrypoint: str) -> None: + dem = _noise_model_entrypoint_dem( + entrypoint, + noise=NoiseParameters(p_idle_linear=0.01), + idle_after_2q_duration=1.0, + strip_traced_idles=True, + seed=17, + ) + + assert "error(" in dem.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_default_matches_axis_primitives(entrypoint: str) -> None: + rate = 0.03 + + structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_linear=rate) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_x_linear_rate=rate / 3.0, + p_idle_y_linear_rate=rate / 3.0, + p_idle_z_linear_rate=rate / 3.0, + ) + + assert structured.to_string() == primitive.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_custom_z_model_matches_axis_primitive(entrypoint: str) -> None: + rate = 0.03 + + structured = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_linear=rate, + p_idle_linear_model={"Z": 1.0}, + ) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_z_linear_rate=rate, + ) + + assert structured.to_string() == primitive.to_string() + assert structured.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_model_uses_engines_normalization_tolerance(entrypoint: str) -> None: + rate = 0.03 + + within_tolerance = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_linear=rate, + p_idle_linear_model={"Z": 1.0 + 5.0e-6}, + ) + normalized = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_linear=rate, + p_idle_linear_model={"Z": 1.0}, + ) + + assert within_tolerance.to_string() == normalized.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_default_matches_all_axis_sine_primitives(entrypoint: str) -> None: + rate = 0.17 + + structured = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, p_idle_sin_squared=rate) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_x_quadratic_sine_rate=rate, + p_idle_y_quadratic_sine_rate=rate, + p_idle_z_quadratic_sine_rate=rate, + ) + + assert structured.to_string() == primitive.to_string() + assert structured.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_explicit_z_model_matches_z_sine_primitive(entrypoint: str) -> None: + rate = 0.17 + + structured = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=rate, + p_idle_sin_squared_model={"Z": 1.0}, + ) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_z_quadratic_sine_rate=rate, + ) + + assert structured.to_string() == primitive.to_string() + assert structured.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_custom_model_matches_axis_sine_primitives(entrypoint: str) -> None: + rate = 0.17 + + structured = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=rate, + p_idle_sin_squared_model={"X": 1.0, "Z": 0.5}, + ) + primitive = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_x_quadratic_sine_rate=rate, + p_idle_z_quadratic_sine_rate=rate / 2.0, + ) + + assert structured.to_string() == primitive.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize( + ("rate_name", "model_name"), + [ + ("p_idle_linear", "p_idle_linear_model"), + ("p_idle_sin_squared", "p_idle_sin_squared_model"), + ], +) +def test_structured_idle_pauli_models_accept_zero_leakage_weight( + entrypoint: str, + rate_name: str, + model_name: str, +) -> None: + dem = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + **{rate_name: 0.03, model_name: {"X": 0.5, "Z": 0.5, "L": 0.0}}, + ) + + assert dem.num_contributions > 0 + assert dem.idle_noise_residuals == [] + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("model", [None, {"not_a_coherent_key": 1.0}]) +def test_structured_idle_coherent_nonzero_rate_is_rejected( + entrypoint: str, + model: dict[str, float] | None, +) -> None: + with pytest.raises(ValueError, match="standard DEM builder cannot represent coherent idle noise") as exc_info: + _structured_idle_dem(entrypoint, p_idle_coherent=0.17, p_idle_coherent_model=model) + + message = str(exc_info.value) + assert "standard DEM builder cannot represent coherent idle noise" in message + assert "silently stored the Pauli twirl" in message + assert "EEG" in message + assert "p_idle_sin_squared=rate/2" in message + assert "p_idle_sin_squared_model={'Z': 1.0}" in message + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_coherent_zero_rate_is_byte_identical_to_omitting_family(entrypoint: str) -> None: + omitted = _structured_idle_dem(entrypoint) + zero_rate = _structured_idle_dem(entrypoint, p_idle_coherent=0.0) + + assert zero_rate.to_string() == omitted.to_string() + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_coherent_model_keys_are_validation_only_at_zero_rate(entrypoint: str) -> None: + omitted = _structured_idle_dem(entrypoint) + zero_rate = _structured_idle_dem( + entrypoint, + p_idle_coherent=0.0, + p_idle_coherent_model={"RX": 1.0, "RY": 2.0, "RZ": 3.0}, + ) + + assert zero_rate.to_string() == omitted.to_string() + + +_LINEAR_IDLE_PRIMITIVES = ( + "p_idle_linear_rate", + "p_idle_x_linear_rate", + "p_idle_y_linear_rate", + "p_idle_z_linear_rate", +) +_SINE_IDLE_PRIMITIVES = ( + "p_idle_quadratic_sine_rate", + "p_idle_x_quadratic_sine_rate", + "p_idle_y_quadratic_sine_rate", + "p_idle_z_quadratic_sine_rate", +) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("primitive", _LINEAR_IDLE_PRIMITIVES) +def test_structured_idle_linear_rejects_each_low_level_primitive(entrypoint: str, primitive: str) -> None: + with pytest.raises(ValueError, match=primitive): + _structured_idle_dem(entrypoint, p_idle_linear=0.01, **{primitive: 0.02}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_linear_model_rejects_low_level_primitive_without_rate(entrypoint: str) -> None: + with pytest.raises(ValueError, match="p_idle_z_linear_rate"): + _structured_idle_dem( + entrypoint, + p_idle_linear_model={"Z": 1.0}, + p_idle_z_linear_rate=0.02, + ) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("primitive", _SINE_IDLE_PRIMITIVES) +def test_structured_idle_sin_squared_rejects_each_sine_primitive(entrypoint: str, primitive: str) -> None: + with pytest.raises(ValueError, match=rf"sine-law idle rate.*{primitive}"): + _structured_idle_dem(entrypoint, p_idle_sin_squared=0.01, **{primitive: 0.02}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_model_rejects_sine_primitive_without_rate(entrypoint: str) -> None: + with pytest.raises(ValueError, match=r"sine-law idle rate.*p_idle_z_quadratic_sine_rate"): + _structured_idle_dem( + entrypoint, + p_idle_sin_squared_model={"Z": 1.0}, + p_idle_z_quadratic_sine_rate=0.02, + ) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_structured_idle_sin_squared_composes_with_coefficient_quadratic_primitive(entrypoint: str) -> None: + dem = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=0.01, + p_idle_x_quadratic_rate=0.02, + ) + + assert dem.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"p_idle_linear": 0.01, "p_idle_linear_model": {"A": 1.0}}, "invalid.*key"), + ( + {"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.5, "Z": 0.3, "L": 0.2}}, + "'L'.*DEM fault propagation is Pauli-only.*engines simulators", + ), + ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.4, "Z": 0.4}}, "sum to 1.0"), + ( + {"p_idle_linear": 0.01, "p_idle_linear_model": {"X": 0.5, "Z": 0.6, "L": 0.2}}, + "sum to 1.0", + ), + ({"p_idle_linear": 0.01, "p_idle_linear_model": {"X": -0.1, "Z": 1.1}}, "non-negative"), + ({"p_idle_linear_model": {"Z": 1.0}}, "requires p_idle_linear"), + ({"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"A": 1.0}}, "invalid.*key"), + ( + {"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"X": 0.5, "Z": 0.3, "L": 0.2}}, + "'L'.*DEM fault propagation is Pauli-only.*engines simulators", + ), + ({"p_idle_sin_squared": 0.01, "p_idle_sin_squared_model": {"X": -0.1}}, "non-negative"), + ({"p_idle_sin_squared_model": {"Z": 1.0}}, "requires p_idle_sin_squared"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"A": 1.0}}, "invalid.*key"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"L": 1.0}}, "invalid.*key.*'L'"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"U": 0.0}}, "invalid.*key.*'U'"), + ({"p_idle_coherent": 0.0, "p_idle_coherent_model": {"RZ": -0.1}}, "non-negative"), + ({"p_idle_coherent_model": {"RZ": 1.0}}, "requires p_idle_coherent"), + ], +) +def test_structured_idle_model_validation(entrypoint: str, kwargs: dict[str, object], message: str) -> None: + with pytest.raises(ValueError, match=message): + _structured_idle_dem(entrypoint, **kwargs) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize( + ("alias", "replacement"), + [ + ("p_idle_linear_rate", "p_idle_linear"), + ("p_idle_quadratic_rate", "p_idle_sin_squared"), + ("p_idle_quadratic_sine_rate", "p_idle_sin_squared"), + ], +) +def test_legacy_idle_alias_warns_and_remains_functional(entrypoint: str, alias: str, replacement: str) -> None: + with pytest.warns(DeprecationWarning, match=rf"{alias}.*{replacement}"): + dem = _structured_idle_dem(entrypoint, idle_after_2q_duration=1.0, **{alias: 0.03}) + + assert dem.num_contributions > 0 + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +@pytest.mark.parametrize("rate_name", ["p_idle_linear", "p_idle_sin_squared", "p_idle_coherent"]) +@pytest.mark.parametrize("bad_rate", [-0.01, float("nan"), float("inf")]) +def test_structured_idle_family_rate_must_be_finite_and_non_negative( + entrypoint: str, + rate_name: str, + bad_rate: float, +) -> None: + with pytest.raises(ValueError, match=rf"{rate_name} must be a finite, non-negative float"): + _structured_idle_dem(entrypoint, **{rate_name: bad_rate}) + + +@pytest.mark.parametrize("entrypoint", ["from_guppy", "build_dem_from_guppy"]) +def test_sin_squared_idle_model_does_not_require_normalized_multipliers(entrypoint: str) -> None: + dem = _structured_idle_dem( + entrypoint, + idle_after_2q_duration=1.0, + p_idle_sin_squared=0.01, + p_idle_sin_squared_model={"X": 1.0, "Z": 0.5}, + ) + + assert dem.num_contributions > 0 + + +def test_from_guppy_idle_insertion_matches_manual_pass_pipeline() -> None: + from pecos.tracing import trace_program_to_tick_circuit + + rate = 0.01 + reference_circuit = trace_program_to_tick_circuit(_two_qubit_idle_target, 2, seed=0) + normalize_traced_tick_circuit(reference_circuit, context="from_guppy idle insertion reference") + reference_circuit.insert_idle_after_two_qubit_gates(1.0) + reference_circuit.set_meta("detectors", _TWO_QUBIT_DETECTORS_JSON) + reference_circuit.set_meta("observables", _TWO_QUBIT_OBSERVABLES_JSON) + reference_circuit.set_meta("num_measurements", "2") + reference = DetectorErrorModel.from_circuit( + reference_circuit, + p_idle_x_linear_rate=rate / 3.0, + p_idle_y_linear_rate=rate / 3.0, + p_idle_z_linear_rate=rate / 3.0, + **_NO_GATE_NOISE, + ) + + composed = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=rate) + + assert composed.to_string() == reference.to_string() + + +def test_from_guppy_inserted_idles_make_idle_noise_effective() -> None: + without_idle_noise = _two_qubit_dem(idle_after_2q_duration=1.0) + with_idle_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01) + + assert with_idle_noise.to_string() != without_idle_noise.to_string() + assert with_idle_noise.num_contributions > without_idle_noise.num_contributions + + +# Every idle-noise parameter the guard must observe; omitting any one from the +# guard wiring in dem.py must fail the corresponding parametrized case below. +_ALL_IDLE_NOISE_PARAMS = { + "p_idle_linear": 0.01, + "p_idle_sin_squared": 0.01, + "t1": 100.0, + "t2": 100.0, + "p_idle_linear_rate": 0.01, + "p_idle_quadratic_rate": 0.01, + "p_idle_x_linear_rate": 0.01, + "p_idle_y_linear_rate": 0.01, + "p_idle_z_linear_rate": 0.01, + "p_idle_x_quadratic_rate": 0.01, + "p_idle_y_quadratic_rate": 0.01, + "p_idle_z_quadratic_rate": 0.01, + "p_idle_quadratic_sine_rate": 0.01, + "p_idle_x_quadratic_sine_rate": 0.01, + "p_idle_y_quadratic_sine_rate": 0.01, + "p_idle_z_quadratic_sine_rate": 0.01, +} + + +@pytest.mark.parametrize("idle_param", sorted(_ALL_IDLE_NOISE_PARAMS)) +def test_from_guppy_rejects_idle_noise_without_idle_gates(idle_param: str) -> None: + with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): + _two_qubit_dem(**{idle_param: _ALL_IDLE_NOISE_PARAMS[idle_param]}) + + +@pytest.mark.parametrize("bad_duration", [0.0, -1.0, float("nan"), float("inf")]) +def test_from_guppy_rejects_non_positive_idle_duration(bad_duration: float) -> None: + with pytest.raises(ValueError, match=r"finite, positive duration"): + _two_qubit_dem(idle_after_2q_duration=bad_duration, p_idle_linear=0.01) + + +def test_from_guppy_idle_guard_accepts_inserted_idles_and_idles_without_noise() -> None: + with_noise = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01) + without_noise = _two_qubit_dem(idle_after_2q_duration=1.0) + + assert with_noise.num_contributions > 0 + assert without_noise is not None + + +def test_from_guppy_idle_guard_accepts_runtime_emitted_idles(monkeypatch: pytest.MonkeyPatch) -> None: + from pecos_rslib.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().pz([0, 1]) + circuit.tick().cx([(0, 1)]) + circuit.tick().idle(1, [0, 1]) + circuit.tick().mz_with_ids([0, 1], [0, 1]) + monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", lambda *_args, **_kwargs: circuit) + + dem = _two_qubit_dem(p_idle_linear=0.01) + + assert dem.num_contributions > 0 + + +def test_from_guppy_strip_traced_idles_is_noop_when_trace_has_no_idles() -> None: + baseline = _two_qubit_dem() + stripped = _two_qubit_dem(strip_traced_idles=True) + + assert stripped.to_string() == baseline.to_string() + + +def test_from_guppy_strip_traced_idles_removes_runtime_emitted_idles(monkeypatch: pytest.MonkeyPatch) -> None: + from pecos_rslib.quantum import TickCircuit + + circuit = TickCircuit() + circuit.tick().pz([0, 1]) + circuit.tick().cx([(0, 1)]) + circuit.tick().idle(1, [0, 1]) + circuit.tick().mz_with_ids([0, 1], [0, 1]) + monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", lambda *_args, **_kwargs: circuit) + + # The same runtime-emitted-idle circuit passes the guard when idles are kept + # (test_from_guppy_idle_guard_accepts_runtime_emitted_idles); with + # strip_traced_idles the guard must find no idle gates left. + with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): + _two_qubit_dem(strip_traced_idles=True, p_idle_linear=0.01) + + +def test_from_guppy_insertion_strips_runtime_idles_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + from pecos_rslib.quantum import TickCircuit + + def _traced_circuit_with_runtime_idles(*_args, **_kwargs): + circuit = TickCircuit() + circuit.tick().pz([0, 1]) + circuit.tick().cx([(0, 1)]) + circuit.tick().idle(1, [0, 1]) + circuit.tick().mz_with_ids([0, 1], [0, 1]) + return circuit + + monkeypatch.setattr("pecos.tracing.trace_program_to_tick_circuit", _traced_circuit_with_runtime_idles) + + default_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01) + explicit_strip = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01, strip_traced_idles=True) + keep_runtime_idles = _two_qubit_dem(idle_after_2q_duration=1.0, p_idle_linear=0.01, strip_traced_idles=False) + + # Insertion implies stripping unless explicitly disabled; keeping the + # runtime idles doubles the idle content and must change the DEM. + assert default_strip.to_string() == explicit_strip.to_string() + assert keep_runtime_idles.to_string() != default_strip.to_string() + + +def test_build_dem_from_guppy_rejects_idle_noise_without_idle_gates() -> None: + for idle_param, value in _ALL_IDLE_NOISE_PARAMS.items(): + with pytest.raises(ValueError, match=r"idle-noise parameters have no idle gates"): + build_dem_from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + **{idle_param: value}, + **_NO_GATE_NOISE, + ) + + +def test_build_dem_from_guppy_rejects_non_positive_idle_duration() -> None: + with pytest.raises(ValueError, match=r"finite, positive duration"): + build_dem_from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + idle_after_2q_duration=0.0, + p_idle_linear=0.01, + **_NO_GATE_NOISE, + ) + + +def test_build_dem_from_guppy_strips_then_inserts_idles() -> None: + build = build_dem_from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + strip_traced_idles=True, + idle_after_2q_duration=1.0, + p_idle_linear=0.01, + **_NO_GATE_NOISE, + ) + + assert build.circuit.gate_counts_by_type().get("Idle") == 2 + assert build.dem.num_contributions > 0 + + +def test_from_guppy_result_tags_coexist_with_idle_insertion() -> None: + via_tags = DetectorErrorModel.from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors_json='[{"id":0,"result_tags":["m0"]}]', + idle_after_2q_duration=1.0, + seed=0, + **_NO_GATE_NOISE, + ) + via_records = DetectorErrorModel.from_guppy( + _two_qubit_idle_target, + num_qubits=2, + detectors_json=_TWO_QUBIT_DETECTORS_JSON, + idle_after_2q_duration=1.0, + seed=0, + **_NO_GATE_NOISE, + ) + + assert via_tags.to_string() == via_records.to_string() + + def _flat_mz_ids(tc) -> list[int]: dag = tc.to_dag_circuit() ids: list[int] = [] @@ -321,17 +1055,19 @@ def test_lowered_replay_converts_runtime_idle_seconds_to_nanosecond_time_units() def test_noise_model_converts_runtime_idle_rates_from_seconds_to_dem_time_units() -> None: - noise = NoiseModel( - p1=0.001, - p2=0.002, - p_meas=0.003, - p_prep=0.004, - p_idle=9.0, - t1=1.5, - t2=2.5, - p_idle_z_linear_rate=3.0, - p_idle_x_quadratic_rate=4.0, - p_idle_z_quadratic_sine_rate=5.0, + noise = ( + NoiseParameters( + p1=0.001, + p2=0.002, + p_meas=0.003, + p_prep=0.004, + p_idle=9.0, + t1=1.5, + t2=2.5, + _p_idle_x_quadratic_rate=4.0, + ) + .with_p_idle_linear(3.0, {"Z": 1.0}) + .with_p_idle_sin_squared(5.0, {"Z": 1.0}) ) converted = noise.for_runtime_idle_time_units() @@ -343,14 +1079,16 @@ def test_noise_model_converts_runtime_idle_rates_from_seconds_to_dem_time_units( assert converted.p_idle == pytest.approx(9.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) assert converted.t1 == pytest.approx(1.5 * RUNTIME_IDLE_TIME_UNITS_PER_SECOND) assert converted.t2 == pytest.approx(2.5 * RUNTIME_IDLE_TIME_UNITS_PER_SECOND) - assert converted.p_idle_z_linear_rate == pytest.approx(3.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) - assert converted.p_idle_x_quadratic_rate == pytest.approx(4.0 / (RUNTIME_IDLE_TIME_UNITS_PER_SECOND**2)) - assert converted.p_idle_z_quadratic_sine_rate == pytest.approx(5.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) + assert converted.idle_memory_rates[2] == pytest.approx(3.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) + assert converted.idle_memory_rates[3] == pytest.approx(4.0 / (RUNTIME_IDLE_TIME_UNITS_PER_SECOND**2)) + assert converted.idle_memory_rates[8] == pytest.approx(5.0 / RUNTIME_IDLE_TIME_UNITS_PER_SECOND) def test_noise_model_rejects_invalid_runtime_idle_time_unit_scale() -> None: with pytest.raises(ValueError, match="time_units_per_second"): - NoiseModel(p_idle_z_linear_rate=1.0).for_runtime_idle_time_units(time_units_per_second=0.0) + NoiseParameters().with_p_idle_linear(1.0, {"Z": 1.0}).for_runtime_idle_time_units( + time_units_per_second=0.0, + ) def test_lowered_replay_preserves_gate_metadata() -> None: @@ -1011,7 +1749,7 @@ def test_native_abstract_surface_dem_uses_record_metadata_only_for_r0(basis: str assert json.loads(native_tc.get_meta("detectors") or "[]") assert json.loads(native_tc.get_meta("observables") or "[]") - noise = NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0) + noise = NoiseParameters(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0) for decompose_errors in (False, True): dem_text = generate_circuit_level_dem_from_builder( patch, @@ -1154,7 +1892,7 @@ def test_constrained_from_guppy_dem_is_consumable_by_pecos_native_decoder() -> N # Each shot's syndrome covers exactly the DEM's detectors. assert len(batch.get_syndrome(0)) == dem.num_detectors # The observable mask fits within ``num_observables`` bits (no stray bits). - assert batch.get_observable_mask(0) >> dem.num_observables == 0 + assert batch.get_observable_flips(0).mask >> dem.num_observables == 0 # PECOS-native Rust-backed matching decoder: DEM is consumable by # the actual downstream decoder surface. @@ -1501,3 +2239,264 @@ def test_surface_module_cache_collapses_unconstrained_budget_forms() -> None: # A genuinely-constrained budget is a separate cache entry. assert constrained is not unconstrained_none assert constrained["ancilla_budget"] == 2 + + +def test_noise_channel_residual_warning_names_kinds_and_magnitudes() -> None: + """Approximated idle and gate channels warn; exact channels stay silent. + + The residual is queryable on the DEM, but a field alone is easy to miss, so the + build also warns when it emits the non-negative boundary fit. + """ + from pecos.qec.dem import _warn_on_noise_channel_residuals + + class _Exact: + idle_noise_residuals: ClassVar[list[dict[str, object]]] = [] + + class _Approximated: + idle_noise_residuals: ClassVar[list[dict[str, object]]] = [ + { + "location_index": 3, + "channel_kind": "idle", + "magnitude": 1.894e-05, + "channel_weight": 0.01, + "relative_magnitude": 1.894e-03, + }, + { + "location_index": 7, + "channel_kind": "one-qubit gate", + "magnitude": 2.1e-05, + "channel_weight": 0.1, + "relative_magnitude": 2.1e-04, + }, + ] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_noise_channel_residuals(_Exact()) + assert caught == [] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_noise_channel_residuals(_Approximated()) + assert len(caught) == 1 + message = str(caught[0].message) + assert "2 categorical noise channel(s) were approximated" in message + assert "1 idle (largest relative 1.894e-03; largest TV 1.894e-05)" in message + assert "1 one-qubit gate (largest relative 2.100e-04; largest TV 2.100e-05)" in message + assert "2.100e-05" in message + assert "fractions of each requested channel's total error weight" in message + assert "total-variation distances" in message + assert "dem.idle_noise_residuals" in message + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_noise_channel_residuals(_Approximated(), 0.001) + assert len(caught) == 1 + message = str(caught[0].message) + assert "1 categorical noise channel(s) were approximated" in message + assert "1 idle" in message + assert "one-qubit gate" not in message + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _warn_on_noise_channel_residuals(_Approximated(), 1.894e-03) + assert caught == [] + + +def _approximated_gate_build( + *, + p2: float = 0.007, + p2_weights: dict[str, float] | None = None, + residual_warning_threshold: float | None = None, +): + weights = {"IX": 0.4, "XI": 0.6} if p2_weights is None else p2_weights + builder = ( + DetectorErrorModel.builder() + .with_program(_structured_idle_noise_target) + .with_qubits(2) + .with_detectors([Detector(rec[-2])]) + .with_observables([Observable(rec[-1])]) + .with_noise( + NoiseParameters( + p1=0.0, + p2=p2, + p2_weights=weights, + p_meas=0.0, + p_prep=0.0, + ), + ) + ) + if residual_warning_threshold is not None: + builder.with_residual_warning_threshold(residual_warning_threshold) + return builder.build() + + +def test_residual_warning_threshold_defaults_to_zero() -> None: + with pytest.warns(UserWarning, match="1 categorical noise channel"): + build = _approximated_gate_build() + + assert len(build.dem.idle_noise_residuals) == 1 + + +def test_residual_warning_threshold_above_relative_magnitude_is_quiet() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + build = _approximated_gate_build(residual_warning_threshold=0.002) + + assert build.dem.idle_noise_residuals[0]["relative_magnitude"] < 0.002 + + +def test_residual_warning_threshold_below_relative_magnitude_still_warns() -> None: + with pytest.warns(UserWarning, match=r"largest relative 1\.692e-03"): + build = _approximated_gate_build(residual_warning_threshold=0.001) + + assert build.dem.idle_noise_residuals[0]["relative_magnitude"] > 0.001 + + +def test_residual_warning_threshold_never_filters_residual_data() -> None: + with pytest.warns(UserWarning, match="1 categorical noise channel"): + default_build = _approximated_gate_build() + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + tolerant_build = _approximated_gate_build(residual_warning_threshold=0.002) + + default_residuals = default_build.dem.idle_noise_residuals + tolerant_residuals = tolerant_build.dem.idle_noise_residuals + default_audit_residuals = default_build.audit["idle_noise_residuals"] + tolerant_audit_residuals = tolerant_build.audit["idle_noise_residuals"] + + assert len(default_residuals) == 1 + assert tolerant_residuals == default_residuals + assert tolerant_audit_residuals == default_audit_residuals + assert tolerant_audit_residuals == tolerant_residuals + assert default_audit_residuals == default_residuals + + def encode(residuals: list[dict[str, object]]) -> bytes: + return json.dumps( + residuals, + sort_keys=True, + separators=(",", ":"), + ).encode() + + assert encode(tolerant_residuals) == encode(default_residuals) + assert encode(tolerant_audit_residuals) == encode(default_audit_residuals) + + +def test_relative_residual_threshold_is_portable_across_channel_weights() -> None: + target_relative_magnitude = 0.0002502503129381573 + configurations = [ + (0.001, {"IX": 0.5, "XI": 0.5}), + (0.1, {"IX": 0.002257285529184556, "XI": 0.9977427144708154}), + ] + observed_weights = [] + observed_relative_magnitudes = [] + + for p2, p2_weights in configurations: + with pytest.warns(UserWarning, match=r"largest relative 2\.503e-04"): + warned_build = _approximated_gate_build( + p2=p2, + p2_weights=p2_weights, + residual_warning_threshold=0.0002, + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + quiet_build = _approximated_gate_build( + p2=p2, + p2_weights=p2_weights, + residual_warning_threshold=0.0003, + ) + + assert quiet_build.dem.idle_noise_residuals == warned_build.dem.idle_noise_residuals + residual = quiet_build.dem.idle_noise_residuals[0] + observed_weights.append(residual["channel_weight"]) + observed_relative_magnitudes.append(residual["relative_magnitude"]) + + assert observed_weights == pytest.approx([0.001, 0.1]) + assert observed_relative_magnitudes == pytest.approx( + [target_relative_magnitude, target_relative_magnitude], + abs=1e-15, + ) + + +@pytest.mark.parametrize("fraction", [-0.1, float("nan"), float("inf"), float("-inf")]) +def test_residual_warning_threshold_rejects_invalid_fraction(fraction: float) -> None: + with pytest.raises(ValueError, match="fraction of the channel's total error weight"): + DetectorErrorModel.builder().with_residual_warning_threshold(fraction) + + +def test_residual_warning_threshold_rejects_values_above_one_as_absolute() -> None: + with pytest.raises(ValueError, match="not an absolute probability") as exc_info: + DetectorErrorModel.builder().with_residual_warning_threshold(1.01) + + assert "fraction of the channel's total error weight" in str(exc_info.value) + + +@guppy +def _two_qubit_gate_channel_program() -> None: + """One CX with a detector on each measurement, for gate-channel conversion checks.""" + a, b = qubit(), qubit() + cx(a, b) + result("m0", measure(a)) + result("m1", measure(b)) + + +def test_two_qubit_gate_channel_is_converted_not_emitted_naively() -> None: + """The p2 channel is mutually exclusive, so its DEM mechanisms need conversion. + + Fifteen two-qubit Paulis land on three distinct flip signatures here: the three + Z-type Paulis are invisible to Z-basis measurement and drop out, and the other + twelve merge four-to-one. Within a group the probabilities ADD (the channel picks + one Pauli), giving 4 * p2/15 = 5.333e-3 per signature. Emitting that directly would + be wrong, because independent mechanisms also fire together; the converted value is + 5.362e-3, computed independently from the Pauli-channel characters. + """ + build = ( + DetectorErrorModel.builder() + .with_program(_two_qubit_gate_channel_program) + .with_qubits(2) + .with_detectors([Detector("m0")]) + .with_observables([Observable("m1")]) + .with_noise(NoiseParameters().with_p2(0.02)) + .build() + ) + text = build.dem.to_string() + + # The converted probability, not the summed-but-unconverted 0.005333. + assert text.count("error(0.005362)") == 3, text + assert "0.005333" not in text, text + + # Fifteen Paulis, three surviving signatures: the Z-type ones are undetectable. + assert text.count("error(") == 3, text + + # An exactly representable channel takes no approximation. + assert build.dem.idle_noise_residuals == [] + + +@guppy +def _prep_and_measure_program() -> None: + """Prepare and measure one qubit, for the prep/measurement exactness check.""" + q = qubit() + result("m0", measure(q)) + + +def test_prep_and_measurement_channels_stay_exact() -> None: + """Prep and measurement are single Bernoulli events, so they need no conversion. + + Each emits one Pauli at the full probability rather than a set of mutually + exclusive ones, so there is nothing to compose and nothing to approximate. This + pins that the gate/idle conversion work did not sweep them in. + """ + for setter, probability in (("with_p_prep", 0.02), ("with_p_meas", 0.02)): + noise = getattr(NoiseParameters(), setter)(probability) + build = ( + DetectorErrorModel.builder() + .with_program(_prep_and_measure_program) + .with_qubits(1) + .with_detectors([Detector("m0")]) + .with_observables([]) + .with_noise(noise) + .build() + ) + text = build.dem.to_string() + assert f"error({probability})" in text, f"{setter}: {text}" + assert build.dem.idle_noise_residuals == [], setter diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_build.py b/python/quantum-pecos/tests/qec/test_guppy_dem_build.py index 050378d7d..b562fc794 100644 --- a/python/quantum-pecos/tests/qec/test_guppy_dem_build.py +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_build.py @@ -26,7 +26,7 @@ surface_memory_dem_spec, ) from pecos.qec.dem import _generator_certified_result_traces -from pecos.qec.dem_spec import GuppyDemBuild, _resolve_dem_specs +from pecos.qec.dem_spec import GuppyDemBuild, RecordRef, ResultRef, _resolve_dem_specs from pecos_rslib.quantum import TickCircuit @@ -132,6 +132,44 @@ def test_real_guppy_rec_and_result_ref_builds_are_byte_identical() -> None: assert via_records.dem.to_string() == via_results.dem.to_string() +def test_bare_tag_strings_are_shorthand_for_result_ref() -> None: + noise = {"p1": 0.01, "p2": 0.02, "p_meas": 0.1, "p_prep": 0.0} + via_result_ref = build_dem_from_guppy( + _scrambled_tagged_measurements, + num_qubits=2, + detectors=[Detector(result_ref("a"))], + observables=[Observable(result_ref("b"))], + **noise, + ) + via_strings = build_dem_from_guppy( + _scrambled_tagged_measurements, + num_qubits=2, + detectors=[Detector("a")], + observables=[Observable("b")], + **noise, + ) + + assert via_strings.detectors_json == via_result_ref.detectors_json + assert via_strings.observables_json == via_result_ref.observables_json + assert via_strings.schema_fingerprint == via_result_ref.schema_fingerprint + assert via_strings.dem.to_string() == via_result_ref.dem.to_string() + + +def test_tag_strings_mix_with_rec_and_result_ref_refs() -> None: + detector = Detector("a", result_ref("b"), rec[-1]) + + assert detector.refs == (ResultRef("a"), ResultRef("b"), RecordRef(-1)) + + +def test_tag_string_shorthand_rejects_empty_and_wrong_types() -> None: + with pytest.raises(ValueError, match="non-empty string"): + Detector("") + with pytest.raises(TypeError, match="measurement references must be"): + Detector(3.5) + with pytest.raises(ValueError, match="at least one measurement"): + Observable() + + def test_trace_once_build_evaluates_runtime_and_rejects_uncertified_named_results( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py new file mode 100644 index 000000000..9359932ed --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_guppy_dem_builder.py @@ -0,0 +1,294 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Contract tests for the unified Guppy detector-error-model builder.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pecos +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit +from pecos.qec import ( + Detector, + DetectorErrorModel, + GuppyDemBuild, + GuppyDemBuilder, + Observable, + build_dem_from_guppy, + rec, +) +from pecos.qec.surface import NoiseParameters +from pecos_rslib.quantum import TickCircuit + +if TYPE_CHECKING: + from collections.abc import Callable + + +@guppy +def _tagged_two_qubit_program() -> None: + q0 = qubit() + q1 = qubit() + cx(q0, q1) + result("m0", measure(q0)) + result("m1", measure(q1)) + + +_DETECTORS_JSON = '[{"id":0,"records":[-2]}]' +_OBSERVABLES_JSON = '[{"id":0,"records":[-1]}]' + + +def test_builder_matches_both_wrappers_with_noise_and_inserted_idles() -> None: + noise = NoiseParameters(p1=0.0, p2=0.01, p_meas=0.02, p_prep=0.0).with_p_idle_linear( + 0.03, + {"Z": 1.0}, + ) + via_json_builder = ( + DetectorErrorModel.builder() + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors_json(_DETECTORS_JSON) + .with_observables_json(_OBSERVABLES_JSON) + .with_noise(noise) + .with_idle_after_2q(1.0) + .build() + ) + via_from_guppy = DetectorErrorModel.from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors_json=_DETECTORS_JSON, + observables_json=_OBSERVABLES_JSON, + noise=noise, + idle_after_2q_duration=1.0, + ) + via_typed_builder = ( + DetectorErrorModel.builder() + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector(rec[-2])]) + .with_observables([Observable(rec[-1])]) + .with_noise(noise) + .with_idle_after_2q(1.0) + .build() + ) + via_typed_wrapper = build_dem_from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors=[Detector(rec[-2])], + observables=[Observable(rec[-1])], + noise=noise, + idle_after_2q_duration=1.0, + ) + + expected = via_from_guppy.to_string() + assert isinstance(via_json_builder, GuppyDemBuild) + assert isinstance(DetectorErrorModel.builder(), GuppyDemBuilder) + assert via_json_builder.dem.to_string() == expected + assert via_typed_builder.dem.to_string() == expected + assert via_typed_wrapper.dem.to_string() == expected + + +def test_builder_matches_both_wrappers_with_result_tags() -> None: + detectors_json = '[{"id":0,"result_tags":["m0"]}]' + observables_json = '[{"id":0,"result_tags":["m1"]}]' + noise = NoiseParameters(p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) + via_json_builder = ( + DetectorErrorModel.builder() + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors_json(detectors_json) + .with_observables_json(observables_json) + .with_noise(noise) + .build() + ) + via_from_guppy = DetectorErrorModel.from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors_json=detectors_json, + observables_json=observables_json, + noise=noise, + ) + via_typed_builder = ( + DetectorErrorModel.builder() + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector("m0")]) + .with_observables([Observable("m1")]) + .with_noise(noise) + .build() + ) + via_typed_wrapper = build_dem_from_guppy( + _tagged_two_qubit_program, + num_qubits=2, + detectors=[Detector("m0")], + observables=[Observable("m1")], + noise=noise, + ) + + expected = via_from_guppy.to_string() + assert via_json_builder.dem.to_string() == expected + assert via_typed_builder.dem.to_string() == expected + assert via_typed_wrapper.dem.to_string() == expected + + +@pytest.mark.parametrize( + ("configure", "missing"), + [ + (lambda builder: builder.with_qubits(2).with_detectors_json(_DETECTORS_JSON), "with_program"), + ( + lambda builder: builder.with_program(_tagged_two_qubit_program).with_detectors_json(_DETECTORS_JSON), + "with_qubits", + ), + (lambda builder: builder.with_program(_tagged_two_qubit_program).with_qubits(2), "with_detectors"), + ], +) +def test_builder_reports_missing_required_setters( + configure: Callable[[GuppyDemBuilder], GuppyDemBuilder], + missing: str, +) -> None: + with pytest.raises(ValueError, match=missing): + configure(DetectorErrorModel.builder()).build() + + +@pytest.mark.parametrize( + ("setter", "value"), + [ + ("with_program", _tagged_two_qubit_program), + ("with_qubits", 2), + ("with_detectors", [Detector(rec[-1])]), + ("with_observables", [Observable(rec[-1])]), + ("with_detectors_json", _DETECTORS_JSON), + ("with_observables_json", _OBSERVABLES_JSON), + ("with_num_measurements", 2), + ("with_noise", NoiseParameters()), + ("with_idle_after_2q", 1.0), + ("with_strip_traced_idles", True), + ("with_runtime", None), + ("with_seed", 7), + ("with_require_hosted_operation_order", True), + ("with_max_hosted_tick_separation", 3), + ], +) +def test_every_setter_rejects_a_second_call(setter: str, value: Any) -> None: + builder = DetectorErrorModel.builder() + getattr(builder, setter)(value) + + with pytest.raises(ValueError, match=setter): + getattr(builder, setter)(value) + + +@pytest.mark.parametrize( + ("first", "second"), + [ + ("with_detectors", "with_detectors_json"), + ("with_detectors_json", "with_detectors"), + ("with_observables", "with_observables_json"), + ("with_observables_json", "with_observables"), + ], +) +def test_typed_and_json_spellings_for_one_role_conflict(first: str, second: str) -> None: + values = { + "with_detectors": [Detector(rec[-1])], + "with_detectors_json": _DETECTORS_JSON, + "with_observables": [Observable(rec[-1])], + "with_observables_json": _OBSERVABLES_JSON, + } + builder = DetectorErrorModel.builder() + getattr(builder, first)(values[first]) + + with pytest.raises(ValueError, match="cannot be combined"): + getattr(builder, second)(values[second]) + + +@pytest.mark.parametrize("typed_setter", ["with_detectors", "with_observables"]) +@pytest.mark.parametrize("typed_first", [False, True]) +def test_num_measurements_conflicts_with_typed_specs(typed_setter: str, typed_first: bool) -> None: + specs = [Detector(rec[-1])] if typed_setter == "with_detectors" else [Observable(rec[-1])] + builder = DetectorErrorModel.builder() + + def combine_typed_specs_and_measurement_count() -> None: + if typed_first: + getattr(builder, typed_setter)(specs).with_num_measurements(1) + else: + getattr(builder.with_num_measurements(1), typed_setter)(specs) + + with pytest.raises(ValueError, match="with_num_measurements"): + combine_typed_specs_and_measurement_count() + + +def test_builder_result_evaluates_simulation_result_columns() -> None: + build = ( + DetectorErrorModel.builder() + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector("m0")]) + .with_observables([Observable("m1")]) + .with_noise(NoiseParameters(p_meas=0.1)) + .build() + ) + columns = ( + pecos.sim(_tagged_two_qubit_program) + .classical(pecos.selene_engine()) + .quantum(pecos.stabilizer()) + .qubits(2) + .seed(7) + .run(3) + .to_shot_map() + .to_dict() + ) + + assert build.evaluate_result_columns(columns) == [([0], 0)] * 3 + + +def test_json_builder_audit_accepts_legacy_id_aliases() -> None: + build = ( + DetectorErrorModel.builder() + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors_json('[{"detector_id":"D0","records":[-2]}]') + .with_observables_json('[{"observable_id":"L0","records":[-1]}]') + .with_noise(NoiseParameters(p_meas=0.1)) + .build() + ) + + assert build.evaluate_measurements({0: 1, 1: 1}) == ([1], 1) + + +def test_builder_setter_order_does_not_change_the_dem() -> None: + noise = NoiseParameters(p1=0.01, p2=0.02, p_meas=0.03, p_prep=0.04) + first = ( + DetectorErrorModel.builder() + .with_program(_tagged_two_qubit_program) + .with_qubits(2) + .with_detectors([Detector(rec[-2])]) + .with_observables([Observable(rec[-1])]) + .with_noise(noise) + .with_seed(11) + .build() + ) + second = ( + DetectorErrorModel.builder() + .with_seed(11) + .with_observables([Observable(rec[-1])]) + .with_noise(noise) + .with_detectors([Detector(rec[-2])]) + .with_qubits(2) + .with_program(_tagged_two_qubit_program) + .build() + ) + + assert first.dem.to_string() == second.dem.to_string() + + +def test_builder_rejects_circuit_inputs_with_from_circuit_guidance() -> None: + with pytest.raises(ValueError, match="from_circuit"): + ( + DetectorErrorModel.builder() + .with_program(TickCircuit()) + .with_qubits(1) + .with_detectors_json(_DETECTORS_JSON) + .build() + ) diff --git a/python/quantum-pecos/tests/qec/test_guppy_output_dem.py b/python/quantum-pecos/tests/qec/test_guppy_output_dem.py new file mode 100644 index 000000000..6f88ea292 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_guppy_output_dem.py @@ -0,0 +1,122 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for parity annotations learned from Guppy result outputs.""" + +from __future__ import annotations + +import json + +import pytest +from guppylang import guppy +from guppylang.std.builtins import array, result +from guppylang.std.quantum import measure, qubit +from pecos.qec import infer_guppy_dem_annotations + + +@guppy +def _measure_three_into_array() -> array[bool, 3]: + q0 = qubit() + q1 = qubit() + q2 = qubit() + return array(measure(q0), measure(q1), measure(q2)) + + +@guppy +def _computed_parity_outputs() -> None: + measurements = _measure_three_into_array() + m0 = measurements[0] + m1 = measurements[1] + m2 = measurements[2] + result("DETECTOR", m0 ^ m1) + result("DETECTOR", m1 ^ m2) + result("raw measurements", measurements) + result("obs", m0 ^ m2) + + +@guppy +def _raw_results_incomplete() -> None: + q0 = qubit() + q1 = qubit() + m0 = measure(q0) + result("raw measurements", m0) + m1 = measure(q1) + result("DETECTOR", m0 ^ m1) + result("obs", m0) + + +@guppy +def _reordered_raw_array() -> None: + m0 = measure(qubit()) + m1 = measure(qubit()) + m2 = measure(qubit()) + result("DETECTOR", m2 ^ m0) + result("raw measurements", array(m2, m0, m1)) + result("obs", m1) + + +def test_infers_computed_detector_and_observable_parities_and_builds_dem() -> None: + inferred = infer_guppy_dem_annotations( + _computed_parity_outputs, + num_qubits=3, + probe_shots=64, + validation_rows=16, + seed=7, + require_raw_provenance=False, + ) + + assert inferred.raw_measurement_ids == (0, 1, 2) + assert inferred.detector_supports == ((0, 1), (1, 2)) + assert inferred.observable_supports == ((0, 2),) + assert inferred.observable_labels == (("obs", 0),) + assert inferred.raw_binding == "assumed_canonical_result_order" + assert json.loads(inferred.detectors_json) == [ + {"id": 0, "meas_ids": [0, 1], "inferred_from_result_tag": "DETECTOR"}, + {"id": 1, "meas_ids": [1, 2], "inferred_from_result_tag": "DETECTOR"}, + ] + + dem = inferred.build_dem(p1=0.0, p2=0.0, p_meas=0.1, p_prep=0.0) + assert dem.num_detectors == 2 + assert dem.num_observables == 1 + assert "D0" in dem.to_string() + + +def test_computed_array_provenance_is_correlated_to_qis_result_ids() -> None: + inferred = infer_guppy_dem_annotations( + _computed_parity_outputs, + num_qubits=3, + probe_shots=32, + provenance_shots=16, + validation_rows=8, + seed=7, + ) + + assert inferred.raw_measurement_ids == (0, 1, 2) + assert inferred.raw_binding == "probe_correlated_result_ids" + + +def test_correlated_provenance_preserves_reordered_raw_identity() -> None: + inferred = infer_guppy_dem_annotations( + _reordered_raw_array, + num_qubits=3, + probe_shots=32, + provenance_shots=16, + validation_rows=8, + seed=13, + ) + + assert inferred.raw_measurement_ids == (2, 0, 1) + assert inferred.detector_supports == ((2, 0),) + assert inferred.observable_supports == ((1,),) + assert inferred.raw_binding == "probe_correlated_result_ids" + + +def test_raw_tag_must_cover_canonical_qis_measurement_order() -> None: + with pytest.raises(ValueError, match="emits 1 values during provenance probing"): + infer_guppy_dem_annotations( + _raw_results_incomplete, + num_qubits=2, + probe_shots=32, + validation_rows=8, + seed=3, + ) diff --git a/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py b/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py new file mode 100644 index 000000000..3d8791962 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_noise_option_conflicts.py @@ -0,0 +1,81 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Base-idle-channel combinations must fail loud, not resolve silently (issue #426). + +`set_t1_t2` makes T1/T2 the base channel that shadows ``p_idle``, and +`set_idle_rz` zeroes ``p_idle`` and overwrites T1/T2. Each combination used to +discard a caller-supplied rate with no signal. +""" + +from __future__ import annotations + +import pytest +from pecos.qec import DemSampler, DetectorErrorModel +from pecos.quantum import TickCircuit + +_GATE_NOISE = {"p1": 0.001, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + + +def _circuit() -> TickCircuit: + circuit = TickCircuit() + circuit.tick().pz([0]) + circuit.tick().idle(1, [0]) + circuit.tick().mz_with_ids([0], [0]) + circuit.set_meta("num_measurements", "1") + circuit.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + return circuit + + +@pytest.mark.parametrize( + ("conflict", "message"), + [ + pytest.param( + {"p_idle": 0.01, "t1": 100.0, "t2": 50.0}, + "T1/T2 channel replaces", + id="p_idle-with-t1t2", + ), + pytest.param( + {"idle_rz": 0.01, "p_idle": 0.01}, + "coherent RZ conversion replaces", + id="idle_rz-with-p_idle", + ), + pytest.param( + {"idle_rz": 0.01, "t1": 100.0, "t2": 50.0}, + "overwrites the T1/T2 channel", + id="idle_rz-with-t1t2", + ), + ], +) +def test_from_circuit_rejects_shadowed_idle_channels(conflict: dict[str, float], message: str) -> None: + with pytest.raises(ValueError, match=message): + DetectorErrorModel.from_circuit(_circuit(), **conflict, **_GATE_NOISE) + + +def test_dem_sampler_shares_the_same_guard() -> None: + # The guard lives in the shared noise-option helper, so every ingest path + # is protected -- not only DetectorErrorModel.from_circuit. + with pytest.raises(ValueError, match="T1/T2 channel replaces"): + DemSampler.from_circuit(_circuit(), p_idle=0.01, t1=100.0, t2=50.0, **_GATE_NOISE) + + +@pytest.mark.parametrize( + "idle_noise", + [ + pytest.param({"p_idle": 0.01}, id="p_idle-alone"), + pytest.param({"t1": 100.0, "t2": 50.0}, id="t1t2-alone"), + pytest.param({"idle_rz": 0.01}, id="idle_rz-alone"), + ], +) +def test_each_base_idle_channel_alone_still_builds(idle_noise: dict[str, float]) -> None: + dem = DetectorErrorModel.from_circuit(_circuit(), **idle_noise, **_GATE_NOISE) + + assert dem.num_detectors == 1 diff --git a/python/quantum-pecos/tests/qec/test_observable_flips.py b/python/quantum-pecos/tests/qec/test_observable_flips.py new file mode 100644 index 000000000..980a4be95 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_observable_flips.py @@ -0,0 +1,259 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Uniform observable-flip values across decoder predictions and sampled truth.""" + +from __future__ import annotations + +import pytest +from pecos_rslib.decoders import ( + BpOsdBuilder, + BpOsdDecoder, + DemAwareDecoder, + ObservableFlips, + PyMatchingDecoder, + SparseMatrix, + TesseractDecoder, +) +from pecos_rslib.qec import ( + ObservableFlips as QecObservableFlips, +) +from pecos_rslib.qec import ( + SampleBatch, +) + +_ONE_OBSERVABLE_DEM = """detector D0 +detector D1 +logical_observable L0 +error(0.1) D0 +error(0.1) D1 L0 +""" + + +def _wide_dem(num_observables: int = 71) -> str: + lines = ["error(0.1) D0 L0", "error(0.1) D1 L70", "detector D0", "detector D1"] + lines += [f"logical_observable L{index}" for index in range(num_observables)] + return "\n".join(lines) + + +def test_same_observable_flips_type_is_exported_from_both_namespaces() -> None: + assert QecObservableFlips is ObservableFlips + + +def test_indexing_iteration_indices_mask_and_repr() -> None: + flips = ObservableFlips.from_mask(0b101, 3) + + assert len(flips) == 3 + assert [flips[index] for index in range(len(flips))] == [True, False, True] + assert flips[-1] is True + assert flips[-2] is False + assert flips[-3] is True + assert list(flips) == [True, False, True] + assert flips.indices() == [0, 2] + assert flips.mask == 0b101 + assert repr(flips) == "ObservableFlips(num_observables=3, mask=5)" + + for index in (3, -4): + with pytest.raises(IndexError) as error: + _ = flips[index] + assert str(index) in str(error.value) + assert "num_observables=3" in str(error.value) + + +def test_equality_requires_same_type_bits_and_length() -> None: + flips = ObservableFlips.from_mask(1, 2) + + assert flips == ObservableFlips.from_bits([True, False]) + assert flips != ObservableFlips.from_bits([False, True]) + assert flips != ObservableFlips.from_mask(1, 3) + assert flips.__eq__([True, False]) is NotImplemented + assert flips.__eq__(1) is NotImplemented + assert (flips == [True, False]) is False + assert (flips == 1) is False + + +def test_from_mask_rejects_high_bits_and_round_trips() -> None: + with pytest.raises(ValueError, match=rf"mask={1 << 5}.*num_observables=2"): + ObservableFlips.from_mask(1 << 5, 2) + + mask = (1 << 70) | (1 << 2) + flips = ObservableFlips.from_mask(mask, 71) + assert flips.mask == mask + assert flips[70] is True + assert flips[69] is False + + +def test_constructors_accept_integer_like_values() -> None: + """The accessors this type bridges from hand back ints, and masks arrive as NumPy scalars. + + Both constructors go through ``__index__``, so ``int``, ``bool`` and NumPy + integer scalars are all accepted on the same footing. + """ + numpy = pytest.importorskip("numpy") + + # Integer-oriented callers can still construct flips without first converting to bool. + assert ObservableFlips.from_bits([1, 0, 1]) == ObservableFlips.from_mask(0b101, 3) + assert ObservableFlips.from_bits([True, 0, 1]) == ObservableFlips.from_mask(0b101, 3) + assert ObservableFlips.from_bits(list(numpy.array([1, 0, 1]))) == ObservableFlips.from_mask(0b101, 3) + assert ObservableFlips.from_bits([numpy.True_, numpy.False_]) == ObservableFlips.from_mask(0b01, 2) + + assert ObservableFlips.from_mask(numpy.uint64(5), 3) == ObservableFlips.from_mask(5, 3) + assert ObservableFlips.from_mask(numpy.int64(5), 3) == ObservableFlips.from_mask(5, 3) + + +def test_constructors_reject_non_integers_and_non_bits() -> None: + # Truthiness is never used: a non-bit integer is an error, not something to coerce. + with pytest.raises(ValueError, match="bit at index 1 must be 0 or 1, got 2"): + ObservableFlips.from_bits([1, 2, 0]) + + # A missing __index__ is "not an integer", which Python reports as TypeError. + with pytest.raises(TypeError, match="cannot be interpreted as an integer"): + ObservableFlips.from_bits(["a"]) + with pytest.raises(TypeError, match="cannot be interpreted as an integer"): + ObservableFlips.from_mask("x", 3) + + with pytest.raises(ValueError, match="mask=-1 is negative"): + ObservableFlips.from_mask(-1, 3) + + +def test_from_bits_accepts_an_iterable_and_indices_agree_with_getitem() -> None: + bits = [False, True, False, True] + flips = ObservableFlips.from_bits(bit for bit in bits) + + assert list(flips) == bits + assert flips.indices() == [index for index in range(len(flips)) if flips[index]] + + +def test_sample_batch_observable_metadata_and_shot_bounds() -> None: + batch = SampleBatch([[0], [1]], [0, 3]) + + assert batch.num_shots == 2 + assert batch.num_observables == 2 + assert batch.get_observable_flips(1) == ObservableFlips.from_mask(3, 2) + with pytest.raises(IndexError, match=r"Shot index 2.*num_shots=2"): + batch.get_observable_flips(2) + + +def test_removed_members_are_absent_and_replacements_match_captured_values() -> None: + syndrome = [1, 1] + mwpm_result = PyMatchingDecoder.from_dem(_ONE_OBSERVABLE_DEM).decode_syndrome(syndrome) + tesseract_result = TesseractDecoder.from_dem(_ONE_OBSERVABLE_DEM).decode_syndrome(syndrome) + dem_aware_result = DemAwareDecoder.from_dem( + _ONE_OBSERVABLE_DEM, + decoder_type="bp_osd", + ).decode_syndrome(syndrome) + bp_result = BpOsdBuilder(SparseMatrix([[1]]), error_rate=0.1).build().decode_syndrome([0]) + batch = SampleBatch([[0], [1]], [0, 3]) + + removed_members = [ + (mwpm_result, "correction"), + (mwpm_result, "to_list"), + (bp_result, "to_list"), + (tesseract_result, "observables_mask"), + (tesseract_result, "observable_bits"), + (dem_aware_result, "observables_mask"), + (batch, "get_observable_mask"), + (batch, "get_observable_mask_wide"), + ] + for result, member in removed_members: + assert not hasattr(result, member), f"{type(result).__name__}.{member} still exists" + + replacements = { + "MwpmResult.correction": list(mwpm_result.observable_flips), + "MwpmResult.to_list()": list(mwpm_result.observable_flips), + "BpResult.to_list()": bp_result.decoding, + "TesseractResult.observables_mask": tesseract_result.observable_flips.mask, + "TesseractResult.observable_bits(1)": list(tesseract_result.observable_flips), + "DemAwareResult.observables_mask": dem_aware_result.observable_flips.mask, + "SampleBatch.get_observable_mask(1)": batch.get_observable_flips(1).mask, + "SampleBatch.get_observable_mask_wide(1)": batch.get_observable_flips(1).mask, + } + assert replacements == { + "MwpmResult.correction": [True], + "MwpmResult.to_list()": [True], + "BpResult.to_list()": [0], + "TesseractResult.observables_mask": 1, + "TesseractResult.observable_bits(1)": [True], + "DemAwareResult.observables_mask": 1, + "SampleBatch.get_observable_mask(1)": 3, + "SampleBatch.get_observable_mask_wide(1)": 3, + } + assert not hasattr(bp_result, "observable_flips") + assert bp_result.decoding == [0] + + +def test_uniform_loop_preserves_one_observable_error_counts() -> None: + syndromes = [[0, 0], [1, 0], [0, 1], [1, 1]] + batch = SampleBatch(syndromes, [0, 1, 0, 1]) + decoders = { + "pymatching": PyMatchingDecoder.from_dem(_ONE_OBSERVABLE_DEM), + "tesseract": TesseractDecoder.from_dem(_ONE_OBSERVABLE_DEM), + "bp_osd": BpOsdDecoder.from_dem(_ONE_OBSERVABLE_DEM), + } + error_counts = dict.fromkeys(decoders, 0) + + for shot in range(batch.num_shots): + syndrome = batch.get_syndrome(shot) + actual_flips = batch.get_observable_flips(shot) + results = {name: decoder.decode_syndrome(syndrome) for name, decoder in decoders.items()} + + for name, result in results.items(): + error_counts[name] += result.observable_flips != actual_flips + + assert error_counts == {"pymatching": 2, "tesseract": 2, "bp_osd": 2} + + +def test_any_observable_and_per_observable_counts_are_distinct() -> None: + batch = SampleBatch([[], []], [0, 3]) + predictions = [ObservableFlips.from_mask(1, 2), ObservableFlips.from_mask(1, 2)] + any_observable_errors = 0 + per_observable_errors = [0] * batch.num_observables + + for shot, predicted in enumerate(predictions): + actual = batch.get_observable_flips(shot) + any_observable_errors += predicted != actual + for index in range(batch.num_observables): + per_observable_errors[index] += predicted[index] != actual[index] + + assert any_observable_errors == 2 + assert per_observable_errors == [1, 1] + + +def test_wide_observables_are_not_truncated_end_to_end() -> None: + dem = _wide_dem() + syndrome = [0, 1] + batch = SampleBatch([syndrome], [1 << 70]) + actual = batch.get_observable_flips(0) + decoders = [ + PyMatchingDecoder.from_dem(dem), + DemAwareDecoder.from_dem(dem, decoder_type="bp_osd"), + ] + + assert batch.num_observables == 71 + assert actual.mask == 1 << 70 + assert actual[70] is True + assert actual[6] is False + for decoder in decoders: + predicted = decoder.decode_syndrome(syndrome).observable_flips + assert predicted == actual + assert len(predicted) == 71 + assert predicted.mask == 1 << 70 + assert predicted[70] is True + assert predicted[6] is False + + +def test_bp_result_does_not_fabricate_observable_flips() -> None: + decoder = BpOsdBuilder(SparseMatrix([[1]]), error_rate=0.1).build() + result = decoder.decode_syndrome([0]) + + assert not hasattr(result, "observable_flips") + assert result.decoding == [0] diff --git a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py index 44f228224..da58f8899 100644 --- a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -99,12 +99,12 @@ def test_surface_code_memory_accepts_traced_qis_runtime() -> None: def test_surface_decoder_accepts_traced_qis_runtime() -> None: - from pecos.qec.surface import NoiseModel, SurfaceDecoder, SurfacePatch + from pecos.qec.surface import NoiseParameters, SurfaceDecoder, SurfacePatch decoder = SurfaceDecoder( SurfacePatch.create(distance=3), num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), decoder_type="pymatching_uncorrelated", circuit_level_dem_source="traced_qis", runtime=_NON_DEFAULT_RUNTIME, @@ -114,12 +114,12 @@ def test_surface_decoder_accepts_traced_qis_runtime() -> None: def test_build_native_sampler_accepts_traced_qis_runtime() -> None: - from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface import NoiseParameters, SurfacePatch, build_native_sampler sampler = build_native_sampler( SurfacePatch.create(distance=3), num_rounds=1, - noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + noise=NoiseParameters(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), circuit_source="traced_qis", runtime=_NON_DEFAULT_RUNTIME, ) @@ -128,12 +128,12 @@ def test_build_native_sampler_accepts_traced_qis_runtime() -> None: def test_surface_code_memory_rejects_ambiguous_noise_inputs() -> None: - from pecos.qec.surface import NoiseModel, surface_code_memory + from pecos.qec.surface import NoiseParameters, surface_code_memory with pytest.raises(ValueError, match="either physical_error_rate or noise_model"): surface_code_memory( physical_error_rate=0.0, - noise_model=NoiseModel.uniform(0.001), + noise_model=NoiseParameters.uniform(0.001), shots=0, rounds=1, ) diff --git a/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py b/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py new file mode 100644 index 000000000..d52e832ed --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_record_vs_meas_id_semantics.py @@ -0,0 +1,106 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Characterize what ``records`` offsets mean relative to ``meas_ids``. + +The two spellings resolve through different code paths: ``records`` becomes an +absolute index into the measurement record, while ``meas_ids`` is looked up by +position in the influence map's stamped ids. They coincide whenever the +influence order matches the canonical order, which is the case for every +runtime available here. These tests pin that agreement so a change that makes +the two diverge -- a reordering runtime, or a change to either resolver -- +fails loudly instead of silently rebinding detectors. + +The builder's redundancy rule is the oracle: co-present ``records`` and +``meas_ids`` must resolve to the same measurement set, so an accepted pair +proves the two spellings name the same measurement. +""" + +from __future__ import annotations + +import pytest +from guppylang import guppy +from guppylang.std.builtins import result +from guppylang.std.quantum import cx, measure, qubit +from pecos.qec import DetectorErrorModel +from pecos.quantum import TickCircuit + +_NOISE = {"p1": 0.001, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + + +@guppy +def _five_measurement_program() -> None: + d0, d1, d2 = qubit(), qubit(), qubit() + a0, a1 = qubit(), qubit() + cx(d0, a0) + cx(d1, a0) + cx(d1, a1) + cx(d2, a1) + result("s0", measure(a0)) + result("s1", measure(a1)) + result("m0", measure(d0)) + result("m1", measure(d1)) + result("m2", measure(d2)) + + +def _stamped_circuit() -> TickCircuit: + # Stamped ids deliberately differ from execution position. + circuit = TickCircuit() + circuit.tick().pz([0, 1, 2]) + circuit.tick().mz_with_ids([2, 0, 1], [2, 0, 1]) + circuit.set_meta("num_measurements", "3") + return circuit + + +def _accepts(detectors_json: str, *, circuit: TickCircuit | None = None) -> bool: + """True when the builder accepts the co-present references as redundant.""" + if circuit is None: + try: + DetectorErrorModel.from_guppy( + _five_measurement_program, + num_qubits=5, + detectors_json=detectors_json, + **_NOISE, + ) + except ValueError: + return False + return True + circuit.set_meta("detectors", detectors_json) + try: + DetectorErrorModel.from_circuit(circuit, **_NOISE) + except ValueError: + return False + return True + + +@pytest.mark.parametrize("offset_from_end", range(1, 6)) +def test_traced_records_agree_with_meas_ids(offset_from_end: int) -> None: + num_measurements = 5 + expected_meas_id = num_measurements - offset_from_end + detectors = f'[{{"id":0,"records":[-{offset_from_end}],"meas_ids":[{expected_meas_id}]}}]' + + assert _accepts(detectors), f"records[-{offset_from_end}] should name meas_id {expected_meas_id}" + + +def test_traced_records_reject_every_other_meas_id() -> None: + # Guards against an accept-everything redundancy check. + mismatched = [ + meas_id + for meas_id in range(5) + if meas_id != 0 and _accepts(f'[{{"id":0,"records":[-5],"meas_ids":[{meas_id}]}}]') + ] + + assert mismatched == [] + + +def test_stamped_circuit_records_agree_with_meas_ids() -> None: + assert _accepts('[{"id":0,"records":[-3],"meas_ids":[0]}]', circuit=_stamped_circuit()) + assert not _accepts('[{"id":0,"records":[-3],"meas_ids":[2]}]', circuit=_stamped_circuit()) diff --git a/python/quantum-pecos/tests/qec/test_sample_batch.py b/python/quantum-pecos/tests/qec/test_sample_batch.py index 7edc66f0f..c0ee81867 100644 --- a/python/quantum-pecos/tests/qec/test_sample_batch.py +++ b/python/quantum-pecos/tests/qec/test_sample_batch.py @@ -13,10 +13,10 @@ def test_round_trip_get_syndrome(self): assert list(batch.get_syndrome(0)) == [1, 0] assert list(batch.get_syndrome(1)) == [0, 1] - def test_round_trip_get_observable_mask(self): + def test_round_trip_get_observable_flips(self): batch = SampleBatch([[1, 0], [0, 1]], [1, 0]) - assert batch.get_observable_mask(0) == 1 - assert batch.get_observable_mask(1) == 0 + assert batch.get_observable_flips(0).mask == 1 + assert batch.get_observable_flips(1).mask == 0 def test_num_shots(self): batch = SampleBatch([[0, 0], [1, 1], [0, 1]], [0, 0, 0]) @@ -92,7 +92,7 @@ def test_bulk_accessors_match_per_shot_accessors(self, d3_setup): assert len(observable_flips) == batch.num_shots for shot in range(batch.num_shots): assert detector_events[shot] == [bool(value) for value in batch.get_syndrome(shot)] - mask = batch.get_observable_mask(shot) + mask = batch.get_observable_flips(shot).mask assert observable_flips[shot] == [ bool(mask & (1 << observable)) for observable in range(sampler.num_observables) ] @@ -114,10 +114,10 @@ def test_get_syndrome_shape(self, d3_setup): syn = batch.get_syndrome(0) assert len(syn) == sampler.num_detectors - def test_get_observable_mask_type(self, d3_setup): + def test_get_observable_flips_mask_type(self, d3_setup): sampler, _ = d3_setup batch = sampler.sample_batch(10, seed=42) - mask = batch.get_observable_mask(0) + mask = batch.get_observable_flips(0).mask assert isinstance(mask, int) def test_decode_count(self, d3_setup): diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py index bdc4c2f0c..19a18ee42 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py @@ -104,7 +104,7 @@ def _decode_native_dem_samples(circuit, noise_args, matching, shots, seed): syndrome[det_index] = sampled_syndrome[det_index] predicted = matching.decode(syndrome) predicted_mask = sum(int(bit) << index for index, bit in enumerate(predicted)) - errors += predicted_mask != batch.get_observable_mask(shot_index) + errors += predicted_mask != batch.get_observable_flips(shot_index).mask return errors diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index 5cd4bb791..72c6b34d6 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -97,23 +97,19 @@ def test_observable_flips_matches_wide_per_shot_masks() -> None: observable_flips = batch.observable_flips() assert all(len(row) == n for row in observable_flips) for shot, row in enumerate(observable_flips): - mask = batch.get_observable_mask_wide(shot) + mask = batch.get_observable_flips(shot).mask assert row == [bool(mask & (1 << observable)) for observable in range(n)] -def test_u64_observable_getter_rejects_wide_batch() -> None: - # get_observable_mask returns a u64 and cannot represent observable >= 64, so - # it rejects a wide batch; get_observable_mask_wide returns the full Python - # int. (The decode methods, by contrast, compare wide ObsMasks and do not - # reject -- see below.) +def test_observable_flips_mask_supports_the_formerly_rejected_wide_batch() -> None: + # The removed u64 getter rejected this batch. ObservableFlips carries the + # full arbitrary-precision Python mask instead. n = 65 _dem, _ = _wide_dem(n) syn = [0] * n wide = SampleBatch([syn, syn], [1 << 64, 1 << 64]) - with pytest.raises(ValueError, match="64-observable"): - wide.get_observable_mask(0) - assert wide.get_observable_mask_wide(0) == 1 << 64 + assert wide.get_observable_flips(0).mask == 1 << 64 def test_sample_batch_decode_count_batch_handles_wide_dem() -> None: diff --git a/scripts/compare_meas_sampling_pipeline.py b/scripts/compare_meas_sampling_pipeline.py index 7cac295b3..f1501f649 100644 --- a/scripts/compare_meas_sampling_pipeline.py +++ b/scripts/compare_meas_sampling_pipeline.py @@ -133,7 +133,7 @@ def run_native_sampler(tc, noise_args, matching, shots, seed): predicted = matching.decode(syndrome) pred_mask = sum(int(v) << j for j, v in enumerate(predicted)) - if pred_mask != batch.get_observable_mask(i): + if pred_mask != batch.get_observable_flips(i).mask: errors += 1 t_decode = time.perf_counter() - t0 diff --git a/scripts/docs/generate_doc_tests.py b/scripts/docs/generate_doc_tests.py index 594f29116..79f1291f8 100755 --- a/scripts/docs/generate_doc_tests.py +++ b/scripts/docs/generate_doc_tests.py @@ -332,6 +332,7 @@ def extract_code_blocks(file_path: Path, language: str = "python") -> list[CodeB blocks = [] preamble_parts: list[str] = [] + chain_parts: list[str] = [] setup_code = "" block_number = 0 @@ -378,19 +379,29 @@ def extract_code_blocks(file_path: Path, language: str = "python") -> list[CodeB # Regular visible block block_number += 1 + # Each generated test runs in a fresh interpreter, so a continuation + # block carries state by re-executing the visible blocks before it. A + # block without the marker starts a new chain. + if attrs["is_continuation"] and chain_parts: + body = "\n\n".join([*chain_parts, cleaned_code]) + else: + body = cleaned_code + chain_parts = [] + chain_parts.append(cleaned_code) + # Build full code with preamble if preamble_parts: preamble = "\n\n".join(preamble_parts) # Check for placeholder pattern: // CODE or /* CODE */ if "// CODE" in preamble: - full_code = preamble.replace("// CODE", cleaned_code) + full_code = preamble.replace("// CODE", body) elif "/* CODE */" in preamble: - full_code = preamble.replace("/* CODE */", cleaned_code) + full_code = preamble.replace("/* CODE */", body) else: # Default: append code after preamble - full_code = preamble + "\n\n" + cleaned_code + full_code = preamble + "\n\n" + body else: - full_code = cleaned_code + full_code = body # Add setup code if present if setup_code: