From eafecddbe9619db25ba00a140fd365dd91d9235a Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 24 Jul 2026 10:11:41 +0200 Subject: [PATCH 01/11] Add leakage tracking and a leakage channel --- crates/ppvm-tableau/src/data.rs | 51 ++++-- crates/ppvm-tableau/src/gates/clifford.rs | 22 +-- crates/ppvm-tableau/src/gates/reset.rs | 7 + crates/ppvm-tableau/src/gates/rot1.rs | 2 +- crates/ppvm-tableau/src/gates/rot2.rs | 6 +- crates/ppvm-tableau/src/gates/tgate.rs | 4 +- crates/ppvm-tableau/src/noise.rs | 190 +++++++++++++++++++++- crates/ppvm-traits/src/traits/mod.rs | 2 +- crates/ppvm-traits/src/traits/noise.rs | 4 + 9 files changed, 258 insertions(+), 30 deletions(-) diff --git a/crates/ppvm-tableau/src/data.rs b/crates/ppvm-tableau/src/data.rs index 7b8d008db..203e98c2c 100644 --- a/crates/ppvm-tableau/src/data.rs +++ b/crates/ppvm-tableau/src/data.rs @@ -641,6 +641,10 @@ pub struct GeneralizedTableau< pub coefficients: SparseVectorType, /// Per-qubit loss flags. pub is_lost: Vec, + /// Per-qubit leakage flags. A leaked qubit has been pinned to a + /// computational basis state (`|0⟩`/`|1⟩`) in the tableau, so gates skip it + /// and measurement reports the pinned value directly. + pub is_leaked: Vec, /// Coefficient-magnitude threshold below which branches are dropped. pub coefficient_threshold: T::Coeff, /// Ordered log of every measurement performed (mirrors stim's record). @@ -648,6 +652,15 @@ pub struct GeneralizedTableau< _index_phantom: PhantomData, } +impl, I>> GeneralizedTableau { + /// Whether qubit `addr0` is outside the computational subspace — either lost + /// or leaked. Gates skip such qubits. + #[inline] + pub fn is_lost_or_leaked(&self, addr0: usize) -> bool { + self.is_leaked[addr0] || self.is_lost[addr0] + } +} + impl, I>> GeneralizedTableau where T::Coeff: One + Zero + Clone + num::Num, @@ -673,6 +686,7 @@ where tableau: Tableau::new(n_qubits), coefficients, is_lost: vec![false; n_qubits], + is_leaked: vec![false; n_qubits], coefficient_threshold, measurement_record: Vec::new(), _index_phantom: PhantomData, @@ -697,7 +711,10 @@ where coefficients.unsafe_insert(I::zero(), complex_one); self.coefficients = coefficients; for l in self.is_lost.iter_mut() { - *l &= false; + *l = false; + } + for l in self.is_leaked.iter_mut() { + *l = false; } self.measurement_record.clear(); } @@ -743,15 +760,15 @@ where } /// Apply CZ to N pairs with constant offset: (base+i, base+offset+i) for i in 0..count. - /// Falls back to individual CZ calls if any qubit in the range is lost. + /// Falls back to individual CZ calls if any qubit in the range is lost or leaked. pub fn cz_block_pairs(&mut self, base: usize, offset: usize, count: usize) where <::Store as TryFrom>::Error: Debug, ::Store: PrimInt + TryFrom, { - // Check if any qubit in the range is lost - let any_lost = - (0..count).any(|i| self.is_lost[base + i] || self.is_lost[base + offset + i]); + // Check if any qubit in the range is lost or leaked + let any_lost = (0..count) + .any(|i| self.is_lost_or_leaked(base + i) || self.is_lost_or_leaked(base + offset + i)); if !any_lost { self.tableau.cz_block_pairs(base, offset, count); } else { @@ -759,7 +776,7 @@ where for i in 0..count { let c = base + i; let t = base + offset + i; - if !self.is_lost[c] && !self.is_lost[t] { + if !self.is_lost_or_leaked(c) && !self.is_lost_or_leaked(t) { Clifford::cz(&mut self.tableau, c, t); } } @@ -767,7 +784,7 @@ where } /// Apply CZ to N cross-word pairs. Controls at word_c, targets at word_t. - /// Falls back to individual CZ calls if any qubit is lost. + /// Falls back to individual CZ calls if any qubit is lost or leaked. pub fn cz_block_pairs_cross_word( &mut self, word_c: usize, @@ -782,7 +799,7 @@ where let any_lost = (0..count).any(|i| { let c = word_c * bits_per_word + base_bit_c + i; let t = word_t * bits_per_word + base_bit_t + i; - self.is_lost[c] || self.is_lost[t] + self.is_lost_or_leaked(c) || self.is_lost_or_leaked(t) }); if !any_lost { self.tableau @@ -791,7 +808,7 @@ where for i in 0..count { let c = word_c * bits_per_word + base_bit_c + i; let t = word_t * bits_per_word + base_bit_t + i; - if !self.is_lost[c] && !self.is_lost[t] { + if !self.is_lost_or_leaked(c) && !self.is_lost_or_leaked(t) { Clifford::cz(&mut self.tableau, c, t); } } @@ -1012,7 +1029,7 @@ where ) where <::Storage as BitView>::Store: PrimInt, { - if self.is_lost[addr0] { + if self.is_lost_or_leaked(addr0) { return; } @@ -1215,7 +1232,7 @@ where ) where <::Storage as BitView>::Store: PrimInt, { - if self.is_lost[addr0] { + if self.is_lost_or_leaked(addr0) { return; } @@ -1607,6 +1624,18 @@ mod tests { assert!(tab.is_lost.iter().all(|&lost| !lost)); } + /// A full reset clears per-qubit leakage flags. + #[test] + fn reset_all_clears_leak_flags() { + let mut tab: TestTableau = GeneralizedTableau::new(3, 1e-12); + tab.is_leaked[0] = true; + tab.is_leaked[2] = true; + + tab.reset_all(); + + assert!(tab.is_leaked.iter().all(|&leaked| !leaked)); + } + /// `Tableau::reset_all` restores the fresh identity tableau rows. #[test] fn tableau_reset_all_restores_fresh_rows() { diff --git a/crates/ppvm-tableau/src/gates/clifford.rs b/crates/ppvm-tableau/src/gates/clifford.rs index 60fe5345d..854728d6a 100644 --- a/crates/ppvm-tableau/src/gates/clifford.rs +++ b/crates/ppvm-tableau/src/gates/clifford.rs @@ -12,12 +12,12 @@ use smallvec::{SmallVec, smallvec}; /// Stack-allocates for up to 8 storage words; spills to heap beyond. type MaskBuf = SmallVec<[<::Storage as BitView>::Store; 8]>; -// Single-qubit gate on a `GeneralizedTableau`: skip lost qubits, delegate to -// the inner tableau's canonical (word-level) method. +// Single-qubit gate on a `GeneralizedTableau`: skip lost/leaked qubits, delegate +// to the inner tableau's canonical (word-level) method. macro_rules! impl_generalized_tableau_clifford { ($name:ident) => { fn $name(&mut self, index: usize) { - if self.is_lost[index] { + if self.is_lost_or_leaked(index) { return; } self.tableau.$name(index); @@ -25,11 +25,11 @@ macro_rules! impl_generalized_tableau_clifford { }; } -// Two-qubit gate on a `GeneralizedTableau`: skip pairs with a lost qubit. +// Two-qubit gate on a `GeneralizedTableau`: skip pairs with a lost/leaked qubit. macro_rules! impl_generalized_tableau_clifford_pair { ($name:ident) => { fn $name(&mut self, control: usize, target: usize) { - if self.is_lost[control] || self.is_lost[target] { + if self.is_lost_or_leaked(control) || self.is_lost_or_leaked(target) { return; } self.tableau.$name(control, target); @@ -752,18 +752,18 @@ where Complex<::Coeff>: From>, ::Store: PrimInt, { - /// Fast path: check if any qubit in the slice is lost + /// Fast path: check if any qubit in the slice is lost or leaked #[inline] fn any_lost_single(&self, indices: &[usize]) -> bool { - indices.iter().any(|&i| self.is_lost[i]) + indices.iter().any(|&i| self.is_lost_or_leaked(i)) } - /// Fast path: check if any qubit pair has a lost qubit + /// Fast path: check if any qubit pair has a lost or leaked qubit #[inline] fn any_lost_pair(&self, pairs: &[(usize, usize)]) -> bool { pairs .iter() - .any(|&(c, t)| self.is_lost[c] || self.is_lost[t]) + .any(|&(c, t)| self.is_lost_or_leaked(c) || self.is_lost_or_leaked(t)) } } @@ -777,7 +777,7 @@ macro_rules! impl_gen_tableau_batch_single { let filtered: Vec = indices .iter() .copied() - .filter(|&i| !self.is_lost[i]) + .filter(|&i| !self.is_lost_or_leaked(i)) .collect(); self.tableau.$name(&filtered); } @@ -794,7 +794,7 @@ macro_rules! impl_gen_tableau_batch_pair { let filtered: Vec<(usize, usize)> = pairs .iter() .copied() - .filter(|&(c, t)| !self.is_lost[c] && !self.is_lost[t]) + .filter(|&(c, t)| !self.is_lost_or_leaked(c) && !self.is_lost_or_leaked(t)) .collect(); self.tableau.$name(&filtered); } diff --git a/crates/ppvm-tableau/src/gates/reset.rs b/crates/ppvm-tableau/src/gates/reset.rs index 4ad579c92..44dedec84 100644 --- a/crates/ppvm-tableau/src/gates/reset.rs +++ b/crates/ppvm-tableau/src/gates/reset.rs @@ -45,6 +45,13 @@ where + Copy, { fn reset(&mut self, addr0: usize) { + // Skip qubits outside the computational subspace. Currently a no-op for + // loss (the `x` below is already skipped and `measure` returns `None`), + // but leaked qubits must not be re-zeroed, and this short-cuts both. + if self.is_lost_or_leaked(addr0) { + return; + } + let m = self.measure(addr0); // A reset is not a measurement in stim's model: drop the record diff --git a/crates/ppvm-tableau/src/gates/rot1.rs b/crates/ppvm-tableau/src/gates/rot1.rs index 3ee2cd978..a53f68c0f 100644 --- a/crates/ppvm-tableau/src/gates/rot1.rs +++ b/crates/ppvm-tableau/src/gates/rot1.rs @@ -23,7 +23,7 @@ where + Copy, { fn rotate_1(&mut self, axis: Pauli, addr0: usize, theta: ::Coeff) { - if self.is_lost[addr0] { + if self.is_lost_or_leaked(addr0) { return; } let (sin, cos) = (theta * 0.5.into()).sin_cos(); diff --git a/crates/ppvm-tableau/src/gates/rot2.rs b/crates/ppvm-tableau/src/gates/rot2.rs index fd7da17d9..9ac1679f3 100644 --- a/crates/ppvm-tableau/src/gates/rot2.rs +++ b/crates/ppvm-tableau/src/gates/rot2.rs @@ -38,10 +38,10 @@ where let [axis_b_x, axis_b_z] = axis_b; let pauli_a = PAULIS[(axis_a_z << 1 | axis_a_x) as usize]; let pauli_b = PAULIS[(axis_b_z << 1 | axis_b_x) as usize]; - // NOTE: if both qubits are lost, the rot1 will be a no-op - if self.is_lost[a] { + // NOTE: if both qubits are lost/leaked, the rot1 will be a no-op + if self.is_lost_or_leaked(a) { return self.rotate_1(pauli_b, b, theta); - } else if self.is_lost[b] { + } else if self.is_lost_or_leaked(b) { return self.rotate_1(pauli_a, a, theta); } diff --git a/crates/ppvm-tableau/src/gates/tgate.rs b/crates/ppvm-tableau/src/gates/tgate.rs index 8034c3eb6..5ffa1ccfb 100644 --- a/crates/ppvm-tableau/src/gates/tgate.rs +++ b/crates/ppvm-tableau/src/gates/tgate.rs @@ -32,7 +32,7 @@ where >::Output>>::Output: PartialEq, { fn t(&mut self, index: usize) { - if self.is_lost[index] { + if self.is_lost_or_leaked(index) { return; } @@ -42,7 +42,7 @@ where } fn t_dag(&mut self, index: usize) { - if self.is_lost[index] { + if self.is_lost_or_leaked(index) { return; } diff --git a/crates/ppvm-tableau/src/noise.rs b/crates/ppvm-tableau/src/noise.rs index 8efb5e926..937820e7f 100644 --- a/crates/ppvm-tableau/src/noise.rs +++ b/crates/ppvm-tableau/src/noise.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 +use std::debug_assert; use std::fmt::Debug; use bitvec::view::BitView; @@ -52,7 +53,7 @@ where #[inline] fn is_qubit_lost(&self, addr: usize) -> bool { - self.is_lost[addr] + self.is_lost_or_leaked(addr) } } @@ -311,6 +312,70 @@ impl, I>> ResetLos } } +impl, I>> LeakageChannel + for GeneralizedTableau +where + <::Storage as BitView>::Store: PrimInt, + C: std::fmt::Debug, + T::Coeff: PartialOrd + + PartialOrd + + One + + Zero + + Clone + + num::Num + + ToPrimitive + + std::fmt::Debug, + Complex: std::ops::Mul> + + From + + std::ops::MulAssign + + std::ops::AddAssign + + One + + ComplexFloat + + Copy, + I: Debug, +{ + fn leakage_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff) { + if self.is_lost_or_leaked(addr0) { + return; + } + + debug_assert!(T::Coeff::zero() <= p0 && p0 <= T::Coeff::one()); + debug_assert!(T::Coeff::zero() <= p1 && p1 <= T::Coeff::one()); + debug_assert!( + T::Coeff::zero() <= p0.clone() + p1.clone() + && p0.clone() + p1.clone() <= T::Coeff::one() + ); + + let p_tot = p0.clone() + p1; + let r = self.tableau.rng.random::(); + + if p_tot <= r { + return; + } + + // Collapse the qubit to a definite basis state. This internal + // measurement is a mechanism, not a logical measurement, so drop the + // record entry it pushed (mirrors `loss_channel`). + let m = self + .measure(addr0) + .expect("Loss was checked before, this should be unreachable"); + self.measurement_record.pop(); + + // Pin the qubit to |0⟩ (prob p0) or |1⟩ (prob p1). r < p_tot = p0 + p1 + // here, so r < p0 selects |0⟩ and p0 <= r < p_tot selects |1⟩. The pin + // must be applied before flagging the qubit leaked, otherwise the `x` + // gate would be skipped by `is_lost_or_leaked`. + if p0 > r { + if m { + self.x(addr0); + } + } else if !m { + self.x(addr0); + } + self.is_leaked[addr0] = true; + } +} + #[cfg(test)] mod tests { use super::*; @@ -990,4 +1055,127 @@ mod tests { "expected ~{expected}, got {frac:.3}" ); } + + // === LeakageChannel === + + #[test] + fn leakage_p0_p1_zero_no_leak() { + // p0 = p1 = 0 → p_tot = 0, never leaks; the qubit stays live. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 0.0); + assert!(!t.is_leaked[0]); + assert!(!t.is_lost[0]); + assert!(!t.measure(0).unwrap()); + } + + #[test] + fn leakage_to_zero_pins_qubit_to_zero() { + // Start in |1⟩; leak-to-|0⟩ (p0 = 1) must pin the qubit to |0⟩. + let mut t = tab(1); + t.x(0); + t.leakage_channel(0, 1.0, 0.0); + assert!(t.is_leaked[0]); + assert!(!t.is_lost[0]); // leaked, not lost + assert_eq!(t.measure(0), Some(false)); + } + + #[test] + fn leakage_to_one_pins_qubit_to_one() { + // Start in |0⟩; leak-to-|1⟩ (p1 = 1) must pin the qubit to |1⟩. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); + assert!(t.is_leaked[0]); + assert!(!t.is_lost[0]); + assert_eq!(t.measure(0), Some(true)); + } + + #[test] + fn leaked_qubit_reports_a_bit_unlike_lost() { + // A leaked qubit measures a definite bit; a lost qubit returns None. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); + assert!(t.measure(0).is_some()); + } + + #[test] + fn leakage_does_not_pollute_measurement_record() { + // The internal collapse is a mechanism, not a logical measurement; + // mirrors `loss_channel`. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); + assert!(t.current_measurement_record().is_empty()); + } + + #[test] + fn leakage_collapses_superposition_then_pins() { + // Leaking a superposed qubit collapses and pins it, so later + // measurement is deterministic even though |+⟩ alone would be random. + let mut t = tab(1); + t.tableau.rng = rand::SeedableRng::seed_from_u64(7); + t.h(0); // |+⟩ + t.leakage_channel(0, 0.0, 1.0); + assert!(t.is_leaked[0]); + assert_eq!(t.measure(0), Some(true)); + assert_eq!(t.measure(0), Some(true)); + } + + #[test] + fn single_qubit_gate_skips_leaked_qubit() { + // Pinned to |1⟩; a subsequent x must be a no-op. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); + t.x(0); + assert_eq!(t.measure(0), Some(true)); + } + + #[test] + fn two_qubit_gate_skipped_when_control_leaked() { + // Control leaked to |1⟩; cnot must not flip the (live) target. + let mut t = tab(2); + t.leakage_channel(0, 0.0, 1.0); + t.cnot(0, 1); + assert_eq!(t.measure(1), Some(false)); + assert_eq!(t.measure(0), Some(true)); + } + + #[test] + fn leaked_qubit_stays_deterministic_after_other_ops() { + // A leaked qubit is disentangled and pinned: gating/measuring other + // qubits doesn't disturb its outcome. + let mut t = tab(2); + t.leakage_channel(0, 0.0, 1.0); + t.h(1); + let _ = t.measure(1); + assert_eq!(t.measure(0), Some(true)); + } + + #[test] + fn leakage_channel_skips_already_leaked() { + // A second leakage on a leaked qubit is a no-op (early return), so the + // pinned value is unchanged. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); // |1⟩, leaked + t.leakage_channel(0, 1.0, 0.0); // would pin |0⟩ if it ran + assert_eq!(t.measure(0), Some(true)); + } + + #[test] + fn leaked_qubit_can_still_be_lost() { + // Leaked-then-lost is allowed; loss wins and measurement returns None. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); + t.loss_channel(0, 1.0); + assert!(t.is_lost[0]); + assert!(t.measure(0).is_none()); + } + + #[test] + fn reset_skips_leaked_qubit() { + // reset must not re-zero a leaked qubit. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); // leaked, |1⟩ + t.reset(0); + assert!(t.is_leaked[0]); + assert_eq!(t.measure(0), Some(true)); + } } diff --git a/crates/ppvm-traits/src/traits/mod.rs b/crates/ppvm-traits/src/traits/mod.rs index 57520129d..c0834f620 100644 --- a/crates/ppvm-traits/src/traits/mod.rs +++ b/crates/ppvm-traits/src/traits/mod.rs @@ -26,7 +26,7 @@ pub use map::{ pub use measure::{LossyMeasure, Measure}; pub use noise::{ AmplitudeDamping, AsymmetricLossChannel, CorrelatedLossChannel, Depolarizing, Depolarizing2, - LossChannel, PauliError, PauliErrorAll, ResetLossChannel, TwoQubitPauliError, + LeakageChannel, LossChannel, PauliError, PauliErrorAll, ResetLossChannel, TwoQubitPauliError, }; pub use reset::Reset; pub use storage::PauliStorage; diff --git a/crates/ppvm-traits/src/traits/noise.rs b/crates/ppvm-traits/src/traits/noise.rs index 80544ecb8..5c670df3b 100644 --- a/crates/ppvm-traits/src/traits/noise.rs +++ b/crates/ppvm-traits/src/traits/noise.rs @@ -151,3 +151,7 @@ pub trait AsymmetricLossChannel { /// trajectory approximation used (the survival back-action is omitted). fn asymmetric_loss_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff); } + +pub trait LeakageChannel { + fn leakage_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff); +} From 4f5d2d20847fe62c45f97fa85d9084b076f203cb Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 24 Jul 2026 10:42:46 +0200 Subject: [PATCH 02/11] Leakage reset --- crates/ppvm-tableau/src/gates/reset.rs | 6 +- crates/ppvm-tableau/src/noise.rs | 81 ++++++++++++++++++++++++++ crates/ppvm-traits/src/traits/mod.rs | 3 +- crates/ppvm-traits/src/traits/noise.rs | 4 ++ 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/ppvm-tableau/src/gates/reset.rs b/crates/ppvm-tableau/src/gates/reset.rs index 44dedec84..38c74b99d 100644 --- a/crates/ppvm-tableau/src/gates/reset.rs +++ b/crates/ppvm-tableau/src/gates/reset.rs @@ -24,7 +24,7 @@ impl Reset for GeneralizedTableau where T: Config, <::Storage as BitView>::Store: PrimInt, - I: TableauIndex + Debug + Send + Sync, + I: TableauIndex + Debug, C: SparseVector, I> + Debug, T::Coeff: One + Zero @@ -33,9 +33,7 @@ where + ToPrimitive + std::fmt::Debug + std::ops::Mul - + PartialOrd - + Send - + Sync, + + PartialOrd, Complex: std::ops::Mul> + From + std::ops::MulAssign diff --git a/crates/ppvm-tableau/src/noise.rs b/crates/ppvm-tableau/src/noise.rs index 937820e7f..780ccc6f7 100644 --- a/crates/ppvm-tableau/src/noise.rs +++ b/crates/ppvm-tableau/src/noise.rs @@ -376,6 +376,43 @@ where } } +impl ResetLeakageChannel for GeneralizedTableau +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + I: TableauIndex + Debug, + C: SparseVector, I> + Debug, + T::Coeff: One + + Zero + + Clone + + num::Num + + ToPrimitive + + std::fmt::Debug + + std::ops::Mul + + PartialOrd, + Complex: std::ops::Mul> + + From + + std::ops::MulAssign + + std::ops::AddAssign + + One + + ComplexFloat + + Copy, +{ + fn reset_leakage_channel(&mut self, addr0: usize) { + if self.is_lost[addr0] { + // cannot recover a lost qubit + return; + } + + if !self.is_leaked[addr0] { + return; + } + + self.is_leaked[addr0] = false; + self.reset(addr0); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1178,4 +1215,48 @@ mod tests { assert!(t.is_leaked[0]); assert_eq!(t.measure(0), Some(true)); } + + // === ResetLeakageChannel === + + #[test] + fn reset_leakage_channel_recovers_qubit_to_zero() { + // A qubit leaked to |1⟩ is un-leaked and re-initialized to |0⟩. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); // leaked, pinned |1⟩ + t.reset_leakage_channel(0); + assert!(!t.is_leaked[0]); + assert!(!t.is_lost[0]); + assert!(t.current_measurement_record().is_empty()); // record-neutral + assert_eq!(t.measure(0), Some(false)); // back in |0⟩ + } + + #[test] + fn reset_leakage_channel_gates_work_again() { + // After recovery the qubit is live: gates are no longer skipped. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); + t.reset_leakage_channel(0); + t.x(0); + assert_eq!(t.measure(0), Some(true)); + } + + #[test] + fn reset_leakage_channel_does_not_recover_lost() { + // A lost qubit cannot be brought back by leakage reduction. + let mut t = tab(1); + t.is_lost[0] = true; + t.reset_leakage_channel(0); + assert!(t.is_lost[0]); + assert!(t.measure(0).is_none()); + } + + #[test] + fn reset_leakage_channel_noop_on_live_qubit() { + // A live (never-leaked) qubit is left untouched — not re-zeroed. + let mut t = tab(1); + t.x(0); // |1⟩ + t.reset_leakage_channel(0); + assert!(!t.is_leaked[0]); + assert_eq!(t.measure(0), Some(true)); // unchanged, still |1⟩ + } } diff --git a/crates/ppvm-traits/src/traits/mod.rs b/crates/ppvm-traits/src/traits/mod.rs index c0834f620..5bc27c548 100644 --- a/crates/ppvm-traits/src/traits/mod.rs +++ b/crates/ppvm-traits/src/traits/mod.rs @@ -26,7 +26,8 @@ pub use map::{ pub use measure::{LossyMeasure, Measure}; pub use noise::{ AmplitudeDamping, AsymmetricLossChannel, CorrelatedLossChannel, Depolarizing, Depolarizing2, - LeakageChannel, LossChannel, PauliError, PauliErrorAll, ResetLossChannel, TwoQubitPauliError, + LeakageChannel, LossChannel, PauliError, PauliErrorAll, ResetLeakageChannel, ResetLossChannel, + TwoQubitPauliError, }; pub use reset::Reset; pub use storage::PauliStorage; diff --git a/crates/ppvm-traits/src/traits/noise.rs b/crates/ppvm-traits/src/traits/noise.rs index 5c670df3b..c61fdeb23 100644 --- a/crates/ppvm-traits/src/traits/noise.rs +++ b/crates/ppvm-traits/src/traits/noise.rs @@ -155,3 +155,7 @@ pub trait AsymmetricLossChannel { pub trait LeakageChannel { fn leakage_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff); } + +pub trait ResetLeakageChannel { + fn reset_leakage_channel(&mut self, addr0: usize); +} From 2afe2c80a05b19be0e668a28321e034ff29f9cc1 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 24 Jul 2026 10:59:49 +0200 Subject: [PATCH 03/11] Add leakage to ppvm-stim --- crates/ppvm-stim/benches/stim-circuits.rs | 3 +- crates/ppvm-stim/src/executor.rs | 7 +++++ crates/ppvm-stim/src/validate.rs | 3 +- crates/ppvm-stim/tests/executor.rs | 28 +++++++++++++++++ crates/stim-parser/src/ast/extended.rs | 12 ++++++-- crates/stim-parser/src/pipeline/lower.rs | 36 ++++++++++++++++++++-- crates/stim-parser/src/print/mod.rs | 18 +++++++++++ crates/stim-parser/tests/extended.rs | 28 +++++++++++++++++ crates/stim-parser/tests/proptest_ast.rs | 9 ++++++ crates/stim-parser/tests/proptest_parse.rs | 1 + crates/stim-parser/tests/roundtrip.rs | 1 + crates/stim-parser/tests/tags.rs | 16 ++++++++++ 12 files changed, 156 insertions(+), 6 deletions(-) diff --git a/crates/ppvm-stim/benches/stim-circuits.rs b/crates/ppvm-stim/benches/stim-circuits.rs index 430004aea..08882b217 100644 --- a/crates/ppvm-stim/benches/stim-circuits.rs +++ b/crates/ppvm-stim/benches/stim-circuits.rs @@ -77,7 +77,8 @@ fn required_qubits(program: &ExtendedProgram) -> usize { | ExtendedInstruction::TDag { targets, .. } | ExtendedInstruction::Rotation { targets, .. } | ExtendedInstruction::U3 { targets, .. } - | ExtendedInstruction::Loss { targets, .. } => { + | ExtendedInstruction::Loss { targets, .. } + | ExtendedInstruction::Leakage { targets, .. } => { for &q in targets { bump(q); } diff --git a/crates/ppvm-stim/src/executor.rs b/crates/ppvm-stim/src/executor.rs index 592d31e5d..05ae2f332 100644 --- a/crates/ppvm-stim/src/executor.rs +++ b/crates/ppvm-stim/src/executor.rs @@ -736,6 +736,13 @@ pub fn execute_validated( tab.correlated_loss_channel(a, b, ps.clone()); } } + ExtendedInstruction::Leakage { + p0, p1, targets, .. + } => { + for &q in targets { + tab.leakage_channel(q, (*p0).into(), (*p1).into()); + } + } ExtendedInstruction::Measure(MeasureOp { name, args, diff --git a/crates/ppvm-stim/src/validate.rs b/crates/ppvm-stim/src/validate.rs index be5605823..d68c0bf84 100644 --- a/crates/ppvm-stim/src/validate.rs +++ b/crates/ppvm-stim/src/validate.rs @@ -105,7 +105,8 @@ fn validate_slice( | ExtendedInstruction::Rotation { .. } | ExtendedInstruction::U3 { .. } | ExtendedInstruction::Loss { .. } - | ExtendedInstruction::CorrelatedLoss { .. } => {} + | ExtendedInstruction::CorrelatedLoss { .. } + | ExtendedInstruction::Leakage { .. } => {} ExtendedInstruction::MPad { prob, bits, span, .. } => { diff --git a/crates/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index dd3c25c32..3ec4cd47e 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -102,6 +102,34 @@ fn loss_channel_with_p1_marks_qubit_lost() { assert!(tab.is_lost[0]); } +#[test] +fn leakage_channel_leaks_qubit_to_one() { + // p1 = 1.0 pins the qubit to |1⟩ and flags it leaked (not lost). + let prog = parse_extended("I_ERROR[leakage](0.0, 1.0) 0").unwrap(); + let mut tab: Tab = GeneralizedTableau::new(1, 1e-10); + execute(&prog, &mut tab).unwrap(); + assert!(tab.is_leaked[0]); + assert!(!tab.is_lost[0]); + assert_eq!(tab.measure(0), Some(true)); +} + +#[test] +fn leakage_channel_zero_prob_no_leak() { + let prog = parse_extended("I_ERROR[leakage](0.0, 0.0) 0").unwrap(); + let mut tab: Tab = GeneralizedTableau::new(1, 1e-10); + execute(&prog, &mut tab).unwrap(); + assert!(!tab.is_leaked[0]); + assert!(!tab.is_lost[0]); +} + +#[test] +fn leakage_channel_applies_to_all_targets() { + let prog = parse_extended("I_ERROR[leakage](0.0, 1.0) 0 1 2").unwrap(); + let mut tab: Tab = GeneralizedTableau::new(3, 1e-10); + execute(&prog, &mut tab).unwrap(); + assert!(tab.is_leaked[0] && tab.is_leaked[1] && tab.is_leaked[2]); +} + #[test] fn repeat_executes_body_n_times() { let (results, _) = run("REPEAT 2 { X 0 }\nM 0", 1); diff --git a/crates/stim-parser/src/ast/extended.rs b/crates/stim-parser/src/ast/extended.rs index 5b94cb04f..555cd6c4e 100644 --- a/crates/stim-parser/src/ast/extended.rs +++ b/crates/stim-parser/src/ast/extended.rs @@ -50,6 +50,12 @@ pub enum ExtendedInstruction { targets: Vec<(usize, usize)>, span: Span, }, + Leakage { + p0: f64, + p1: f64, + targets: Vec, + span: Span, + }, MPad { tag: String, prob: Option, @@ -109,7 +115,8 @@ fn max_qubit_in_slice(instructions: &[ExtendedInstruction]) -> Option { | ExtendedInstruction::TDag { targets, .. } | ExtendedInstruction::Rotation { targets, .. } | ExtendedInstruction::U3 { targets, .. } - | ExtendedInstruction::Loss { targets, .. } => targets.iter().copied().max(), + | ExtendedInstruction::Loss { targets, .. } + | ExtendedInstruction::Leakage { targets, .. } => targets.iter().copied().max(), ExtendedInstruction::CorrelatedLoss { targets, .. } => { targets.iter().flat_map(|&(a, b)| [a, b]).max() } @@ -146,7 +153,8 @@ fn count_in_slice(instructions: &[ExtendedInstruction], factor: u64) -> usize { | ExtendedInstruction::Rotation { .. } | ExtendedInstruction::U3 { .. } | ExtendedInstruction::Loss { .. } - | ExtendedInstruction::CorrelatedLoss { .. } => {} + | ExtendedInstruction::CorrelatedLoss { .. } + | ExtendedInstruction::Leakage { .. } => {} } } total diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 042334246..6a492d46e 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -336,18 +336,35 @@ fn lower_noise( span, })) } + (IError, "leakage") => { + if args.len() != 2 { + return invalid_tag( + "leakage", + name.canonical_name(), + span, + format!("[leakage] expects 2 args, got {}", args.len()), + sink, + ); + } + Ok(Some(ExtendedInstruction::Leakage { + p0: args[0], + p1: args[1], + targets, + span, + })) + } (IError, "") => invalid_tag( "", name.canonical_name(), span, - "I_ERROR requires a [loss] or [correlated_loss] tag", + "I_ERROR requires a [loss], [correlated_loss], or [leakage] tag", sink, ), (IError, other) => invalid_tag( other, name.canonical_name(), span, - "expected [loss] or [correlated_loss]", + "expected [loss], [correlated_loss], or [leakage]", sink, ), _ => Ok(Some(ExtendedInstruction::Noise(NoiseOp { @@ -719,6 +736,21 @@ mod tests { } } + #[test] + fn i_error_leakage_lowers() { + let prog = lower_extended("I_ERROR[leakage](0.1, 0.2) 0").expect("lower"); + match &prog.instructions[0] { + ExtendedInstruction::Leakage { + p0, p1, targets, .. + } => { + assert_eq!(*p0, 0.1); + assert_eq!(*p1, 0.2); + assert_eq!(targets, &vec![0]); + } + other => panic!("{other:?}"), + } + } + #[test] fn h_passes_through_as_gate() { let prog = lower_extended("H 0").expect("lower"); diff --git a/crates/stim-parser/src/print/mod.rs b/crates/stim-parser/src/print/mod.rs index c7a34de42..6af28e707 100644 --- a/crates/stim-parser/src/print/mod.rs +++ b/crates/stim-parser/src/print/mod.rs @@ -358,6 +358,17 @@ impl StimPrint for ExtendedInstruction { write!(out, " {a} {b}")?; } } + ExtendedInstruction::Leakage { + p0, p1, targets, .. + } => { + write!( + out, + "I_ERROR[leakage]({}, {})", + FloatLit(*p0), + FloatLit(*p1) + )?; + write_usize_targets(out, targets)?; + } ExtendedInstruction::MPad { tag, prob, bits, .. } => { @@ -421,6 +432,13 @@ mod tests { assert_eq!(ast.to_stim(), expected); } + #[test] + fn leakage_prints_canonically() { + let src = "I_ERROR[leakage](0.1, 0.2) 0 1\n"; + let ast = parse_extended(src).unwrap(); + assert_eq!(ast.to_stim(), src); + } + #[test] fn rec_and_mpp_targets_round_trip() { // rec[-k] feed-forward control and MPP Pauli products print canonically. diff --git a/crates/stim-parser/tests/extended.rs b/crates/stim-parser/tests/extended.rs index 3f0a9a5fa..28a8456fc 100644 --- a/crates/stim-parser/tests/extended.rs +++ b/crates/stim-parser/tests/extended.rs @@ -494,6 +494,34 @@ fn i_error_correlated_loss_two_args_errors() { ); } +#[test] +fn i_error_leakage_promotes_to_leakage() { + let p = parse_ok("I_ERROR[leakage](0.1, 0.2) 0\n"); + match &p.instructions[0] { + ExtendedInstruction::Leakage { + p0, + p1, + targets, + span, + } => { + approx_eq(*p0, 0.1); + approx_eq(*p1, 0.2); + assert_eq!(targets, &vec![0]); + assert_eq!(span.line(&p.line_map), 1); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn i_error_leakage_wrong_arg_count_errors() { + assert_eq!(err_code("I_ERROR[leakage](0.1) 0\n"), Some("invalid-tag")); + assert_eq!( + err_code("I_ERROR[leakage](0.1, 0.2, 0.3) 0\n"), + Some("invalid-tag") + ); +} + #[test] fn i_error_with_no_tag_errors() { let err = parse_err("I_ERROR(0.1) 0\n"); diff --git a/crates/stim-parser/tests/proptest_ast.rs b/crates/stim-parser/tests/proptest_ast.rs index d43910422..0304723f2 100644 --- a/crates/stim-parser/tests/proptest_ast.rs +++ b/crates/stim-parser/tests/proptest_ast.rs @@ -423,6 +423,14 @@ fn ext_flat() -> impl Strategy { targets, span: span0(), }), + (prob_lit(), prob_lit(), one_q_targets()).prop_map(|(p0, p1, targets)| { + ExtendedInstruction::Leakage { + p0, + p1, + targets, + span: span0(), + } + }), ( prob_lit(), prob_lit(), @@ -502,6 +510,7 @@ fn zero_spans_ext(instrs: &mut [ExtendedInstruction]) { | ExtendedInstruction::U3 { span, .. } | ExtendedInstruction::Loss { span, .. } | ExtendedInstruction::CorrelatedLoss { span, .. } + | ExtendedInstruction::Leakage { span, .. } | ExtendedInstruction::MPad { span, .. } => *span = span0(), ExtendedInstruction::Repeat { body, span, .. } => { *span = span0(); diff --git a/crates/stim-parser/tests/proptest_parse.rs b/crates/stim-parser/tests/proptest_parse.rs index 1dc531a09..22f91f3b1 100644 --- a/crates/stim-parser/tests/proptest_parse.rs +++ b/crates/stim-parser/tests/proptest_parse.rs @@ -48,6 +48,7 @@ fn stim_token() -> impl Strategy { Just("[T]"), Just("[loss]"), Just("[correlated_loss]"), + Just("[leakage]"), Just("[R_X(theta=0.5)]"), Just("[U3(theta=0.5,phi=1,lambda=0.25)]"), Just("(0.1)"), diff --git a/crates/stim-parser/tests/roundtrip.rs b/crates/stim-parser/tests/roundtrip.rs index 81e1b98f5..2d879fff5 100644 --- a/crates/stim-parser/tests/roundtrip.rs +++ b/crates/stim-parser/tests/roundtrip.rs @@ -85,6 +85,7 @@ const EXTENDED_CORPUS: &[(&str, &str)] = &[ "correlated_loss", "I_ERROR[correlated_loss](0.1, 0.05, 0.05) 0 1 2 3\n", ), + ("leakage", "I_ERROR[leakage](0.1, 0.2) 0 1\n"), ("mpad_bits", "MPAD 0 1 0\nMPAD(0.01) 1 1 0 0\n"), ( "extended_in_repeat", diff --git a/crates/stim-parser/tests/tags.rs b/crates/stim-parser/tests/tags.rs index d759da2df..3c58c6262 100644 --- a/crates/stim-parser/tests/tags.rs +++ b/crates/stim-parser/tests/tags.rs @@ -75,6 +75,22 @@ fn parse_loss_tag_with_args() { } } +#[test] +fn parse_leakage_tag_with_args() { + let p = parse("I_ERROR[leakage](0.1, 0.2) 0").unwrap(); + match &p.instructions[0] { + Instruction::Noise(NoiseOp { + name, tag, args, .. + }) => { + assert_eq!(*name, NoiseName::IError); + assert_eq!(tag, "leakage"); + approx_eq(args[0], 0.1); + approx_eq(args[1], 0.2); + } + other => panic!("{other:?}"), + } +} + #[test] fn parse_correlated_loss_three_args() { let p = parse("I_ERROR[correlated_loss](0.1, 0.2, 0.3) 0 1").unwrap(); From 6b81097b2974f6170730b7c1922ef87ee36f1a08 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 24 Jul 2026 11:11:46 +0200 Subject: [PATCH 04/11] Add some tests to the STIM path --- .../test/generalized_tableau/test_stim.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/ppvm-python/test/generalized_tableau/test_stim.py b/ppvm-python/test/generalized_tableau/test_stim.py index 599264b79..e35988103 100644 --- a/ppvm-python/test/generalized_tableau/test_stim.py +++ b/ppvm-python/test/generalized_tableau/test_stim.py @@ -151,6 +151,35 @@ def test_run_stim_string_loss_channel(): assert results == [MeasurementResult.LOST] +def test_run_stim_string_leakage_to_one_measures_one(): + # I_ERROR[leakage](p0, p1): p1=1 pins the qubit to |1>. Unlike loss, a + # leaked qubit reads a definite classical bit (ONE), not LOST. + tab = GeneralizedTableau(1) + results = tab.run(StimProgram.parse("I_ERROR[leakage](0.0, 1.0) 0\nM 0")) + assert results == [MeasurementResult.ONE] + + +def test_run_stim_string_leakage_to_zero_measures_zero(): + # p0=1 pins the qubit to |0>. + tab = GeneralizedTableau(1) + results = tab.run(StimProgram.parse("X 0\nI_ERROR[leakage](1.0, 0.0) 0\nM 0")) + assert results == [MeasurementResult.ZERO] + + +def test_run_stim_string_leaked_qubit_skips_gates(): + # A leaked qubit is frozen: the X after leakage is a no-op, so a qubit + # pinned to |1> still reads ONE (a live qubit would flip to |0>). + tab = GeneralizedTableau(1) + results = tab.run(StimProgram.parse("I_ERROR[leakage](0.0, 1.0) 0\nX 0\nM 0")) + assert results == [MeasurementResult.ONE] + + +def test_run_stim_string_leakage_applies_to_all_targets(): + tab = GeneralizedTableau(2) + results = tab.run(StimProgram.parse("I_ERROR[leakage](0.0, 1.0) 0 1\nM 0 1")) + assert results == [MeasurementResult.ONE, MeasurementResult.ONE] + + def test_run_stim_string_comments_and_blank_lines_ignored(): # Comments (#) and blank lines must not affect execution circuit = """ From bf60891eb92e134bd7a4e2289605ee887bdfd090 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Tue, 1 Sep 2026 12:15:48 +0200 Subject: [PATCH 05/11] Use a single enum to track qubit status --- .../src/interface_tableau.rs | 8 +- crates/ppvm-stim/tests/executor.rs | 16 +- crates/ppvm-tableau/src/data.rs | 64 +++--- crates/ppvm-tableau/src/display.rs | 6 +- crates/ppvm-tableau/src/gates/clifford.rs | 36 +-- crates/ppvm-tableau/src/gates/reset.rs | 2 +- crates/ppvm-tableau/src/gates/rot1.rs | 2 +- crates/ppvm-tableau/src/gates/rot2.rs | 4 +- crates/ppvm-tableau/src/gates/tgate.rs | 4 +- crates/ppvm-tableau/src/lib.rs | 4 + crates/ppvm-tableau/src/measure.rs | 2 +- crates/ppvm-tableau/src/measure_all.rs | 7 +- crates/ppvm-tableau/src/noise.rs | 212 ++++++++++-------- crates/ppvm-tableau/src/qubit_status.rs | 83 +++++++ crates/ppvm-tableau/tests/gates.rs | 44 ++-- 15 files changed, 291 insertions(+), 203 deletions(-) create mode 100644 crates/ppvm-tableau/src/qubit_status.rs diff --git a/crates/ppvm-python-native/src/interface_tableau.rs b/crates/ppvm-python-native/src/interface_tableau.rs index 9bbcda6b7..2a0062b6a 100644 --- a/crates/ppvm-python-native/src/interface_tableau.rs +++ b/crates/ppvm-python-native/src/interface_tableau.rs @@ -295,11 +295,15 @@ macro_rules! create_interface { } pub fn is_lost(&self, addr0: usize) -> bool { - self.inner.is_lost[addr0] + self.inner.is_lost(addr0) } pub fn loss_values(&self) -> Vec { - self.inner.is_lost.clone() + self.inner + .qubit_status + .iter() + .map(|&o| o == QubitStatus::Lost) + .collect() } pub fn run( diff --git a/crates/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index 3ec4cd47e..1de2beee8 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -99,7 +99,7 @@ fn loss_channel_with_p1_marks_qubit_lost() { let prog = parse_extended("I_ERROR[loss](1.0) 0").unwrap(); let mut tab: Tab = GeneralizedTableau::new(1, 1e-10); execute(&prog, &mut tab).unwrap(); - assert!(tab.is_lost[0]); + assert!(tab.is_lost(0)); } #[test] @@ -108,8 +108,8 @@ fn leakage_channel_leaks_qubit_to_one() { let prog = parse_extended("I_ERROR[leakage](0.0, 1.0) 0").unwrap(); let mut tab: Tab = GeneralizedTableau::new(1, 1e-10); execute(&prog, &mut tab).unwrap(); - assert!(tab.is_leaked[0]); - assert!(!tab.is_lost[0]); + assert!(tab.is_leaked(0)); + assert!(!tab.is_lost(0)); assert_eq!(tab.measure(0), Some(true)); } @@ -118,8 +118,8 @@ fn leakage_channel_zero_prob_no_leak() { let prog = parse_extended("I_ERROR[leakage](0.0, 0.0) 0").unwrap(); let mut tab: Tab = GeneralizedTableau::new(1, 1e-10); execute(&prog, &mut tab).unwrap(); - assert!(!tab.is_leaked[0]); - assert!(!tab.is_lost[0]); + assert!(!tab.is_leaked(0)); + assert!(!tab.is_lost(0)); } #[test] @@ -127,7 +127,7 @@ fn leakage_channel_applies_to_all_targets() { let prog = parse_extended("I_ERROR[leakage](0.0, 1.0) 0 1 2").unwrap(); let mut tab: Tab = GeneralizedTableau::new(3, 1e-10); execute(&prog, &mut tab).unwrap(); - assert!(tab.is_leaked[0] && tab.is_leaked[1] && tab.is_leaked[2]); + assert!(tab.is_leaked(0) && tab.is_leaked(1) && tab.is_leaked(2)); } #[test] @@ -332,7 +332,7 @@ fn test_stim_correlated_loss_simple() { let mut tab: Tab = GeneralizedTableau::new(2, 1e-10); run_str("I_ERROR[correlated_loss](1.0) 0 1", &mut tab); assert!( - tab.is_lost[0] && tab.is_lost[1], + tab.is_lost(0) && tab.is_lost(1), "Both qubits should be lost" ); } @@ -343,7 +343,7 @@ fn test_stim_correlated_loss_zero_prob() { let mut tab: Tab = GeneralizedTableau::new(2, 1e-10); run_str("I_ERROR[correlated_loss](0.0) 0 1", &mut tab); assert!( - !tab.is_lost[0] && !tab.is_lost[1], + !tab.is_lost(0) && !tab.is_lost(1), "No qubits should be lost" ); } diff --git a/crates/ppvm-tableau/src/data.rs b/crates/ppvm-tableau/src/data.rs index 5a8a65607..ccd36af53 100644 --- a/crates/ppvm-tableau/src/data.rs +++ b/crates/ppvm-tableau/src/data.rs @@ -22,6 +22,8 @@ use rand::rngs::SmallRng; type PhasedPauliWordNoHash = PhasedPauliWord>; +pub use crate::qubit_status::QubitStatus; + /// A `2n`-row stabilizer / destabilizer tableau. /// /// Rows `0..n` hold the destabilizers; rows `n..2n` hold the @@ -591,8 +593,9 @@ where /// * `IndexType = bnum::types::U256` and friends for the very wide /// regime. /// -/// Per-qubit loss is tracked in [`is_lost`](GeneralizedTableau::is_lost); -/// gates respect it automatically. +/// Per-qubit status (loss / leakage) is tracked in +/// [`qubit_status`](GeneralizedTableau::qubit_status); gates skip any qubit that is +/// not [`QubitStatus::Live`]. /// /// # Examples /// @@ -639,12 +642,8 @@ pub struct GeneralizedTableau< pub tableau: Tableau, /// Sparse coefficient vector indexed by bitstrings. pub coefficients: SparseVectorType, - /// Per-qubit loss flags. - pub is_lost: Vec, - /// Per-qubit leakage flags. A leaked qubit has been pinned to a - /// computational basis state (`|0⟩`/`|1⟩`) in the tableau, so gates skip it - /// and measurement reports the pinned value directly. - pub is_leaked: Vec, + /// Per-qubit status relative to the computational subspace. + pub qubit_status: Vec, /// Coefficient-magnitude threshold below which branches are dropped. pub coefficient_threshold: T::Coeff, /// Ordered log of every measurement performed (mirrors stim's record). @@ -652,15 +651,6 @@ pub struct GeneralizedTableau< _index_phantom: PhantomData, } -impl, I>> GeneralizedTableau { - /// Whether qubit `addr0` is outside the computational subspace — either lost - /// or leaked. Gates skip such qubits. - #[inline] - pub fn is_lost_or_leaked(&self, addr0: usize) -> bool { - self.is_leaked[addr0] || self.is_lost[addr0] - } -} - impl, I>> GeneralizedTableau where T::Coeff: One + Zero + Clone + num::Num, @@ -685,8 +675,7 @@ where Self { tableau: Tableau::new(n_qubits), coefficients, - is_lost: vec![false; n_qubits], - is_leaked: vec![false; n_qubits], + qubit_status: vec![QubitStatus::Live; n_qubits], coefficient_threshold, measurement_record: Vec::new(), _index_phantom: PhantomData, @@ -710,11 +699,8 @@ where }; coefficients.unsafe_insert(I::zero(), complex_one); self.coefficients = coefficients; - for l in self.is_lost.iter_mut() { - *l = false; - } - for l in self.is_leaked.iter_mut() { - *l = false; + for occ in self.qubit_status.iter_mut() { + *occ = QubitStatus::Live; } self.measurement_record.clear(); } @@ -767,8 +753,8 @@ where ::Store: PrimInt + TryFrom, { // Check if any qubit in the range is lost or leaked - let any_lost = (0..count) - .any(|i| self.is_lost_or_leaked(base + i) || self.is_lost_or_leaked(base + offset + i)); + let any_lost = + (0..count).any(|i| self.is_inactive(base + i) || self.is_inactive(base + offset + i)); if !any_lost { self.tableau.cz_block_pairs(base, offset, count); } else { @@ -776,7 +762,7 @@ where for i in 0..count { let c = base + i; let t = base + offset + i; - if !self.is_lost_or_leaked(c) && !self.is_lost_or_leaked(t) { + if !self.is_inactive(c) && !self.is_inactive(t) { Clifford::cz(&mut self.tableau, c, t); } } @@ -799,7 +785,7 @@ where let any_lost = (0..count).any(|i| { let c = word_c * bits_per_word + base_bit_c + i; let t = word_t * bits_per_word + base_bit_t + i; - self.is_lost_or_leaked(c) || self.is_lost_or_leaked(t) + self.is_inactive(c) || self.is_inactive(t) }); if !any_lost { self.tableau @@ -808,7 +794,7 @@ where for i in 0..count { let c = word_c * bits_per_word + base_bit_c + i; let t = word_t * bits_per_word + base_bit_t + i; - if !self.is_lost_or_leaked(c) && !self.is_lost_or_leaked(t) { + if !self.is_inactive(c) && !self.is_inactive(t) { Clifford::cz(&mut self.tableau, c, t); } } @@ -1029,7 +1015,7 @@ where ) where <::Storage as BitView>::Store: PrimInt, { - if self.is_lost_or_leaked(addr0) { + if self.is_inactive(addr0) { return; } @@ -1232,7 +1218,7 @@ where ) where <::Storage as BitView>::Store: PrimInt, { - if self.is_lost_or_leaked(addr0) { + if self.is_inactive(addr0) { return; } @@ -1510,14 +1496,14 @@ mod tests { for i in 0..n { Clifford::h(&mut tab1.tableau, i); } - tab1.is_lost[2] = true; // Mark qubit 2 as lost + tab1.qubit_status[2] = QubitStatus::Lost; // Mark qubit 2 as lost let mut tab2 = tab1.clone(); // Individual, skipping lost qubits for i in 0..4 { let c = i; let t = 4 + i; - if !tab1.is_lost[c] && !tab1.is_lost[t] { + if !tab1.is_lost(c) && !tab1.is_lost(t) { Clifford::cz(&mut tab1.tableau, c, t); } } @@ -1616,24 +1602,24 @@ mod tests { #[test] fn reset_all_clears_loss_flags() { let mut tab: TestTableau = GeneralizedTableau::new(3, 1e-12); - tab.is_lost[0] = true; - tab.is_lost[2] = true; + tab.qubit_status[0] = QubitStatus::Lost; + tab.qubit_status[2] = QubitStatus::Lost; tab.reset_all(); - assert!(tab.is_lost.iter().all(|&lost| !lost)); + assert!(tab.qubit_status.iter().all(|&occ| occ == QubitStatus::Live)); } /// A full reset clears per-qubit leakage flags. #[test] fn reset_all_clears_leak_flags() { let mut tab: TestTableau = GeneralizedTableau::new(3, 1e-12); - tab.is_leaked[0] = true; - tab.is_leaked[2] = true; + tab.qubit_status[0] = QubitStatus::Leaked; + tab.qubit_status[2] = QubitStatus::Leaked; tab.reset_all(); - assert!(tab.is_leaked.iter().all(|&leaked| !leaked)); + assert!(tab.qubit_status.iter().all(|&occ| occ == QubitStatus::Live)); } /// `Tableau::reset_all` restores the fresh identity tableau rows. diff --git a/crates/ppvm-tableau/src/display.rs b/crates/ppvm-tableau/src/display.rs index 2b892a343..0359149d8 100644 --- a/crates/ppvm-tableau/src/display.rs +++ b/crates/ppvm-tableau/src/display.rs @@ -38,9 +38,9 @@ where for (coeff, idx) in self.coefficients.clone().into_iter() { writeln!(f, " Index {}: {}", idx, coeff)?; } - writeln!(f, " Is Lost: [")?; - for (i, &lost) in self.is_lost.iter().enumerate() { - writeln!(f, " Qubit {}: {}", i, lost)?; + writeln!(f, " Qubit status: [")?; + for (i, &occ) in self.qubit_status.iter().enumerate() { + writeln!(f, " Qubit {}: {:?}", i, occ)?; } writeln!(f, " ]")?; Ok(()) diff --git a/crates/ppvm-tableau/src/gates/clifford.rs b/crates/ppvm-tableau/src/gates/clifford.rs index 854728d6a..384b84b40 100644 --- a/crates/ppvm-tableau/src/gates/clifford.rs +++ b/crates/ppvm-tableau/src/gates/clifford.rs @@ -17,7 +17,7 @@ type MaskBuf = SmallVec<[<::Storage as BitView>::Store; 8]>; macro_rules! impl_generalized_tableau_clifford { ($name:ident) => { fn $name(&mut self, index: usize) { - if self.is_lost_or_leaked(index) { + if self.is_inactive(index) { return; } self.tableau.$name(index); @@ -29,7 +29,7 @@ macro_rules! impl_generalized_tableau_clifford { macro_rules! impl_generalized_tableau_clifford_pair { ($name:ident) => { fn $name(&mut self, control: usize, target: usize) { - if self.is_lost_or_leaked(control) || self.is_lost_or_leaked(target) { + if self.is_inactive(control) || self.is_inactive(target) { return; } self.tableau.$name(control, target); @@ -747,37 +747,17 @@ where } } -impl, I>> GeneralizedTableau -where - Complex<::Coeff>: From>, - ::Store: PrimInt, -{ - /// Fast path: check if any qubit in the slice is lost or leaked - #[inline] - fn any_lost_single(&self, indices: &[usize]) -> bool { - indices.iter().any(|&i| self.is_lost_or_leaked(i)) - } - - /// Fast path: check if any qubit pair has a lost or leaked qubit - #[inline] - fn any_lost_pair(&self, pairs: &[(usize, usize)]) -> bool { - pairs - .iter() - .any(|&(c, t)| self.is_lost_or_leaked(c) || self.is_lost_or_leaked(t)) - } -} - macro_rules! impl_gen_tableau_batch_single { ($name:ident) => { fn $name(&mut self, indices: &[usize]) { - if !self.any_lost_single(indices) { + if !self.any_inactive(indices) { self.tableau.$name(indices); return; } let filtered: Vec = indices .iter() .copied() - .filter(|&i| !self.is_lost_or_leaked(i)) + .filter(|&i| !self.is_inactive(i)) .collect(); self.tableau.$name(&filtered); } @@ -787,14 +767,14 @@ macro_rules! impl_gen_tableau_batch_single { macro_rules! impl_gen_tableau_batch_pair { ($name:ident) => { fn $name(&mut self, pairs: &[(usize, usize)]) { - if !self.any_lost_pair(pairs) { + if !self.any_inactive_pair(pairs) { self.tableau.$name(pairs); return; } let filtered: Vec<(usize, usize)> = pairs .iter() .copied() - .filter(|&(c, t)| !self.is_lost_or_leaked(c) && !self.is_lost_or_leaked(t)) + .filter(|&(c, t)| !self.is_inactive(c) && !self.is_inactive(t)) .collect(); self.tableau.$name(&filtered); } @@ -930,7 +910,7 @@ mod tests { fn test_sqrt_x_on_lost_qubit_is_noop() { let initial = rows(&GeneralizedTableau::new(1, 1e-12)); let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12); - tab.is_lost[0] = true; + tab.qubit_status[0] = QubitStatus::Lost; tab.sqrt_x(0); assert_eq!(rows(&tab), initial); } @@ -939,7 +919,7 @@ mod tests { fn test_sqrt_y_on_lost_qubit_is_noop() { let initial = rows(&GeneralizedTableau::new(1, 1e-12)); let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12); - tab.is_lost[0] = true; + tab.qubit_status[0] = QubitStatus::Lost; tab.sqrt_y(0); assert_eq!(rows(&tab), initial); } diff --git a/crates/ppvm-tableau/src/gates/reset.rs b/crates/ppvm-tableau/src/gates/reset.rs index 38c74b99d..5ae6c737b 100644 --- a/crates/ppvm-tableau/src/gates/reset.rs +++ b/crates/ppvm-tableau/src/gates/reset.rs @@ -46,7 +46,7 @@ where // Skip qubits outside the computational subspace. Currently a no-op for // loss (the `x` below is already skipped and `measure` returns `None`), // but leaked qubits must not be re-zeroed, and this short-cuts both. - if self.is_lost_or_leaked(addr0) { + if self.is_inactive(addr0) { return; } diff --git a/crates/ppvm-tableau/src/gates/rot1.rs b/crates/ppvm-tableau/src/gates/rot1.rs index a53f68c0f..b3a53f2b9 100644 --- a/crates/ppvm-tableau/src/gates/rot1.rs +++ b/crates/ppvm-tableau/src/gates/rot1.rs @@ -23,7 +23,7 @@ where + Copy, { fn rotate_1(&mut self, axis: Pauli, addr0: usize, theta: ::Coeff) { - if self.is_lost_or_leaked(addr0) { + if self.is_inactive(addr0) { return; } let (sin, cos) = (theta * 0.5.into()).sin_cos(); diff --git a/crates/ppvm-tableau/src/gates/rot2.rs b/crates/ppvm-tableau/src/gates/rot2.rs index 9ac1679f3..9077ffa9b 100644 --- a/crates/ppvm-tableau/src/gates/rot2.rs +++ b/crates/ppvm-tableau/src/gates/rot2.rs @@ -39,9 +39,9 @@ where let pauli_a = PAULIS[(axis_a_z << 1 | axis_a_x) as usize]; let pauli_b = PAULIS[(axis_b_z << 1 | axis_b_x) as usize]; // NOTE: if both qubits are lost/leaked, the rot1 will be a no-op - if self.is_lost_or_leaked(a) { + if self.is_inactive(a) { return self.rotate_1(pauli_b, b, theta); - } else if self.is_lost_or_leaked(b) { + } else if self.is_inactive(b) { return self.rotate_1(pauli_a, a, theta); } diff --git a/crates/ppvm-tableau/src/gates/tgate.rs b/crates/ppvm-tableau/src/gates/tgate.rs index 5ffa1ccfb..d9634e770 100644 --- a/crates/ppvm-tableau/src/gates/tgate.rs +++ b/crates/ppvm-tableau/src/gates/tgate.rs @@ -32,7 +32,7 @@ where >::Output>>::Output: PartialEq, { fn t(&mut self, index: usize) { - if self.is_lost_or_leaked(index) { + if self.is_inactive(index) { return; } @@ -42,7 +42,7 @@ where } fn t_dag(&mut self, index: usize) { - if self.is_lost_or_leaked(index) { + if self.is_inactive(index) { return; } diff --git a/crates/ppvm-tableau/src/lib.rs b/crates/ppvm-tableau/src/lib.rs index 76f51a881..d51e1c401 100644 --- a/crates/ppvm-tableau/src/lib.rs +++ b/crates/ppvm-tableau/src/lib.rs @@ -40,6 +40,9 @@ pub mod expectation; pub mod gates; /// Z-basis measurement, including loss-aware variants. pub mod measure; +/// Per-qubit [`QubitStatus`](qubit_status::QubitStatus) relative to the +/// computational subspace. +pub mod qubit_status; pub mod measure_all; @@ -57,6 +60,7 @@ pub mod tableau_like; /// Convenience re-exports for downstream code. pub mod prelude { pub use crate::data::{GeneralizedTableau, Tableau}; + pub use crate::qubit_status::QubitStatus; pub use crate::sparsevec::SparseVector; pub use crate::tableau_index::TableauIndex; pub use crate::tableau_like::TableauLike; diff --git a/crates/ppvm-tableau/src/measure.rs b/crates/ppvm-tableau/src/measure.rs index e386192c8..e7852b8ef 100644 --- a/crates/ppvm-tableau/src/measure.rs +++ b/crates/ppvm-tableau/src/measure.rs @@ -107,7 +107,7 @@ where I: TableauIndex + Debug, { fn measure(&mut self, addr0: usize) -> Option { - if self.is_lost[addr0] { + if self.qubit_status[addr0] == QubitStatus::Lost { self.measurement_record.push(None); return None; } diff --git a/crates/ppvm-tableau/src/measure_all.rs b/crates/ppvm-tableau/src/measure_all.rs index a3455985b..44158fa9d 100644 --- a/crates/ppvm-tableau/src/measure_all.rs +++ b/crates/ppvm-tableau/src/measure_all.rs @@ -11,7 +11,10 @@ use num::{ use ppvm_traits::{char::Pauli, config::Config}; use crate::measure::MeasureScratch; -use crate::{data::GeneralizedTableau, sparsevec::SparseVector, tableau_index::TableauIndex}; +use crate::{ + data::GeneralizedTableau, qubit_status::QubitStatus, sparsevec::SparseVector, + tableau_index::TableauIndex, +}; pub trait LossyMeasureAll { fn measure_all(&mut self) -> Vec>; @@ -117,7 +120,7 @@ where idx: usize, scratch: &mut MeasureScratch, ) -> Option { - if self.is_lost[idx] { + if self.qubit_status[idx] == QubitStatus::Lost { self.measurement_record.push(None); return None; } diff --git a/crates/ppvm-tableau/src/noise.rs b/crates/ppvm-tableau/src/noise.rs index 780ccc6f7..2cdec55b8 100644 --- a/crates/ppvm-tableau/src/noise.rs +++ b/crates/ppvm-tableau/src/noise.rs @@ -53,7 +53,7 @@ where #[inline] fn is_qubit_lost(&self, addr: usize) -> bool { - self.is_lost_or_leaked(addr) + self.is_inactive(addr) } } @@ -147,7 +147,14 @@ where if p < self.tableau.rng.random::() { return; } + if self.qubit_status[addr0] == QubitStatus::Lost { + return; + } + // Temporarily live so the collapse-to-|0⟩ is not skipped on a leaked + // qubit. Loss overwrites leakage: after this the status is Lost, + // indistinguishable from a qubit lost from the computational subspace. + self.qubit_status[addr0] = QubitStatus::Live; // NOTE: this is O(n^2) but also potentially removes coefficients, which is nice let outcome = self.measure(addr0); // A loss event is not a logical measurement: keep the measurement @@ -157,7 +164,7 @@ where // flip back to 0 self.x(addr0); } - self.is_lost[addr0] = true; + self.qubit_status[addr0] = QubitStatus::Lost; } } @@ -212,7 +219,7 @@ where /// branches the coefficient vector like an `rz`), so it is omitted to keep /// the channel cheap enough to apply after every gate. See issue #39. fn asymmetric_loss_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff) { - if self.is_lost[addr0] { + if self.qubit_status[addr0] == QubitStatus::Lost { return; } // State-dependent loss probability from the populations pop0/pop1. @@ -222,11 +229,13 @@ where if p_tot < self.tableau.rng.random::() { return; } - // Lost: collapse + reset to |0⟩, mirroring loss_channel. + // Lost: collapse + reset to |0⟩, mirroring loss_channel. Clear leakage + // first so the |1⟩ → |0⟩ flip is not skipped. + self.qubit_status[addr0] = QubitStatus::Live; if let Some(true) = self.measure(addr0) { self.x(addr0); } - self.is_lost[addr0] = true; + self.qubit_status[addr0] = QubitStatus::Lost; } } @@ -268,10 +277,10 @@ where addr1: usize, p: [::Coeff; 3], ) { - if self.is_lost[addr0] { + if self.qubit_status[addr0] == QubitStatus::Lost { self.loss_channel(addr1, p[2].clone()); return; - } else if self.is_lost[addr1] { + } else if self.qubit_status[addr1] == QubitStatus::Lost { self.loss_channel(addr0, p[2].clone()); return; } @@ -282,20 +291,24 @@ where cumulative += p_i.clone(); if cumulative > r { if i == 0 { - // both lost + // both lost — un-leak first so reset canonicalizes to |0⟩ + self.qubit_status[addr0] = QubitStatus::Live; + self.qubit_status[addr1] = QubitStatus::Live; self.reset(addr0); self.reset(addr1); - self.is_lost[addr0] = true; - self.is_lost[addr1] = true; + self.qubit_status[addr0] = QubitStatus::Lost; + self.qubit_status[addr1] = QubitStatus::Lost; } else { // only losing a single qubit, let choice = self.tableau.rng.random::(); if choice { + self.qubit_status[addr1] = QubitStatus::Live; self.reset(addr1); - self.is_lost[addr1] = true; + self.qubit_status[addr1] = QubitStatus::Lost; } else { + self.qubit_status[addr0] = QubitStatus::Live; self.reset(addr0); - self.is_lost[addr0] = true; + self.qubit_status[addr0] = QubitStatus::Lost; } } return; @@ -308,7 +321,9 @@ impl, I>> ResetLos for GeneralizedTableau { fn reset_loss_channel(&mut self, addr0: usize) { - self.is_lost[addr0] = false; + if self.qubit_status[addr0] == QubitStatus::Lost { + self.qubit_status[addr0] = QubitStatus::Live; + } } } @@ -335,7 +350,7 @@ where I: Debug, { fn leakage_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff) { - if self.is_lost_or_leaked(addr0) { + if self.is_inactive(addr0) { return; } @@ -364,7 +379,7 @@ where // Pin the qubit to |0⟩ (prob p0) or |1⟩ (prob p1). r < p_tot = p0 + p1 // here, so r < p0 selects |0⟩ and p0 <= r < p_tot selects |1⟩. The pin // must be applied before flagging the qubit leaked, otherwise the `x` - // gate would be skipped by `is_lost_or_leaked`. + // gate would be skipped by `is_inactive`. if p0 > r { if m { self.x(addr0); @@ -372,7 +387,7 @@ where } else if !m { self.x(addr0); } - self.is_leaked[addr0] = true; + self.qubit_status[addr0] = QubitStatus::Leaked; } } @@ -399,16 +414,12 @@ where + Copy, { fn reset_leakage_channel(&mut self, addr0: usize) { - if self.is_lost[addr0] { - // cannot recover a lost qubit + if self.qubit_status[addr0] != QubitStatus::Leaked { + // Live: no-op. Lost: cannot recover a lost qubit. return; } - if !self.is_leaked[addr0] { - return; - } - - self.is_leaked[addr0] = false; + self.qubit_status[addr0] = QubitStatus::Live; self.reset(addr0); } } @@ -439,7 +450,7 @@ mod tests { // With p=1.0 an error is always applied; verify is_lost is unaffected let mut t = tab(1); t.depolarize1(0, 1.0); - assert!(!t.is_lost[0]); + assert!(!t.is_lost(0)); } // === PauliError === @@ -537,20 +548,20 @@ mod tests { #[test] fn two_qubit_pauli_error_both_lost_no_change() { let mut t = tab(2); - t.is_lost[0] = true; - t.is_lost[1] = true; + t.qubit_status[0] = QubitStatus::Lost; + t.qubit_status[1] = QubitStatus::Lost; let mut p = [0.0f64; 15]; p[4] = 1.0; // XX — skipped entirely t.two_qubit_pauli_error(0, 1, p); - assert!(t.is_lost[0]); - assert!(t.is_lost[1]); + assert!(t.is_lost(0)); + assert!(t.is_lost(1)); } #[test] fn two_qubit_pauli_error_first_lost_no_apply() { // addr0 lost; p[0] = 1.0 (IX) → marginal p_x for addr1 = 1.0 let mut t = tab(2); - t.is_lost[0] = true; + t.qubit_status[0] = QubitStatus::Lost; let mut p = [0.0f64; 15]; p[0] = 1.0; // IX t.two_qubit_pauli_error(0, 1, p); @@ -570,17 +581,17 @@ mod tests { #[test] fn depolarize2_both_lost_no_change() { let mut t = tab(2); - t.is_lost[0] = true; - t.is_lost[1] = true; + t.qubit_status[0] = QubitStatus::Lost; + t.qubit_status[1] = QubitStatus::Lost; t.depolarize2(0, 1, 1.0); - assert!(t.is_lost[0]); - assert!(t.is_lost[1]); + assert!(t.is_lost(0)); + assert!(t.is_lost(1)); } #[test] fn depolarize2_first_lost_p0_second_unchanged() { let mut t = tab(2); - t.is_lost[0] = true; + t.qubit_status[0] = QubitStatus::Lost; t.depolarize2(0, 1, 0.0); // effective p on addr1 = 4/5 * 0 = 0 assert!(!t.measure(1).unwrap()); } @@ -588,7 +599,7 @@ mod tests { #[test] fn depolarize2_second_lost_p0_first_unchanged() { let mut t = tab(2); - t.is_lost[1] = true; + t.qubit_status[1] = QubitStatus::Lost; t.depolarize2(0, 1, 0.0); // effective p on addr0 = 4/5 * 0 = 0 assert!(!t.measure(0).unwrap()); } @@ -599,14 +610,14 @@ mod tests { fn loss_channel_p0_qubit_not_lost() { let mut t = tab(1); t.loss_channel(0, 0.0); - assert!(!t.is_lost[0]); + assert!(!t.is_lost(0)); } #[test] fn loss_channel_p1_qubit_marked_lost() { let mut t = tab(1); t.loss_channel(0, 1.0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); } #[test] @@ -615,7 +626,7 @@ mod tests { let mut t = tab(1); t.x(0); t.loss_channel(0, 1.0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); assert!(t.measure(0).is_none()); // Reset to |0⟩ before marking lost } @@ -635,7 +646,7 @@ mod tests { t.loss_channel(0, 1.0); t.x(0); // No-op: qubit is lost assert!(t.measure(0).is_none()); - t.is_lost[0] = false; + t.qubit_status[0] = QubitStatus::Live; assert!(!t.measure(0).unwrap()); // still 0 } @@ -644,8 +655,8 @@ mod tests { let mut t = tab(2); t.loss_channel(0, 0.0); t.loss_channel(1, 0.0); - assert!(!t.is_lost[0]); - assert!(!t.is_lost[1]); + assert!(!t.is_lost(0)); + assert!(!t.is_lost(1)); } // === ResetLossChannel === @@ -654,9 +665,9 @@ mod tests { fn reset_loss_channel_clears_lost_flag() { let mut t = tab(1); t.loss_channel(0, 1.0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); t.reset_loss_channel(0); - assert!(!t.is_lost[0]); + assert!(!t.is_lost(0)); } #[test] @@ -694,7 +705,7 @@ mod tests { let mut t_lost = tab(1); t_lost.tableau.rng = rand::SeedableRng::seed_from_u64(seed); - t_lost.is_lost[0] = true; + t_lost.qubit_status[0] = QubitStatus::Lost; t_lost.depolarize1(0, 0.3); let next_lost: f64 = t_lost.tableau.rng.random(); @@ -711,7 +722,7 @@ mod tests { let mut t_lost = tab(1); t_lost.tableau.rng = rand::SeedableRng::seed_from_u64(seed); - t_lost.is_lost[0] = true; + t_lost.qubit_status[0] = QubitStatus::Lost; t_lost.pauli_error(0, [0.1, 0.1, 0.1]); let next_lost: f64 = t_lost.tableau.rng.random(); @@ -818,8 +829,8 @@ mod tests { // All probabilities zero: neither qubit should be lost. let mut t = tab(2); t.correlated_loss_channel(0, 1, [0.0, 0.0, 0.0]); - assert!(!t.is_lost[0]); - assert!(!t.is_lost[1]); + assert!(!t.is_lost(0)); + assert!(!t.is_lost(1)); } #[test] @@ -827,8 +838,8 @@ mod tests { // p[0]=1 → both qubits always lost. let mut t = tab(2); t.correlated_loss_channel(0, 1, [1.0, 0.0, 0.0]); - assert!(t.is_lost[0]); - assert!(t.is_lost[1]); + assert!(t.is_lost(0)); + assert!(t.is_lost(1)); } #[test] @@ -840,7 +851,7 @@ mod tests { t.tableau.rng = rand::SeedableRng::seed_from_u64(seed); t.correlated_loss_channel(0, 1, [0.0, 1.0, 0.0]); assert!( - t.is_lost[0] ^ t.is_lost[1], + t.is_lost(0) ^ t.is_lost(1), "Expected exactly one lost qubit (seed {seed})" ); } @@ -855,7 +866,7 @@ mod tests { let mut t = tab(2); t.tableau.rng = rand::SeedableRng::seed_from_u64(seed); t.correlated_loss_channel(0, 1, [0.0, 1.0, 0.0]); - if t.is_lost[0] { + if t.is_lost(0) { addr0_lost += 1; } } @@ -874,11 +885,11 @@ mod tests { t.x(0); t.x(1); t.correlated_loss_channel(0, 1, [1.0, 0.0, 0.0]); - assert!(t.is_lost[0]); - assert!(t.is_lost[1]); + assert!(t.is_lost(0)); + assert!(t.is_lost(1)); // Restore so we can measure. - t.is_lost[0] = false; - t.is_lost[1] = false; + t.qubit_status[0] = QubitStatus::Live; + t.qubit_status[1] = QubitStatus::Live; assert!(!t.measure(0).unwrap()); assert!(!t.measure(1).unwrap()); } @@ -893,8 +904,8 @@ mod tests { t.tableau.rng = rand::SeedableRng::seed_from_u64(seed); t.x(0); // put addr0 in |1⟩ t.correlated_loss_channel(0, 1, [0.0, 1.0, 0.0]); - if t.is_lost[0] { - t.is_lost[0] = false; + if t.is_lost(0) { + t.qubit_status[0] = QubitStatus::Live; assert!(!t.measure(0).unwrap(), "Lost qubit should be reset to |0⟩"); return; } @@ -906,29 +917,29 @@ mod tests { fn correlated_loss_addr0_already_lost_applies_p2_to_addr1() { // addr0 already lost → addr1 should be lost with probability p[2]=1. let mut t = tab(2); - t.is_lost[0] = true; + t.qubit_status[0] = QubitStatus::Lost; t.correlated_loss_channel(0, 1, [0.0, 0.0, 1.0]); - assert!(t.is_lost[0]); - assert!(t.is_lost[1]); + assert!(t.is_lost(0)); + assert!(t.is_lost(1)); } #[test] fn correlated_loss_addr1_already_lost_applies_p2_to_addr0() { // addr1 already lost → addr0 should be lost with probability p[2]=1. let mut t = tab(2); - t.is_lost[1] = true; + t.qubit_status[1] = QubitStatus::Lost; t.correlated_loss_channel(0, 1, [0.0, 0.0, 1.0]); - assert!(t.is_lost[0]); - assert!(t.is_lost[1]); + assert!(t.is_lost(0)); + assert!(t.is_lost(1)); } #[test] fn correlated_loss_addr0_already_lost_p2_zero_addr1_survives() { // addr0 already lost, p[2]=0 → addr1 stays active. let mut t = tab(2); - t.is_lost[0] = true; + t.qubit_status[0] = QubitStatus::Lost; t.correlated_loss_channel(0, 1, [0.0, 0.0, 0.0]); - assert!(!t.is_lost[1]); + assert!(!t.is_lost(1)); } #[test] @@ -941,7 +952,7 @@ mod tests { let mut t = tab(2); t.tableau.rng = rand::SeedableRng::seed_from_u64(seed); t.correlated_loss_channel(0, 1, [p_both, 0.0, 0.0]); - if t.is_lost[0] && t.is_lost[1] { + if t.is_lost(0) && t.is_lost(1) { both_lost += 1; } } @@ -963,7 +974,7 @@ mod tests { let mut t = tab(2); t.tableau.rng = rand::SeedableRng::seed_from_u64(seed); t.correlated_loss_channel(0, 1, [0.0, p_single, 0.0]); - if t.is_lost[0] ^ t.is_lost[1] { + if t.is_lost(0) ^ t.is_lost(1) { one_lost += 1; } } @@ -1004,11 +1015,11 @@ mod tests { // |0⟩: pop0 = 1, so p_tot = p0. let mut t = tab(1); t.asymmetric_loss_channel(0, 1.0, 0.0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); let mut t = tab(1); t.asymmetric_loss_channel(0, 0.0, 1.0); // p_tot = 0 - assert!(!t.is_lost[0]); + assert!(!t.is_lost(0)); } #[test] @@ -1017,27 +1028,27 @@ mod tests { let mut t = tab(1); t.x(0); t.asymmetric_loss_channel(0, 0.0, 1.0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); let mut t = tab(1); t.x(0); t.asymmetric_loss_channel(0, 1.0, 0.0); // p_tot = 0 - assert!(!t.is_lost[0]); + assert!(!t.is_lost(0)); } #[test] fn asymmetric_loss_zero_prob_not_lost() { let mut t = tab(1); t.asymmetric_loss_channel(0, 0.0, 0.0); - assert!(!t.is_lost[0]); + assert!(!t.is_lost(0)); } #[test] fn asymmetric_loss_already_lost_is_noop() { let mut t = tab(1); - t.is_lost[0] = true; + t.qubit_status[0] = QubitStatus::Lost; t.asymmetric_loss_channel(0, 1.0, 1.0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); } #[test] @@ -1046,8 +1057,8 @@ mod tests { let mut t = tab(1); t.x(0); t.asymmetric_loss_channel(0, 0.0, 1.0); - assert!(t.is_lost[0]); - t.is_lost[0] = false; + assert!(t.is_lost(0)); + t.qubit_status[0] = QubitStatus::Live; assert!(!t.measure(0).unwrap()); } @@ -1062,7 +1073,7 @@ mod tests { t.tableau.rng = rand::SeedableRng::seed_from_u64(seed); t.h(0); t.asymmetric_loss_channel(0, p, p); - if t.is_lost[0] { + if t.is_lost(0) { lost += 1; } } @@ -1082,7 +1093,7 @@ mod tests { t.tableau.rng = rand::SeedableRng::seed_from_u64(seed); t.h(0); t.asymmetric_loss_channel(0, p0, p1); - if t.is_lost[0] { + if t.is_lost(0) { lost += 1; } } @@ -1100,8 +1111,8 @@ mod tests { // p0 = p1 = 0 → p_tot = 0, never leaks; the qubit stays live. let mut t = tab(1); t.leakage_channel(0, 0.0, 0.0); - assert!(!t.is_leaked[0]); - assert!(!t.is_lost[0]); + assert!(!t.is_leaked(0)); + assert!(!t.is_lost(0)); assert!(!t.measure(0).unwrap()); } @@ -1111,8 +1122,8 @@ mod tests { let mut t = tab(1); t.x(0); t.leakage_channel(0, 1.0, 0.0); - assert!(t.is_leaked[0]); - assert!(!t.is_lost[0]); // leaked, not lost + assert!(t.is_leaked(0)); + assert!(!t.is_lost(0)); // leaked, not lost assert_eq!(t.measure(0), Some(false)); } @@ -1121,8 +1132,8 @@ mod tests { // Start in |0⟩; leak-to-|1⟩ (p1 = 1) must pin the qubit to |1⟩. let mut t = tab(1); t.leakage_channel(0, 0.0, 1.0); - assert!(t.is_leaked[0]); - assert!(!t.is_lost[0]); + assert!(t.is_leaked(0)); + assert!(!t.is_lost(0)); assert_eq!(t.measure(0), Some(true)); } @@ -1151,7 +1162,7 @@ mod tests { t.tableau.rng = rand::SeedableRng::seed_from_u64(7); t.h(0); // |+⟩ t.leakage_channel(0, 0.0, 1.0); - assert!(t.is_leaked[0]); + assert!(t.is_leaked(0)); assert_eq!(t.measure(0), Some(true)); assert_eq!(t.measure(0), Some(true)); } @@ -1202,17 +1213,34 @@ mod tests { let mut t = tab(1); t.leakage_channel(0, 0.0, 1.0); t.loss_channel(0, 1.0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); assert!(t.measure(0).is_none()); } + #[test] + fn loss_of_leaked_qubit_is_indistinguishable_from_lost() { + // Loss overwrites leakage: the qubit is Lost, not Leaked-and-Lost. + // After reset_loss it must be a live |0⟩, same as a qubit that was + // lost from the computational subspace — the leaked pin must not + // survive in the tableau. + let mut t = tab(1); + t.leakage_channel(0, 0.0, 1.0); // pin |1⟩ + t.loss_channel(0, 1.0); + assert!(t.is_lost(0)); + assert!(!t.is_leaked(0)); + t.reset_loss_channel(0); + assert!(!t.is_lost(0)); + assert!(!t.is_leaked(0)); + assert_eq!(t.measure(0), Some(false)); + } + #[test] fn reset_skips_leaked_qubit() { // reset must not re-zero a leaked qubit. let mut t = tab(1); t.leakage_channel(0, 0.0, 1.0); // leaked, |1⟩ t.reset(0); - assert!(t.is_leaked[0]); + assert!(t.is_leaked(0)); assert_eq!(t.measure(0), Some(true)); } @@ -1224,8 +1252,8 @@ mod tests { let mut t = tab(1); t.leakage_channel(0, 0.0, 1.0); // leaked, pinned |1⟩ t.reset_leakage_channel(0); - assert!(!t.is_leaked[0]); - assert!(!t.is_lost[0]); + assert!(!t.is_leaked(0)); + assert!(!t.is_lost(0)); assert!(t.current_measurement_record().is_empty()); // record-neutral assert_eq!(t.measure(0), Some(false)); // back in |0⟩ } @@ -1244,9 +1272,9 @@ mod tests { fn reset_leakage_channel_does_not_recover_lost() { // A lost qubit cannot be brought back by leakage reduction. let mut t = tab(1); - t.is_lost[0] = true; + t.qubit_status[0] = QubitStatus::Lost; t.reset_leakage_channel(0); - assert!(t.is_lost[0]); + assert!(t.is_lost(0)); assert!(t.measure(0).is_none()); } @@ -1256,7 +1284,7 @@ mod tests { let mut t = tab(1); t.x(0); // |1⟩ t.reset_leakage_channel(0); - assert!(!t.is_leaked[0]); + assert!(!t.is_leaked(0)); assert_eq!(t.measure(0), Some(true)); // unchanged, still |1⟩ } } diff --git a/crates/ppvm-tableau/src/qubit_status.rs b/crates/ppvm-tableau/src/qubit_status.rs new file mode 100644 index 000000000..f29b9eee2 --- /dev/null +++ b/crates/ppvm-tableau/src/qubit_status.rs @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use num::complex::Complex; + +use crate::data::GeneralizedTableau; +use crate::sparsevec::SparseVector; +use ppvm_traits::config::Config; + +/// Per-qubit status relative to the computational subspace. +/// +/// Loss and leakage both take a qubit out of the computational subspace, so +/// gates skip any status other than [`QubitStatus::Live`]. They differ only +/// at measurement: a lost qubit reports `None`, a leaked qubit reports the +/// pinned computational bit. Loss overwrites leakage — a leaked qubit that is +/// later lost is [`QubitStatus::Lost`], indistinguishable from a qubit lost +/// from the computational subspace. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum QubitStatus { + /// Qubit is in the computational subspace; gates apply normally. + #[default] + Live = 0, + /// Qubit is pinned to a computational basis state. Gates skip it; + /// measurement returns the pinned `0`/`1`. + Leaked = 1, + /// Qubit has been lost. Gates skip it; measurement returns `None`. + Lost = 2, +} + +impl QubitStatus { + /// Outside the computational subspace — lost or leaked. + #[inline] + pub const fn is_inactive(self) -> bool { + (self as u8) != 0 + } + + #[inline] + pub const fn is_lost(self) -> bool { + matches!(self, QubitStatus::Lost) + } + + #[inline] + pub const fn is_leaked(self) -> bool { + matches!(self, QubitStatus::Leaked) + } +} + +impl, I>> GeneralizedTableau { + /// Whether qubit `addr0` is outside the computational subspace — either lost + /// or leaked. Gates skip such qubits. + #[inline] + pub fn is_inactive(&self, addr0: usize) -> bool { + self.qubit_status[addr0].is_inactive() + } + + /// Whether qubit `addr0` is lost. Measurement of a lost qubit returns `None`. + #[inline] + pub fn is_lost(&self, addr0: usize) -> bool { + self.qubit_status[addr0].is_lost() + } + + /// Whether qubit `addr0` is leaked. Measurement of a leaked qubit returns + /// the pinned computational bit. + #[inline] + pub fn is_leaked(&self, addr0: usize) -> bool { + self.qubit_status[addr0].is_leaked() + } + + /// Whether any qubit in `indices` is lost or leaked. + #[inline] + pub(crate) fn any_inactive(&self, indices: &[usize]) -> bool { + indices.iter().any(|&i| self.is_inactive(i)) + } + + /// Whether any pair has a lost or leaked control or target. + #[inline] + pub(crate) fn any_inactive_pair(&self, pairs: &[(usize, usize)]) -> bool { + pairs + .iter() + .any(|&(c, t)| self.is_inactive(c) || self.is_inactive(t)) + } +} diff --git a/crates/ppvm-tableau/tests/gates.rs b/crates/ppvm-tableau/tests/gates.rs index 8d3dc4e22..991ef6b44 100644 --- a/crates/ppvm-tableau/tests/gates.rs +++ b/crates/ppvm-tableau/tests/gates.rs @@ -339,7 +339,7 @@ fn test_generalized_tableau_reset_from_superposition() { fn test_generalized_tableau_reset_lost_qubit() { // Reset on a lost qubit: measure returns None, so reset should not flip let mut g: GTab = GeneralizedTableau::new_with_seed(1, 1e-12, 42); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.reset(0); // Measurement of lost qubit returns None assert_eq!(g.measure(0), None); @@ -381,7 +381,7 @@ fn test_tableau_reset_preserves_other_qubits() { fn test_lost_qubit_x_is_noop() { let initial = snapshot(&GeneralizedTableau::new(1, 1e-12)); let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.x(0); assert_eq!(snapshot(&g), initial); } @@ -390,7 +390,7 @@ fn test_lost_qubit_x_is_noop() { fn test_lost_qubit_y_is_noop() { let initial = snapshot(&GeneralizedTableau::new(1, 1e-12)); let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.y(0); assert_eq!(snapshot(&g), initial); } @@ -399,7 +399,7 @@ fn test_lost_qubit_y_is_noop() { fn test_lost_qubit_z_is_noop() { let initial = snapshot(&GeneralizedTableau::new(1, 1e-12)); let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.z(0); assert_eq!(snapshot(&g), initial); } @@ -408,7 +408,7 @@ fn test_lost_qubit_z_is_noop() { fn test_lost_qubit_h_is_noop() { let initial = snapshot(&GeneralizedTableau::new(1, 1e-12)); let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.h(0); assert_eq!(snapshot(&g), initial); } @@ -417,7 +417,7 @@ fn test_lost_qubit_h_is_noop() { fn test_lost_qubit_s_is_noop() { let initial = snapshot(&GeneralizedTableau::new(1, 1e-12)); let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.s(0); assert_eq!(snapshot(&g), initial); } @@ -426,7 +426,7 @@ fn test_lost_qubit_s_is_noop() { fn test_lost_qubit_s_dag_is_noop() { let initial = snapshot(&GeneralizedTableau::new(1, 1e-12)); let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.s_dag(0); assert_eq!(snapshot(&g), initial); } @@ -435,7 +435,7 @@ fn test_lost_qubit_s_dag_is_noop() { fn test_lost_control_cnot_is_noop() { let initial = snapshot(&GeneralizedTableau::new(2, 1e-12)); let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.cnot(0, 1); assert_eq!(snapshot(&g), initial); } @@ -444,7 +444,7 @@ fn test_lost_control_cnot_is_noop() { fn test_lost_target_cnot_is_noop() { let initial = snapshot(&GeneralizedTableau::new(2, 1e-12)); let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[1] = true; + g.qubit_status[1] = QubitStatus::Lost; g.cnot(0, 1); assert_eq!(snapshot(&g), initial); } @@ -453,7 +453,7 @@ fn test_lost_target_cnot_is_noop() { fn test_lost_control_cz_is_noop() { let initial = snapshot(&GeneralizedTableau::new(2, 1e-12)); let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.cz(0, 1); assert_eq!(snapshot(&g), initial); } @@ -462,7 +462,7 @@ fn test_lost_control_cz_is_noop() { fn test_lost_target_cz_is_noop() { let initial = snapshot(&GeneralizedTableau::new(2, 1e-12)); let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[1] = true; + g.qubit_status[1] = QubitStatus::Lost; g.cz(0, 1); assert_eq!(snapshot(&g), initial); } @@ -471,7 +471,7 @@ fn test_lost_target_cz_is_noop() { fn test_lost_control_cy_is_noop() { let initial = snapshot(&GeneralizedTableau::new(2, 1e-12)); let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.cy(0, 1); assert_eq!(snapshot(&g), initial); } @@ -480,7 +480,7 @@ fn test_lost_control_cy_is_noop() { fn test_lost_target_cy_is_noop() { let initial = snapshot(&GeneralizedTableau::new(2, 1e-12)); let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[1] = true; + g.qubit_status[1] = QubitStatus::Lost; g.cy(0, 1); assert_eq!(snapshot(&g), initial); } @@ -493,7 +493,7 @@ fn test_lost_qubit_t_is_noop() { initial_coeffs_len = g.coefficients.len(); } let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.t(0); assert_eq!( g.coefficients.len(), @@ -505,7 +505,7 @@ fn test_lost_qubit_t_is_noop() { #[test] fn test_lost_qubit_t_dag_is_noop() { let mut g: GTab = GeneralizedTableau::new(1, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.t_dag(0); assert_eq!( g.coefficients.len(), @@ -659,7 +659,7 @@ fn test_rot2_lost_qubit_a_falls_back_to_rot1_on_b() { // If qubit a is lost, rxx(a,b,θ) should fall back to rx(b,θ) // rx(π)|0⟩ = -i|1⟩ let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.rxx(0, 1, PI); // Qubit 1 should have been flipped by rx(π) assert!(g.measure(1).unwrap(), "rx fallback should flip qubit 1"); @@ -669,7 +669,7 @@ fn test_rot2_lost_qubit_a_falls_back_to_rot1_on_b() { fn test_rot2_lost_qubit_b_falls_back_to_rot1_on_a() { // If qubit b is lost, rxx(a,b,θ) should fall back to rx(a,θ) let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[1] = true; + g.qubit_status[1] = QubitStatus::Lost; g.rxx(0, 1, PI); assert!(g.measure(0).unwrap(), "rx fallback should flip qubit 0"); } @@ -678,8 +678,8 @@ fn test_rot2_lost_qubit_b_falls_back_to_rot1_on_a() { fn test_rot2_both_lost_is_noop() { // If both qubits are lost, rotate_2 calls rotate_1 on b which is also lost → no-op let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[0] = true; - g.is_lost[1] = true; + g.qubit_status[0] = QubitStatus::Lost; + g.qubit_status[1] = QubitStatus::Lost; g.rxx(0, 1, PI); // No branching, no state change assert_eq!(g.coefficients.len(), 1); @@ -690,7 +690,7 @@ fn test_rxy_lost_a_falls_back_to_ry_on_b() { // rxy with qubit a lost → ry(b, θ) // ry(π)|0⟩ = |1⟩ let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.rxy(0, 1, PI); assert!(g.measure(1).unwrap(), "ry fallback should flip qubit 1"); } @@ -699,7 +699,7 @@ fn test_rxy_lost_a_falls_back_to_ry_on_b() { fn test_rxz_lost_b_falls_back_to_rx_on_a() { // rxz with qubit b lost → rx(a, θ) let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[1] = true; + g.qubit_status[1] = QubitStatus::Lost; g.rxz(0, 1, PI); assert!(g.measure(0).unwrap(), "rx fallback should flip qubit 0"); } @@ -709,7 +709,7 @@ fn test_rzz_lost_a_falls_back_to_rz_on_b() { // rzz with qubit a lost → rz(b, θ) // rz leaves |0⟩ invariant (just adds phase) let mut g: GTab = GeneralizedTableau::new(2, 1e-12); - g.is_lost[0] = true; + g.qubit_status[0] = QubitStatus::Lost; g.rzz(0, 1, PI); assert!(!g.measure(1).unwrap(), "rz on |0⟩ should stay |0⟩"); assert_eq!(g.coefficients.len(), 1, "rz on |0⟩ should not branch"); From 7228f7fbd15f834642bfbe86353a17fa65ba7920 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Tue, 1 Sep 2026 13:54:11 +0200 Subject: [PATCH 06/11] Update GeneralizedTableauSum to use new enum --- .../benches/parallelization_test.rs | 2 +- crates/ppvm-tableau-sum/src/data.rs | 25 +++++++++----- crates/ppvm-tableau-sum/src/measure.rs | 2 +- crates/ppvm-tableau-sum/src/noise.rs | 30 ++++++++-------- crates/ppvm-tableau-sum/src/storage/map.rs | 2 +- crates/ppvm-tableau-sum/src/storage/mod.rs | 34 ++++++++++--------- crates/ppvm-tableau-sum/src/storage/vec.rs | 2 +- 7 files changed, 54 insertions(+), 43 deletions(-) diff --git a/crates/ppvm-tableau-sum/benches/parallelization_test.rs b/crates/ppvm-tableau-sum/benches/parallelization_test.rs index bc4e2702c..143c99641 100644 --- a/crates/ppvm-tableau-sum/benches/parallelization_test.rs +++ b/crates/ppvm-tableau-sum/benches/parallelization_test.rs @@ -89,7 +89,7 @@ fn branch_entry( addr0: usize, ) -> SmallVec<[(Tableau, f64, u64, u64); 3]> { let mut out: SmallVec<[(Tableau, f64, u64, u64); 3]> = SmallVec::new(); - if tab.is_lost[addr0] { + if tab.is_lost(addr0) { return out; } let word_fp = word_fingerprint(tab); diff --git a/crates/ppvm-tableau-sum/src/data.rs b/crates/ppvm-tableau-sum/src/data.rs index e2faebf4e..949f43a00 100644 --- a/crates/ppvm-tableau-sum/src/data.rs +++ b/crates/ppvm-tableau-sum/src/data.rs @@ -151,6 +151,7 @@ where mod tests { use super::*; use ppvm_pauli_sum::config::fxhash::ByteF64; + use ppvm_tableau::data::QubitStatus; use ppvm_traits::traits::{ Clifford, Depolarizing, LossChannel, LossyMeasure, PauliError, Reset, ResetLossChannel, TGate, @@ -192,7 +193,13 @@ mod tests { assert_eq!(tab.len(), 1); assert_eq!(tab.n_qubits, 3); assert!((tab.entries.entries[0].1 - 1.0).abs() < 1e-12); - assert!(tab.entries.entries[0].0.is_lost.iter().all(|x| !x)); + assert!( + tab.entries.entries[0] + .0 + .qubit_status + .iter() + .all(|&occ| occ == QubitStatus::Live) + ); } #[test] @@ -257,7 +264,7 @@ mod tests { tab.loss_channel(0, 0.0); assert_eq!(tab.len(), 1); assert!((tab.entries.entries[0].1 - 1.0).abs() < 1e-12); - assert!(!tab.entries.entries[0].0.is_lost[0]); + assert!(!tab.entries.entries[0].0.is_lost(0)); } #[test] @@ -266,7 +273,7 @@ mod tests { tab.loss_channel(0, 0.5); assert_eq!(tab.len(), 2); assert!((sum_of_probabilities(&tab) - 1.0).abs() < 1e-12); - let lost_count = tab.entries.iter().filter(|e| e.0.is_lost[0]).count(); + let lost_count = tab.entries.iter().filter(|e| e.0.is_lost(0)).count(); assert_eq!(lost_count, 1); for entry in tab.entries.iter() { assert!((*entry.1 - 0.5).abs() < 1e-12); @@ -342,7 +349,7 @@ mod tests { assert_eq!(tab.len(), 1); let (entry, p) = tab.entries.iter().next().unwrap(); - assert!(!entry.is_lost[0]); + assert!(!entry.is_lost(0)); assert!((*p - 1.0).abs() < 1e-12); } @@ -357,7 +364,7 @@ mod tests { assert_eq!(tab.len(), 1); assert_eq!(tab.entries.buckets.len(), 1); let (entry, p) = tab.entries.iter().next().unwrap(); - assert!(!entry.is_lost[0]); + assert!(!entry.is_lost(0)); assert!((*p - 1.0).abs() < 1e-12); } @@ -396,7 +403,7 @@ mod tests { assert_eq!(tab.len(), 1); assert!((sum_of_probabilities(&tab) - 1.0).abs() < 1e-12); - assert!(tab.entries.entries[0].0.is_lost[0]); + assert!(tab.entries.entries[0].0.is_lost(0)); } #[test] @@ -479,8 +486,8 @@ mod tests { tab.loss_channel(0, 1.0); assert_eq!(tab.len(), 1); assert!((tab.entries.entries[0].1 - 1.0).abs() < 1e-12); - assert!(tab.entries.entries[0].0.is_lost[0]); - assert!(!tab.entries.entries[0].0.is_lost[1]); + assert!(tab.entries.entries[0].0.is_lost(0)); + assert!(!tab.entries.entries[0].0.is_lost(1)); } #[test] @@ -493,7 +500,7 @@ mod tests { tab.loss_channel(0, 0.5); assert_eq!(tab.len(), 1); assert!((tab.entries.entries[0].1 - 1.0).abs() < 1e-12); - assert!(tab.entries.entries[0].0.is_lost[0]); + assert!(tab.entries.entries[0].0.is_lost(0)); } #[test] diff --git a/crates/ppvm-tableau-sum/src/measure.rs b/crates/ppvm-tableau-sum/src/measure.rs index ebdace960..95233a69f 100644 --- a/crates/ppvm-tableau-sum/src/measure.rs +++ b/crates/ppvm-tableau-sum/src/measure.rs @@ -74,7 +74,7 @@ where self.entries .for_each_mut_with_keys(|tab, p_sum, word_fp, phase_loss_fp| { - if tab.is_lost[addr0] { + if tab.is_lost(addr0) { // NOTE: deterministically outputs lost, no branching let _ = per_branch(tab, None, &*p_sum); return; diff --git a/crates/ppvm-tableau-sum/src/noise.rs b/crates/ppvm-tableau-sum/src/noise.rs index eedbde003..a61171fd0 100644 --- a/crates/ppvm-tableau-sum/src/noise.rs +++ b/crates/ppvm-tableau-sum/src/noise.rs @@ -10,7 +10,9 @@ use num::{ }; use ppvm_pauli_word::pattern::NotIdentity; use ppvm_tableau::{ - data::GeneralizedTableau, sparsevec::SparseVector, tableau_index::TableauIndex, + data::{GeneralizedTableau, QubitStatus}, + sparsevec::SparseVector, + tableau_index::TableauIndex, }; use ppvm_traits::config::Config; use ppvm_traits::traits::{ @@ -60,14 +62,14 @@ fn single_qubit_loss_branch( I: TableauIndex + Send + Sync + Debug, C: SparseVector, I>, { - if tab.is_lost[addr0] { + if tab.is_lost(addr0) { // Don't branch if it's already lost return; } let tab_seed = rng.random::(); let mut tab_branch = tab.fork(Some(tab_seed)); - tab_branch.is_lost[addr0] = true; + tab_branch.qubit_status[addr0] = QubitStatus::Lost; // is_lost flip leaves the Pauli words and phases unchanged, so // the branch reuses its parent's word-fingerprint and the only // change to the phase/loss hash is the lost qubit's mask. @@ -121,7 +123,7 @@ where // parent_idx aligns with for_each_mut_with_keys' order. let parent_idx = idx; idx += 1; - if tab.is_lost[addr0] { + if tab.is_lost(addr0) { return; } branches.push(( @@ -228,7 +230,7 @@ where .for_each_mut_with_keys(|tab, p_sum, word_fp, phase_loss| { let parent_idx = idx; idx += 1; - if tab.is_lost[addr0] { + if tab.is_lost(addr0) { return; } @@ -354,7 +356,7 @@ where self.entries .for_each_mut_with_keys(|tab, p_sum, word_fp, phase_loss| { - if tab.is_lost[addr0] || tab.is_lost[addr1] { + if tab.is_lost(addr0) || tab.is_lost(addr1) { return; } @@ -456,7 +458,7 @@ where self.entries .for_each_mut_with_keys(|tab, p_sum, word_fp, phase_loss| { // if either is lost already, we just lose the other with probability p[2] - if tab.is_lost[addr0] { + if tab.is_lost(addr0) { single_qubit_loss_branch( addr1, &p[2], @@ -467,7 +469,7 @@ where (word_fp, phase_loss), ); return; - } else if tab.is_lost[addr1] { + } else if tab.is_lost(addr1) { single_qubit_loss_branch( addr0, &p[2], @@ -485,8 +487,8 @@ where let tab_seed_both = self.rng.random::(); let mut tab_lose_both = tab.fork(Some(tab_seed_both)); - tab_lose_both.is_lost[addr0] = true; - tab_lose_both.is_lost[addr1] = true; + tab_lose_both.qubit_status[addr0] = QubitStatus::Lost; + tab_lose_both.qubit_status[addr1] = QubitStatus::Lost; // is_lost flip leaves the Pauli words and phases unchanged, so // the branch reuses its parent's word-fingerprint and the only @@ -500,7 +502,7 @@ where let tab_seed_0 = self.rng.random::(); let mut tab_lose_0 = tab.fork(Some(tab_seed_0)); - tab_lose_0.is_lost[addr0] = true; + tab_lose_0.qubit_status[addr0] = QubitStatus::Lost; branches.push(( tab_lose_0, p_sum.clone() * (p[1].clone() / 2.0.into()), @@ -510,7 +512,7 @@ where let tab_seed_1 = self.rng.random::(); let mut tab_lose_1 = tab.fork(Some(tab_seed_1)); - tab_lose_1.is_lost[addr1] = true; + tab_lose_1.qubit_status[addr1] = QubitStatus::Lost; branches.push(( tab_lose_1, p_sum.clone() * (p[1].clone() / 2.0.into()), @@ -560,9 +562,9 @@ where { fn reset_loss_channel(&mut self, addr0: usize) { let delta = loss_mask(addr0); - let mut branches = self.entries.drain_where(|tab| tab.is_lost[addr0]); + let mut branches = self.entries.drain_where(|tab| tab.is_lost(addr0)); for (tab, _, _, phase_loss) in branches.iter_mut() { - tab.is_lost[addr0] = false; + tab.qubit_status[addr0] = QubitStatus::Live; *phase_loss ^= delta; } // reset_loss preserves total probability mass and never drops entries diff --git a/crates/ppvm-tableau-sum/src/storage/map.rs b/crates/ppvm-tableau-sum/src/storage/map.rs index 7242f3cdf..c4ed2807b 100644 --- a/crates/ppvm-tableau-sum/src/storage/map.rs +++ b/crates/ppvm-tableau-sum/src/storage/map.rs @@ -135,7 +135,7 @@ where .values() .flat_map(|v| v.iter()) .next() - .map(|(t, _)| RowMasks::new(t.is_lost.len())); + .map(|(t, _)| RowMasks::new(t.qubit_status.len())); if let Some(masks) = masks { for v in self.buckets.values_mut() { for (tab, c) in v.iter_mut() { diff --git a/crates/ppvm-tableau-sum/src/storage/mod.rs b/crates/ppvm-tableau-sum/src/storage/mod.rs index c4320146c..e1358a100 100644 --- a/crates/ppvm-tableau-sum/src/storage/mod.rs +++ b/crates/ppvm-tableau-sum/src/storage/mod.rs @@ -24,7 +24,9 @@ use num::{ }; use ppvm_pauli_word::pattern::NotIdentity; use ppvm_tableau::{ - data::GeneralizedTableau, sparsevec::SparseVector, tableau_index::TableauIndex, + data::{GeneralizedTableau, QubitStatus}, + sparsevec::SparseVector, + tableau_index::TableauIndex, }; use ppvm_traits::config::Config; #[cfg(target_arch = "wasm32")] @@ -155,7 +157,7 @@ where // Single implementation: build a one-shot mask table and delegate so the // table-indexed and from-scratch values are guaranteed identical. // `is_lost.len() == n_qubits` and is available under these minimal bounds. - let masks = RowMasks::new(tab.is_lost.len()); + let masks = RowMasks::new(tab.qubit_status.len()); phase_loss_hash_with(tab, &masks) } @@ -180,8 +182,8 @@ where h ^= masks.sign[row]; } } - for (q, lost) in tab.is_lost.iter().enumerate() { - if *lost { + for (q, status) in tab.qubit_status.iter().enumerate() { + if status.is_lost() { h ^= masks.loss[q]; } } @@ -247,7 +249,7 @@ where { // NOTE: comparing is_lost and rows is only necessary to avoid hash collisions - if tab0.is_lost != tab1.is_lost { + if tab0.qubit_status != tab1.qubit_status { return false; } @@ -313,7 +315,7 @@ pub(crate) fn apply_branch_mutation( NotIdentity::Z => tab.z(addr0), }, BranchMutation::Loss { q } => { - tab.is_lost[q] = true; + tab.qubit_status[q] = QubitStatus::Lost; } } } @@ -348,25 +350,25 @@ where match m { BranchMutation::Loss { q } => { - // Virtual is_lost == parent's with index q forced true. - if existing.is_lost.len() != parent.is_lost.len() { + // Virtual qubit_status == parent's with index q forced Lost. + if existing.qubit_status.len() != parent.qubit_status.len() { return false; } for (i, (&e, &p)) in existing - .is_lost + .qubit_status .iter() - .zip(parent.is_lost.iter()) + .zip(parent.qubit_status.iter()) .enumerate() { - let virt = if i == q { true } else { p }; + let virt = if i == q { QubitStatus::Lost } else { p }; if e != virt { return false; } } } BranchMutation::Pauli { .. } => { - // Virtual is_lost == parent's, unchanged. - if existing.is_lost != parent.is_lost { + // Virtual qubit_status == parent's, unchanged. + if existing.qubit_status != parent.qubit_status { return false; } } @@ -441,7 +443,7 @@ mod fingerprint_tests { fingerprint, loss_mask, pauli_branch_phase_loss, phase_loss_hash, word_fingerprint, }; use ppvm_pauli_sum::config::fxhash::ByteF64; - use ppvm_tableau::data::GeneralizedTableau; + use ppvm_tableau::data::{GeneralizedTableau, QubitStatus}; use ppvm_traits::traits::Clifford; type Cfg = ByteF64<1>; @@ -489,7 +491,7 @@ mod fingerprint_tests { // Marking a qubit lost must equal XORing loss_mask(q) into the hash. let parent = make(); let mut branch = parent.clone(); - branch.is_lost[1] = true; + branch.qubit_status[1] = QubitStatus::Lost; assert_eq!( phase_loss_hash(&parent) ^ loss_mask(1), phase_loss_hash(&branch) @@ -533,7 +535,7 @@ mod fingerprint_tests { } let mut b = parent.clone(); - b.is_lost[0] = true; + b.qubit_status[0] = QubitStatus::Lost; assert_eq!(word_fingerprint(&b), parent_word, "loss changed word-hash"); assert_eq!( parent_word ^ phase_loss_hash(&b), diff --git a/crates/ppvm-tableau-sum/src/storage/vec.rs b/crates/ppvm-tableau-sum/src/storage/vec.rs index 7a13e23e8..8b9dfafbe 100644 --- a/crates/ppvm-tableau-sum/src/storage/vec.rs +++ b/crates/ppvm-tableau-sum/src/storage/vec.rs @@ -112,7 +112,7 @@ where // Build the per-row mask table once for all entries (every tableau in a // sum shares the same qubit count). Skip when there are no entries. if let Some((first, _)) = self.entries.first() { - let masks = RowMasks::new(first.is_lost.len()); + let masks = RowMasks::new(first.qubit_status.len()); for (t, _) in self.entries.iter() { let wfp = word_fingerprint(t); let plh = phase_loss_hash_with(t, &masks); From c9e384282d736343cc44344834f4ed379ee15b64 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Tue, 1 Sep 2026 14:47:36 +0200 Subject: [PATCH 07/11] Expose leakage to python interface --- .../ppvm-python-native/ppvm_python_native.pyi | 2 + .../src/interface_tableau.rs | 12 ++++++ ppvm-python/src/ppvm/_core.pyi | 2 + ppvm-python/src/ppvm/generalized_tableau.py | 23 ++++++++++ .../test/generalized_tableau/test_loss.py | 42 ++++++++++++++++++- 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/ppvm-python-native/ppvm_python_native.pyi b/crates/ppvm-python-native/ppvm_python_native.pyi index 20ebe6e38..0ca6f566d 100644 --- a/crates/ppvm-python-native/ppvm_python_native.pyi +++ b/crates/ppvm-python-native/ppvm_python_native.pyi @@ -141,7 +141,9 @@ class _GeneralizedTableauBase: def reset_loss_channel(self, addr0: int) -> None: ... def reset(self, addr0: int) -> None: ... def is_lost(self, addr0: int) -> bool: ... + def is_leaked(self, addr0: int) -> bool: ... def loss_values(self) -> list[bool]: ... + def leakage_values(self) -> list[bool]: ... def run(self, prog: "StimProgram") -> list[int]: ... @staticmethod def sample( diff --git a/crates/ppvm-python-native/src/interface_tableau.rs b/crates/ppvm-python-native/src/interface_tableau.rs index 2a0062b6a..665324171 100644 --- a/crates/ppvm-python-native/src/interface_tableau.rs +++ b/crates/ppvm-python-native/src/interface_tableau.rs @@ -298,6 +298,10 @@ macro_rules! create_interface { self.inner.is_lost(addr0) } + pub fn is_leaked(&self, addr0: usize) -> bool { + self.inner.is_leaked(addr0) + } + pub fn loss_values(&self) -> Vec { self.inner .qubit_status @@ -306,6 +310,14 @@ macro_rules! create_interface { .collect() } + pub fn leakage_values(&self) -> Vec { + self.inner + .qubit_status + .iter() + .map(|&o| o == QubitStatus::Leaked) + .collect() + } + pub fn run( &mut self, prog: &crate::stim_program::PyStimProgram, diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index bd890adc7..4a01854df 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -162,7 +162,9 @@ class _GeneralizedTableauBase: def reset_y(self, targets: Sequence[int]) -> None: ... def reset_z(self, targets: Sequence[int]) -> None: ... def is_lost(self, addr0: int) -> bool: ... + def is_leaked(self, addr0: int) -> bool: ... def loss_values(self) -> list[bool]: ... + def leakage_values(self) -> list[bool]: ... def run(self, prog: StimProgram) -> list[int]: ... @staticmethod def sample( diff --git a/ppvm-python/src/ppvm/generalized_tableau.py b/ppvm-python/src/ppvm/generalized_tableau.py index 062537101..04caf24b9 100644 --- a/ppvm-python/src/ppvm/generalized_tableau.py +++ b/ppvm-python/src/ppvm/generalized_tableau.py @@ -353,6 +353,20 @@ def is_lost(self, addr0: int) -> bool: """ return self._interface.is_lost(addr0) + def is_leaked(self, addr0: int) -> bool: + """Check whether a qubit has leaked out of the computational subspace. + + A leaked qubit is still present and measures as a pinned `0`/`1`; + gates skip it. Distinct from `is_lost`. + + Args: + addr0: The index of the qubit. + + Returns: + True if the qubit is leaked, False otherwise. + """ + return self._interface.is_leaked(addr0) + def loss_values(self) -> list[bool]: """Return the loss state of all qubits. @@ -362,6 +376,15 @@ def loss_values(self) -> list[bool]: """ return self._interface.loss_values() + def leakage_values(self) -> list[bool]: + """Return the leakage state of all qubits. + + Returns: + A list of booleans of length ``n_qubits``, where each entry is + True if the corresponding qubit is leaked and False otherwise. + """ + return self._interface.leakage_values() + def run(self, prog: StimProgram) -> list[MeasurementResult]: """Execute a parsed Stim program against this tableau (single shot). diff --git a/ppvm-python/test/generalized_tableau/test_loss.py b/ppvm-python/test/generalized_tableau/test_loss.py index c01f5de13..e27b85741 100644 --- a/ppvm-python/test/generalized_tableau/test_loss.py +++ b/ppvm-python/test/generalized_tableau/test_loss.py @@ -1,4 +1,4 @@ -from ppvm import GeneralizedTableau +from ppvm import GeneralizedTableau, StimProgram from ppvm.generalized_tableau import MeasurementResult @@ -265,3 +265,43 @@ def test_asymmetric_loss_superposition_averages_probs(): ) fraction = lost / trials assert abs(fraction - expected) < 0.07, f"Expected ~{expected:.2f}, got {fraction:.3f}" + + +def _leak_to_one(n_qubits: int = 1, q: int = 0) -> GeneralizedTableau: + tab = GeneralizedTableau(n_qubits=n_qubits, seed=0) + tab.run(StimProgram.parse(f"I_ERROR[leakage](0.0, 1.0) {q}")) + return tab + + +def test_is_leaked_initially_false(): + tab = GeneralizedTableau(n_qubits=3) + for i in range(3): + assert not tab.is_leaked(i) + + +def test_is_leaked_after_leakage_channel(): + tab = _leak_to_one(n_qubits=2, q=0) + assert tab.is_leaked(0) + assert not tab.is_leaked(1) + assert not tab.is_lost(0) + + +def test_leakage_values_initially_all_false(): + n = 4 + tab = GeneralizedTableau(n_qubits=n) + assert tab.leakage_values() == [False] * n + + +def test_leakage_values_after_leakage_channel(): + tab = _leak_to_one(n_qubits=3, q=1) + values = tab.leakage_values() + assert values == [False, True, False] + + +def test_loss_of_leaked_qubit_clears_leakage(): + tab = _leak_to_one() + tab.loss_channel(0, 1.0) + assert tab.is_lost(0) + assert not tab.is_leaked(0) + assert tab.leakage_values() == [False] + assert tab.loss_values() == [True] From 8ebb6ca7665b4bc2ca078522834bb032ff64af42 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 09:11:30 +0200 Subject: [PATCH 08/11] Add documentation comments --- crates/ppvm-traits/src/traits/noise.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/ppvm-traits/src/traits/noise.rs b/crates/ppvm-traits/src/traits/noise.rs index c61fdeb23..0ad710fd3 100644 --- a/crates/ppvm-traits/src/traits/noise.rs +++ b/crates/ppvm-traits/src/traits/noise.rs @@ -152,10 +152,22 @@ pub trait AsymmetricLossChannel { fn asymmetric_loss_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff); } +/// Single-qubit leakage channel — with probability `p0` the qubit is +/// leaked and pinned to `|0⟩`, with probability `p1` leaked and pinned +/// to `|1⟩`. Unlike [`LossChannel`], a leaked qubit remains measurable +/// and returns the pinned computational bit; gates still skip it. pub trait LeakageChannel { + /// Apply leakage to qubit `addr0`, with `p0` / `p1` the probabilities + /// of leaking into `|0⟩` / `|1⟩`. fn leakage_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff); } +/// Reset leakage on a qubit — used to model a leakage-reduction event +/// that returns a previously-leaked qubit to the computational subspace +/// in `|0⟩`. pub trait ResetLeakageChannel { + /// Clear leakage at `addr0` and reset the qubit to `|0⟩`. + /// + /// No-op if the qubit is live. A lost qubit cannot be recovered this way. fn reset_leakage_channel(&mut self, addr0: usize); } From f41224f1dfd4cdeef818912bc75c51d94308a275 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 3 Sep 2026 08:59:06 +0200 Subject: [PATCH 09/11] Add leakage to python bindings --- .../ppvm-python-native/ppvm_python_native.pyi | 2 ++ .../src/interface_tableau.rs | 8 +++++ ppvm-python/src/ppvm/_core.pyi | 2 ++ ppvm-python/src/ppvm/generalized_tableau.py | 28 +++++++++++++++ .../test/generalized_tableau/test_loss.py | 35 +++++++++++++++++-- 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/ppvm-python-native/ppvm_python_native.pyi b/crates/ppvm-python-native/ppvm_python_native.pyi index 0ca6f566d..6bde11762 100644 --- a/crates/ppvm-python-native/ppvm_python_native.pyi +++ b/crates/ppvm-python-native/ppvm_python_native.pyi @@ -139,6 +139,8 @@ class _GeneralizedTableauBase: self, addr0: int, addr1: int, p: Sequence[float] ) -> None: ... def reset_loss_channel(self, addr0: int) -> None: ... + def leakage_channel(self, addr0: int, p0: float, p1: float) -> None: ... + def reset_leakage_channel(self, addr0: int) -> None: ... def reset(self, addr0: int) -> None: ... def is_lost(self, addr0: int) -> bool: ... def is_leaked(self, addr0: int) -> bool: ... diff --git a/crates/ppvm-python-native/src/interface_tableau.rs b/crates/ppvm-python-native/src/interface_tableau.rs index 665324171..402bdf231 100644 --- a/crates/ppvm-python-native/src/interface_tableau.rs +++ b/crates/ppvm-python-native/src/interface_tableau.rs @@ -278,6 +278,14 @@ macro_rules! create_interface { self.inner.asymmetric_loss_channel(addr0, p0, p1); } + pub fn leakage_channel(&mut self, addr0: usize, p0: f64, p1: f64) { + self.inner.leakage_channel(addr0, p0, p1); + } + + pub fn reset_leakage_channel(&mut self, addr0: usize) { + self.inner.reset_leakage_channel(addr0); + } + pub fn reset(&mut self, targets: Vec) { self.inner.reset_many(targets.as_slice()); } diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index 4a01854df..fd2e3d2f8 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -157,6 +157,8 @@ class _GeneralizedTableauBase: def correlated_loss_channel(self, addr0: int, addr1: int, p: Sequence[float]) -> None: ... def reset_loss_channel(self, addr0: int) -> None: ... def asymmetric_loss_channel(self, addr0: int, p0: float, p1: float) -> None: ... + def leakage_channel(self, addr0: int, p0: float, p1: float) -> None: ... + def reset_leakage_channel(self, addr0: int) -> None: ... def reset(self, targets: Sequence[int]) -> None: ... def reset_x(self, targets: Sequence[int]) -> None: ... def reset_y(self, targets: Sequence[int]) -> None: ... diff --git a/ppvm-python/src/ppvm/generalized_tableau.py b/ppvm-python/src/ppvm/generalized_tableau.py index 2f3cb4fed..3fc3e04f7 100644 --- a/ppvm-python/src/ppvm/generalized_tableau.py +++ b/ppvm-python/src/ppvm/generalized_tableau.py @@ -343,6 +343,34 @@ def asymmetric_loss_channel(self, addr0: int, p0: float, p1: float) -> None: """ self._interface.asymmetric_loss_channel(addr0, p0, p1) + def leakage_channel(self, addr0: int, p0: float, p1: float) -> None: + """Apply a leakage channel that pins the qubit out of the computational subspace. + + With probability ``p0`` the qubit leaks and is pinned to `|0⟩`; with + probability ``p1`` it leaks and is pinned to `|1⟩`. The total leak + probability is ``p0 + p1`` (independent of the current state). A + leaked qubit still measures as the pinned bit; gates skip it. + + Distinct from `asymmetric_loss_channel`, whose ``p0`` / ``p1`` are + loss probabilities *from* `|0⟩` / `|1⟩`. + + Args: + addr0: The index of the target qubit. + p0: Probability of leaking into a pinned `|0⟩`. + p1: Probability of leaking into a pinned `|1⟩`. + """ + self._interface.leakage_channel(addr0, p0, p1) + + def reset_leakage_channel(self, addr0: int) -> None: + """Return a leaked qubit to the computational subspace in `|0⟩`. + + No-op if the qubit is live. A lost qubit cannot be recovered this way. + + Args: + addr0: The index of the target qubit. + """ + self._interface.reset_leakage_channel(addr0) + def is_lost(self, addr0: int) -> bool: """Check whether a qubit has been lost. diff --git a/ppvm-python/test/generalized_tableau/test_loss.py b/ppvm-python/test/generalized_tableau/test_loss.py index 7425dfcdb..5f1fd6a88 100644 --- a/ppvm-python/test/generalized_tableau/test_loss.py +++ b/ppvm-python/test/generalized_tableau/test_loss.py @@ -1,4 +1,4 @@ -from ppvm import GeneralizedTableau, LossyPauliSum, StimProgram +from ppvm import GeneralizedTableau, LossyPauliSum from ppvm.generalized_tableau import MeasurementResult @@ -307,10 +307,41 @@ def test_asymmetric_loss_superposition_averages_probs(): def _leak_to_one(n_qubits: int = 1, q: int = 0) -> GeneralizedTableau: tab = GeneralizedTableau(n_qubits=n_qubits, seed=0) - tab.run(StimProgram.parse(f"I_ERROR[leakage](0.0, 1.0) {q}")) + tab.leakage_channel(q, 0.0, 1.0) return tab +def test_leakage_channel_pins_to_one(): + tab = GeneralizedTableau(n_qubits=1, seed=0) + tab.leakage_channel(0, 0.0, 1.0) + assert tab.is_leaked(0) + assert not tab.is_lost(0) + assert tab.measure(0) == MeasurementResult.ONE + + +def test_leakage_channel_pins_to_zero(): + tab = GeneralizedTableau(n_qubits=1, seed=0) + tab.x(0) + tab.leakage_channel(0, 1.0, 0.0) + assert tab.is_leaked(0) + assert tab.measure(0) == MeasurementResult.ZERO + + +def test_leakage_channel_zero_prob_no_leak(): + tab = GeneralizedTableau(n_qubits=1, seed=0) + tab.leakage_channel(0, 0.0, 0.0) + assert not tab.is_leaked(0) + assert tab.measure(0) == MeasurementResult.ZERO + + +def test_reset_leakage_channel_recovers_to_zero(): + tab = _leak_to_one() + assert tab.is_leaked(0) + tab.reset_leakage_channel(0) + assert not tab.is_leaked(0) + assert tab.measure(0) == MeasurementResult.ZERO + + def test_is_leaked_initially_false(): tab = GeneralizedTableau(n_qubits=3) for i in range(3): From 719464a0ef96b9be2e92b7fd0cafec7a93ec27a6 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 3 Sep 2026 09:25:18 +0200 Subject: [PATCH 10/11] Update skill --- skills/ppvm-usage/SKILL.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/skills/ppvm-usage/SKILL.md b/skills/ppvm-usage/SKILL.md index 11caf188a..80f3da2c7 100644 --- a/skills/ppvm-usage/SKILL.md +++ b/skills/ppvm-usage/SKILL.md @@ -148,7 +148,7 @@ results = tab.run(prog) # list[MeasurementResult] shots = sample_stim(prog, shots=1000, n_qubits=5) ``` -`MeasurementResult` is an `IntEnum` (`ZERO`, `ONE`, `LOST`). Loss is first-class — neutral-atom hardware effects model directly. +`MeasurementResult` is an `IntEnum` (`ZERO`, `ONE`, `LOST`). Loss is first-class — a lost qubit measures `LOST`. A leaked qubit is different: it still measures the pinned `ZERO`/`ONE`, and gates skip it. Stim: `I_ERROR[leakage](p0, p1)`. ## Rust API @@ -274,8 +274,18 @@ Important: the six off-diagonal two-qubit rotations (`rxy`, `rxz`, `ryx`, `ryz`, | `loss_channel(q, p)` (Lossy types) | ✓ | ✓\* | ✓ | | `correlated_loss_channel(q0, q1, [p_LL, p_LQ, p_LN])` (`p_LQ` = named one) | ✓ | ✓\* | ✓ | | `reset_loss_channel(q)` | ✓ | ✓\* | ✓ | +| `leakage_channel(q, p0, p1)` (pin to 0/1; total `p0+p1`) | † | — | ✓ | +| `reset_leakage_channel(q)` | † | — | ✓ | +| `is_leaked(q)`, `leakage_values()` | † | — | ✓ | \* Python side: loss methods live on `LossyPauliSum`, not the plain `PauliSum`. +† Leakage is `GeneralizedTableau` only (Rust + Python) — not on `PauliSum` / +`LossyPauliSum`, and not on `GeneralizedTableauSum`. `p0`/`p1` are the +probabilities of leaking *into* a pinned `|0⟩`/`|1⟩` (sum is the leak rate). +Do not confuse with `asymmetric_loss_channel(q, p0, p1)`, whose `p0`/`p1` are +loss *from* `|0⟩`/`|1⟩` and whose total depends on `⟨Z⟩`. Stim: +`I_ERROR[leakage](p0, p1)` (no Stim leakage-reset instruction). A leaked +qubit measures the pinned `ZERO`/`ONE`, not `LOST`. ### Naming traps From 3ce10fc4166d8783750e1262399044873f8edc51 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 3 Sep 2026 09:43:00 +0200 Subject: [PATCH 11/11] Mention STIM leakage handling --- skills/ppvm-usage/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/ppvm-usage/SKILL.md b/skills/ppvm-usage/SKILL.md index 80f3da2c7..196cab8bc 100644 --- a/skills/ppvm-usage/SKILL.md +++ b/skills/ppvm-usage/SKILL.md @@ -148,7 +148,9 @@ results = tab.run(prog) # list[MeasurementResult] shots = sample_stim(prog, shots=1000, n_qubits=5) ``` -`MeasurementResult` is an `IntEnum` (`ZERO`, `ONE`, `LOST`). Loss is first-class — a lost qubit measures `LOST`. A leaked qubit is different: it still measures the pinned `ZERO`/`ONE`, and gates skip it. Stim: `I_ERROR[leakage](p0, p1)`. +`MeasurementResult` is an `IntEnum` (`ZERO`, `ONE`, `LOST`). Loss is first-class — a lost qubit measures `LOST`. + +Stim leakage is `I_ERROR[leakage](p0, p1)` — same `p0`/`p1` as `leakage_channel`. A leaked qubit measures the pinned `ZERO`/`ONE`, not `LOST`. ## Rust API