diff --git a/crates/ppvm-stim/benches/stim-circuits.rs b/crates/ppvm-stim/benches/stim-circuits.rs index 430004ae..08882b21 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 592d31e5..05ae2f33 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 be560582..d68c0bf8 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 dd3c25c3..3ec4cd47 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/ppvm-tableau/src/data.rs b/crates/ppvm-tableau/src/data.rs index 7b8d008d..203e98c2 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 60fe5345..854728d6 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 4ad579c9..38c74b99 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 @@ -45,6 +43,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 3ee2cd97..a53f68c0 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 fd7da17d..9ac1679f 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 8034c3eb..5ffa1ccf 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 8efb5e92..780ccc6f 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,107 @@ 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; + } +} + +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::*; @@ -990,4 +1092,171 @@ 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)); + } + + // === 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 57520129..5bc27c54 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, - 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 80544ecb..c61fdeb2 100644 --- a/crates/ppvm-traits/src/traits/noise.rs +++ b/crates/ppvm-traits/src/traits/noise.rs @@ -151,3 +151,11 @@ 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); +} + +pub trait ResetLeakageChannel { + fn reset_leakage_channel(&mut self, addr0: usize); +} diff --git a/crates/stim-parser/src/ast/extended.rs b/crates/stim-parser/src/ast/extended.rs index 5b94cb04..555cd6c4 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 04233424..6a492d46 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 c7a34de4..6af28e70 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 3f0a9a5f..28a8456f 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 d4391042..0304723f 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 1dc531a0..22f91f3b 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 81e1b98f..2d879fff 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 d759da2d..3c58c626 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(); diff --git a/ppvm-python/test/generalized_tableau/test_stim.py b/ppvm-python/test/generalized_tableau/test_stim.py index 599264b7..e3598810 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 = """