From 40005834f2b8ec795b5c843ead1e330548c13631 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 6 Nov 2026 16:44:41 +0100 Subject: [PATCH 1/5] Make reset reset loss and leakage too --- crates/ppvm-pauli-sum/src/sum/reset.rs | 72 +++++++++++++++---- crates/ppvm-pauli-word/src/loss/data.rs | 14 +++- crates/ppvm-pauli-word/src/word/data.rs | 13 +++- crates/ppvm-stim/tests/executor.rs | 11 +++ crates/ppvm-tableau-sum/src/gates/reset.rs | 40 ++++++++++- .../ppvm-tableau-sum/tests/sampler_vs_pure.rs | 28 ++++++-- crates/ppvm-tableau/src/gates/reset.rs | 53 ++++++++++++-- crates/ppvm-tableau/src/noise.rs | 35 ++++++--- crates/ppvm-tableau/tests/gates.rs | 14 +++- crates/ppvm-traits-2/src/gates/measure.rs | 15 +++- crates/ppvm-traits-2/src/gates/mod.rs | 2 +- crates/ppvm-traits-2/src/lib.rs | 4 +- crates/ppvm-traits/src/map/dashmap.rs | 14 ++++ crates/ppvm-traits/src/map/hashmap.rs | 12 ++++ crates/ppvm-traits/src/traits/map.rs | 6 ++ crates/ppvm-traits/src/traits/mod.rs | 2 +- crates/ppvm-traits/src/traits/reset.rs | 25 ++++++- ppvm-python/src/ppvm/generalized_tableau.py | 2 + .../src/ppvm/generalized_tableau_sum.py | 2 + 19 files changed, 313 insertions(+), 51 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/sum/reset.rs b/crates/ppvm-pauli-sum/src/sum/reset.rs index 4215cf048..c5886cd20 100644 --- a/crates/ppvm-pauli-sum/src/sum/reset.rs +++ b/crates/ppvm-pauli-sum/src/sum/reset.rs @@ -6,15 +6,15 @@ use ppvm_traits::char::Pauli; use ppvm_traits::config::Config; use ppvm_traits::traits::*; -/// Reset to `|0⟩` in the Heisenberg picture. +/// Reset a live qubit to `|0⟩` in the Heisenberg picture. /// /// The adjoint of the reset channel maps `A_q ⊗ B` to `⟨0|A|0⟩ · I_q ⊗ B`: /// `I` and `Z` on the reset qubit become `I`, while `X` and `Y` drop out. /// Terms that collide after relabelling have their coefficients added. /// Lost qubits are left untouched, matching the tableau, which skips -/// inactive qubits on reset; use `ResetLossChannel` to model reloading. -impl Reset for PauliSum { - fn reset(&mut self, addr0: usize) { +/// inactive qubits on state reset. +impl ResetState for PauliSum { + fn reset_state(&mut self, addr0: usize) { self.data_mut() .retain(|k, _| k.get_lbit(addr0) || !k.get_xbit(addr0)); self.map_add(|k, v| { @@ -25,6 +25,23 @@ impl Reset for PauliSum { } }); } +} + +/// Full reset to `|0⟩` in the Heisenberg picture, term by term via +/// [`WordReset`]. For lossy words this also reloads lost qubits. +impl Reset for PauliSum +where + T::PauliWordType: WordReset, +{ + fn reset(&mut self, addr0: usize) { + let (data, aux) = self.data_aux_mut(); + aux.clear(); + data.map_flat_add_assign(aux, |k, v| { + let v = v.clone(); + k.full_reset(addr0).map(move |w| (w, v.clone())) + }); + self.swap(); + } // The trait defaults run reset first (Schrödinger order); the Heisenberg // picture needs the basis change applied before the reset instead. @@ -45,6 +62,7 @@ mod tests { use crate::config::fxhash::ByteF64; use super::*; + use ppvm_pauli_word::loss::LossyPauliWord; type Sum = PauliSum>; @@ -86,21 +104,45 @@ mod tests { assert_eq!(s, sum(2, &[("XI", 1.0)])); } + type Lossy = PauliSum< + crate::config::fxhash::Byte< + 1, + f64, + ppvm_traits::traits::NoStrategy, + LossyPauliWord<[u8; 1], fxhash::FxBuildHasher>, + >, + >; + + fn lossy(n_qubits: usize, terms: &[(&str, f64)]) -> Lossy { + let mut s: Lossy = PauliSum::builder().n_qubits(n_qubits).build(); + for &(word, coef) in terms { + s += (word, coef); + } + s + } + + #[test] + fn test_reset_gives_zero_state_even_from_loss() { + // ⟨Z⟩ = 1 after reset on any state: Z maps to the full identity I + L. + let mut o = lossy(1, &[("Z", 1.0)]); + o.reset(0); + assert_eq!(o, lossy(1, &[("I", 1.0), ("L", 1.0)])); + } + #[test] - fn test_reset_leaves_lost_qubit_untouched() { - use ppvm_pauli_word::loss::LossyPauliWord; - type Lossy = PauliSum< - crate::config::fxhash::Byte< - 1, - f64, - ppvm_traits::traits::NoStrategy, - LossyPauliWord<[u8; 1], fxhash::FxBuildHasher>, - >, - >; + fn test_reset_leaves_no_loss_probability() { + // The lost-projector expectation vanishes after a full reset. + let mut o = lossy(2, &[("LZ", 1.0)]); + o.reset(0); + assert!(o.data().iter().all(|(_, v)| *v == 0.0)); + } + + #[test] + fn test_reset_state_leaves_lost_qubit_untouched() { let mut s: Lossy = PauliSum::builder().n_qubits(2).build(); s += ("LZ", 1.0); s += ("ZZ", 0.5); - s.reset(0); + s.reset_state(0); let mut expect: Lossy = PauliSum::builder().n_qubits(2).build(); expect += ("LZ", 1.0); expect += ("IZ", 0.5); diff --git a/crates/ppvm-pauli-word/src/loss/data.rs b/crates/ppvm-pauli-word/src/loss/data.rs index f492c84ca..9e0209c3e 100644 --- a/crates/ppvm-pauli-word/src/loss/data.rs +++ b/crates/ppvm-pauli-word/src/loss/data.rs @@ -3,7 +3,7 @@ use bitvec::prelude::BitArray; use ppvm_traits::char::Pauli; -use ppvm_traits::traits::{HashFinalize, PauliIter, PauliStorage, PauliWordTrait}; +use ppvm_traits::traits::{HashFinalize, PauliIter, PauliStorage, PauliWordTrait, WordReset}; use std::hash::{BuildHasher, Hash}; use std::ops::Index; @@ -418,6 +418,18 @@ impl std::fmt: } } +impl WordReset + for LossyPauliWord +{ + // The identity on the full qubit space is `I + L`. + fn full_reset(&self, addr0: usize) -> impl Iterator + use { + matches!(self.get(addr0), Pauli::I | Pauli::Z) + .then(|| [self.set_new(addr0, Pauli::I), self.set_new(addr0, Pauli::L)]) + .into_iter() + .flatten() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ppvm-pauli-word/src/word/data.rs b/crates/ppvm-pauli-word/src/word/data.rs index 93946dcce..1154ce159 100644 --- a/crates/ppvm-pauli-word/src/word/data.rs +++ b/crates/ppvm-pauli-word/src/word/data.rs @@ -4,7 +4,7 @@ use bitvec::array::BitArray; use core::panic; use ppvm_traits::char::Pauli; -use ppvm_traits::traits::{HashFinalize, PauliIter, PauliStorage, PauliWordTrait}; +use ppvm_traits::traits::{HashFinalize, PauliIter, PauliStorage, PauliWordTrait, WordReset}; use std::hash::{BuildHasher, Hash}; use std::ops::Index; @@ -389,6 +389,17 @@ impl WordReset + for PauliWord +{ + // No loss representable: a full reset is a state reset. + fn full_reset(&self, addr0: usize) -> impl Iterator + use { + matches!(self.get(addr0), Pauli::I | Pauli::Z) + .then(|| self.set_new(addr0, Pauli::I)) + .into_iter() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index 1e7c6749d..4fb5831b3 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -156,6 +156,17 @@ fn reset_leakage_does_not_restore_lost_qubit() { assert!(tab.is_lost(0)); } +#[test] +fn reset_restores_lost_and_leaked_qubits_to_zero() { + // `R` is a full reset: a lost or leaked qubit comes back live in |0⟩. + let (results, tab) = run( + "I_ERROR[loss](1.0) 0\nI_ERROR[leakage](0.0, 1.0) 1\nR 0 1\nX 1\nM 0 1", + 2, + ); + assert!(!tab.is_lost(0) && !tab.is_leaked(1)); + assert_eq!(results, vec![Some(false), Some(true)]); +} + #[test] fn reset_loss_does_not_restore_leaked_qubit() { let (_, tab) = run("I_ERROR[leakage](0.0, 1.0) 0\nI_ERROR[reset_loss] 0", 1); diff --git a/crates/ppvm-tableau-sum/src/gates/reset.rs b/crates/ppvm-tableau-sum/src/gates/reset.rs index fbba6276e..3ff3c6401 100644 --- a/crates/ppvm-tableau-sum/src/gates/reset.rs +++ b/crates/ppvm-tableau-sum/src/gates/reset.rs @@ -11,9 +11,9 @@ use num::traits::{One, PrimInt, ToPrimitive, Zero}; use ppvm_tableau::sparsevec::SparseVector; use ppvm_tableau::tableau_index::TableauIndex; use ppvm_traits::config::Config; -use ppvm_traits::traits::{Clifford, Reset}; +use ppvm_traits::traits::{Clifford, Reset, ResetLossChannel, ResetState}; -impl Reset for GeneralizedTableauSum +impl ResetState for GeneralizedTableauSum where T: Config, <::Storage as BitView>::Store: PrimInt, @@ -39,7 +39,7 @@ where + Copy, S: EntryStore, { - fn reset(&mut self, addr0: usize) { + fn reset_state(&mut self, addr0: usize) { self.for_each_z_branch(addr0, |tab, outcome, _p| { // Flip back to |0⟩ on the outcome-1 branch; the helper takes // care of fingerprint hygiene and the branch / merge plumbing. @@ -52,3 +52,37 @@ where }); } } + +impl Reset for GeneralizedTableauSum +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + I: TableauIndex + Debug + Send + Sync, + C: SparseVector, I> + Debug, + T::Coeff: One + + Zero + + Clone + + num::Num + + ToPrimitive + + std::fmt::Debug + + std::ops::Mul + + PartialOrd + + PartialOrd + + Send + + Sync, + Complex: std::ops::Mul> + + From + + std::ops::MulAssign + + std::ops::AddAssign + + One + + ComplexFloat + + Copy, + S: EntryStore, +{ + fn reset(&mut self, addr0: usize) { + // Un-lose first, otherwise `reset_state` skips the qubit. There is + // no leakage on this backend. + self.reset_loss_channel(addr0); + self.reset_state(addr0); + } +} diff --git a/crates/ppvm-tableau-sum/tests/sampler_vs_pure.rs b/crates/ppvm-tableau-sum/tests/sampler_vs_pure.rs index 666ded5f8..19d04485d 100644 --- a/crates/ppvm-tableau-sum/tests/sampler_vs_pure.rs +++ b/crates/ppvm-tableau-sum/tests/sampler_vs_pure.rs @@ -557,23 +557,39 @@ fn reset_after_depolarize_still_zero() { } #[test] -fn reset_on_lost_qubit_is_no_op() { - // GeneralizedTableau::reset is `measure + flip`; measurement on a lost - // qubit returns None, so the reset leaves is_lost set. Both backends - // must still report None for that qubit. +fn reset_state_on_lost_qubit_is_no_op() { + // reset_state skips lost qubits; both backends must still report None. let shots = 1000; let sum = run_sum(1, shots, 1e-12, |t| { t.loss_channel(0, 1.0); - t.reset(0); + t.reset_state(0); }); let pure = run_pure(1, shots, |t| { t.loss_channel(0, 1.0); - t.reset(0); + t.reset_state(0); }); assert!(sum.iter().all(|s| s[0].is_none())); assert!(pure.iter().all(|s| s[0].is_none())); } +#[test] +fn reset_reloads_lost_qubit_in_zero() { + // A full reset reloads the qubit; both backends must measure |0⟩. + let shots = 1000; + let sum = run_sum(1, shots, 1e-12, |t| { + t.h(0); + t.loss_channel(0, 0.5); + t.reset(0); + }); + let pure = run_pure(1, shots, |t| { + t.h(0); + t.loss_channel(0, 0.5); + t.reset(0); + }); + assert!(sum.iter().all(|s| s[0] == Some(false))); + assert!(pure.iter().all(|s| s[0] == Some(false))); +} + #[test] fn reset_on_one_state_case_b_flips_to_zero() { // X|0⟩ = |1⟩ is a Z eigenstate (case b, no branching). Reset must flip diff --git a/crates/ppvm-tableau/src/gates/reset.rs b/crates/ppvm-tableau/src/gates/reset.rs index 5ae6c737b..28d0e3839 100644 --- a/crates/ppvm-tableau/src/gates/reset.rs +++ b/crates/ppvm-tableau/src/gates/reset.rs @@ -8,11 +8,11 @@ use bitvec::view::BitView; use num::complex::{Complex, Complex64, ComplexFloat}; use num::traits::{One, PrimInt, ToPrimitive, Zero}; -impl Reset for Tableau +impl ResetState for Tableau where <::Storage as BitView>::Store: PrimInt, { - fn reset(&mut self, addr0: usize) { + fn reset_state(&mut self, addr0: usize) { let m = self.measure(addr0); if m { self.x(addr0); @@ -20,7 +20,17 @@ where } } -impl Reset for GeneralizedTableau +/// A plain tableau has no loss or leakage, so a full reset is a state reset. +impl Reset for Tableau +where + <::Storage as BitView>::Store: PrimInt, +{ + fn reset(&mut self, addr0: usize) { + self.reset_state(addr0); + } +} + +impl ResetState for GeneralizedTableau where T: Config, <::Storage as BitView>::Store: PrimInt, @@ -42,10 +52,9 @@ where + ComplexFloat + 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. + fn reset_state(&mut self, addr0: usize) { + // Skip qubits outside the computational subspace: leaked qubits must + // not be re-zeroed, and lost qubits have no state to reset. if self.is_inactive(addr0) { return; } @@ -62,3 +71,33 @@ where } } } + +impl Reset 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(&mut self, addr0: usize) { + // Un-lose / un-leak first, otherwise `reset_state` skips the qubit. + self.reset_loss_channel(addr0); + self.reset_leakage_channel(addr0); + self.reset_state(addr0); + } +} diff --git a/crates/ppvm-tableau/src/noise.rs b/crates/ppvm-tableau/src/noise.rs index a26be0ac0..b9739195c 100644 --- a/crates/ppvm-tableau/src/noise.rs +++ b/crates/ppvm-tableau/src/noise.rs @@ -345,8 +345,8 @@ where // 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.reset_state(addr0); + self.reset_state(addr1); self.qubit_status[addr0] = QubitStatus::Lost; self.qubit_status[addr1] = QubitStatus::Lost; } else { @@ -354,11 +354,11 @@ where let choice = self.tableau.rng.random::(); if choice { self.qubit_status[addr1] = QubitStatus::Live; - self.reset(addr1); + self.reset_state(addr1); self.qubit_status[addr1] = QubitStatus::Lost; } else { self.qubit_status[addr0] = QubitStatus::Live; - self.reset(addr0); + self.reset_state(addr0); self.qubit_status[addr0] = QubitStatus::Lost; } } @@ -471,7 +471,7 @@ where } self.qubit_status[addr0] = QubitStatus::Live; - self.reset(addr0); + self.reset_state(addr0); } } @@ -1560,15 +1560,34 @@ mod tests { } #[test] - fn reset_skips_leaked_qubit() { - // reset must not re-zero a leaked qubit. + fn reset_state_skips_leaked_qubit() { + // reset_state 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); + t.reset_state(0); assert!(t.is_leaked(0)); assert_eq!(t.measure(0), Some(true)); } + #[test] + fn reset_recovers_leaked_qubit_to_zero() { + 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(false)); + } + + #[test] + fn reset_recovers_lost_qubit_to_zero() { + let mut t = tab(1); + t.x(0); + t.loss_channel(0, 1.0); + t.reset(0); + assert!(!t.is_lost(0)); + assert_eq!(t.measure(0), Some(false)); + } + // === ResetLeakageChannel === #[test] diff --git a/crates/ppvm-tableau/tests/gates.rs b/crates/ppvm-tableau/tests/gates.rs index 991ef6b44..cffa81d83 100644 --- a/crates/ppvm-tableau/tests/gates.rs +++ b/crates/ppvm-tableau/tests/gates.rs @@ -335,14 +335,22 @@ fn test_generalized_tableau_reset_from_superposition() { } } +#[test] +fn test_generalized_tableau_reset_state_lost_qubit() { + // reset_state leaves a lost qubit lost. + let mut g: GTab = GeneralizedTableau::new_with_seed(1, 1e-12, 42); + g.qubit_status[0] = QubitStatus::Lost; + g.reset_state(0); + assert_eq!(g.measure(0), None); +} + #[test] fn test_generalized_tableau_reset_lost_qubit() { - // Reset on a lost qubit: measure returns None, so reset should not flip + // A full reset reloads a lost qubit in |0⟩. let mut g: GTab = GeneralizedTableau::new_with_seed(1, 1e-12, 42); g.qubit_status[0] = QubitStatus::Lost; g.reset(0); - // Measurement of lost qubit returns None - assert_eq!(g.measure(0), None); + assert_eq!(g.measure(0), Some(false)); } #[test] diff --git a/crates/ppvm-traits-2/src/gates/measure.rs b/crates/ppvm-traits-2/src/gates/measure.rs index bbba3ae4a..4aa5b7eda 100644 --- a/crates/ppvm-traits-2/src/gates/measure.rs +++ b/crates/ppvm-traits-2/src/gates/measure.rs @@ -17,9 +17,20 @@ pub trait Measure { } } -// Reset one qubit to a computational/Pauli basis state. -pub trait Reset: Clifford { +/// Reset the state of a live qubit to `|0⟩`, leaving lost and leaked qubits +/// untouched. +pub trait ResetState { + /// Reset `qubit` to `|0⟩` if it is live; no-op otherwise. + fn reset_state(&mut self, qubit: usize); +} + +/// Reset one qubit to a computational/Pauli basis state. +pub trait Reset: ResetState + Clifford { /// Reset one qubit to `|0⟩` (stim `R`/`RZ`). + /// + /// This is a full reset: a lost or leaked qubit is first brought back + /// to the computational subspace, then its state is reset via + /// [`ResetState::reset_state`]. fn reset(&mut self, qubit: usize); /// stim `RZ` alias — reset to `|0⟩`. diff --git a/crates/ppvm-traits-2/src/gates/mod.rs b/crates/ppvm-traits-2/src/gates/mod.rs index 871a7de77..e645f0c9e 100644 --- a/crates/ppvm-traits-2/src/gates/mod.rs +++ b/crates/ppvm-traits-2/src/gates/mod.rs @@ -13,7 +13,7 @@ pub use channel::{ LossChannel, PauliError, ResetLossChannel, TwoQubitPauliError, }; pub use clifford::{Clifford, CliffordBatch}; -pub use measure::{Measure, Projection, Reset}; +pub use measure::{Measure, Projection, Reset, ResetState}; pub use rot::{ CRx, RotXY, RotationOne, RotationOneBatch, RotationTwo, RotationTwoBatch, TGate, U3Gate, }; diff --git a/crates/ppvm-traits-2/src/lib.rs b/crates/ppvm-traits-2/src/lib.rs index 400549ab1..a50a56515 100644 --- a/crates/ppvm-traits-2/src/lib.rs +++ b/crates/ppvm-traits-2/src/lib.rs @@ -22,8 +22,8 @@ pub use containers::{ pub use gates::{ AmplitudeDamping, AsymmetricLossChannel, CRx, Clifford, CliffordBatch, CorrelatedLossChannel, Depolarizing, Depolarizing2, LossChannel, Measure, PauliError, Projection, Reset, - ResetLossChannel, RotXY, RotationOne, RotationOneBatch, RotationTwo, RotationTwoBatch, TGate, - TwoQubitPauliError, U3Gate, + ResetLossChannel, ResetState, RotXY, RotationOne, RotationOneBatch, RotationTwo, + RotationTwoBatch, TGate, TwoQubitPauliError, U3Gate, }; pub use loss::{LossState, LossySite}; pub use pauli::{Pauli, PhaseTrack, SymplecticColumns}; diff --git a/crates/ppvm-traits/src/map/dashmap.rs b/crates/ppvm-traits/src/map/dashmap.rs index 4e9749fe0..d7a378cc1 100644 --- a/crates/ppvm-traits/src/map/dashmap.rs +++ b/crates/ppvm-traits/src/map/dashmap.rs @@ -50,6 +50,20 @@ where .or_insert(new_v); }); } + + fn map_flat_add_assign(&self, dest: &mut Self, f: F) + where + F: Fn(&W, &V) -> I + Sync + Send, + I: IntoIterator, + { + self.par_iter().for_each(|entry| { + for (new_k, new_v) in f(entry.key(), entry.value()) { + dest.entry(new_k) + .and_modify(|v| *v += new_v.clone()) + .or_insert(new_v); + } + }); + } } impl ACMapMulAssign for DashMap diff --git a/crates/ppvm-traits/src/map/hashmap.rs b/crates/ppvm-traits/src/map/hashmap.rs index a511d6a15..1ab262524 100644 --- a/crates/ppvm-traits/src/map/hashmap.rs +++ b/crates/ppvm-traits/src/map/hashmap.rs @@ -52,6 +52,18 @@ macro_rules! impl_acmap_add_assign { >::add_assign(dest, new_k, new_v); } } + + fn map_flat_add_assign(&self, dest: &mut Self, f: F) + where + F: Fn(&W, &V) -> I + Sync + Send, + I: IntoIterator, + { + for (k, v) in self.iter() { + for (new_k, new_v) in f(k, v) { + >::add_assign(dest, new_k, new_v); + } + } + } } }; } diff --git a/crates/ppvm-traits/src/traits/map.rs b/crates/ppvm-traits/src/traits/map.rs index a5c1cfa11..8fd7c6baf 100644 --- a/crates/ppvm-traits/src/traits/map.rs +++ b/crates/ppvm-traits/src/traits/map.rs @@ -50,6 +50,12 @@ pub trait ACMapAddAssign< fn map_add_assign(&self, dest: &mut Self, f: F) where F: Fn(&W, &V) -> (W, V) + Sync + Send; + /// Like [`map_add_assign`](Self::map_add_assign), but each entry may + /// produce any number of results. + fn map_flat_add_assign(&self, dest: &mut Self, f: F) + where + F: Fn(&W, &V) -> I + Sync + Send, + I: IntoIterator; } /// Scalar `*=` semantics for an `ACMap`: multiply every value by a diff --git a/crates/ppvm-traits/src/traits/mod.rs b/crates/ppvm-traits/src/traits/mod.rs index f986dc2e7..59b91e513 100644 --- a/crates/ppvm-traits/src/traits/mod.rs +++ b/crates/ppvm-traits/src/traits/mod.rs @@ -28,7 +28,7 @@ pub use noise::{ LeakageChannel, LossChannel, PauliError, ResetLeakageChannel, ResetLossChannel, TwoQubitPauliError, }; -pub use reset::Reset; +pub use reset::{Reset, ResetState, WordReset}; pub use storage::PauliStorage; pub use strategy::{NoStrategy, Strategy}; pub use trace::Trace; diff --git a/crates/ppvm-traits/src/traits/reset.rs b/crates/ppvm-traits/src/traits/reset.rs index e3923da43..e03200e7d 100644 --- a/crates/ppvm-traits/src/traits/reset.rs +++ b/crates/ppvm-traits/src/traits/reset.rs @@ -1,9 +1,32 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 +/// Reset the state of a live qubit to `|0⟩`, leaving lost and leaked qubits +/// untouched. +pub trait ResetState { + /// Reset the qubit at `addr0` to `|0⟩` if it is live; no-op otherwise. + fn reset_state(&mut self, addr0: usize); +} + +/// Full reset to `|0⟩` of a single Pauli word, in the Heisenberg picture. +/// +/// Pauli-propagation backends build [`Reset`] from this: each term maps to +/// the words returned here, each carrying the term's coefficient. +pub trait WordReset: crate::traits::PauliWordTrait { + /// Words this word maps to under a full reset of qubit `addr0`. + /// + /// `I` and `Z` on `addr0` map to the identity on the full qubit space + /// (for lossy words, `I + L`); `X`, `Y` and `L` drop out. + fn full_reset(&self, addr0: usize) -> impl Iterator + use; +} + /// Reset one qubit to a computational/Pauli basis state. -pub trait Reset: crate::traits::Clifford + crate::traits::CliffordExtensions { +pub trait Reset: ResetState + crate::traits::Clifford + crate::traits::CliffordExtensions { /// Reset one qubit to `|0⟩` (stim `R`/`RZ`). + /// + /// This is a full reset: a lost or leaked qubit is first brought back + /// to the computational subspace, then its state is reset via + /// [`ResetState::reset_state`]. fn reset(&mut self, addr0: usize); /// stim `RZ` alias — reset to `|0⟩`. fn reset_z(&mut self, addr0: usize) { diff --git a/ppvm-python/src/ppvm/generalized_tableau.py b/ppvm-python/src/ppvm/generalized_tableau.py index 274c43496..11b01ef29 100644 --- a/ppvm-python/src/ppvm/generalized_tableau.py +++ b/ppvm-python/src/ppvm/generalized_tableau.py @@ -288,6 +288,8 @@ def u3(self, addr0: int, theta: float, phi: float, lam: float): def reset(self, *targets: int | Iterable[int]) -> None: """Reset each target qubit to the |0> state. + Lost and leaked qubits are first returned to the computational subspace. + Args: *targets: The indices of the target qubits. """ diff --git a/ppvm-python/src/ppvm/generalized_tableau_sum.py b/ppvm-python/src/ppvm/generalized_tableau_sum.py index f48db4af8..26e152194 100644 --- a/ppvm-python/src/ppvm/generalized_tableau_sum.py +++ b/ppvm-python/src/ppvm/generalized_tableau_sum.py @@ -131,6 +131,8 @@ def u3(self, addr0: int, theta: float, phi: float, lam: float): def reset(self, *targets: int | Iterable[int]) -> None: """Reset each target qubit to the |0> state. + Lost qubits are first returned to the computational subspace. + Args: *targets: The indices of the target qubits. """ From f039c32646905ea22ab564065dca52e1a1596d3d Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 6 Nov 2026 16:55:55 +0100 Subject: [PATCH 2/5] Rename word_reset --- crates/ppvm-pauli-sum/src/sum/reset.rs | 2 +- crates/ppvm-pauli-word/src/loss/data.rs | 2 +- crates/ppvm-pauli-word/src/word/data.rs | 2 +- crates/ppvm-traits/src/traits/reset.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/sum/reset.rs b/crates/ppvm-pauli-sum/src/sum/reset.rs index c5886cd20..781778993 100644 --- a/crates/ppvm-pauli-sum/src/sum/reset.rs +++ b/crates/ppvm-pauli-sum/src/sum/reset.rs @@ -38,7 +38,7 @@ where aux.clear(); data.map_flat_add_assign(aux, |k, v| { let v = v.clone(); - k.full_reset(addr0).map(move |w| (w, v.clone())) + k.word_reset(addr0).map(move |w| (w, v.clone())) }); self.swap(); } diff --git a/crates/ppvm-pauli-word/src/loss/data.rs b/crates/ppvm-pauli-word/src/loss/data.rs index 9e0209c3e..3d85409dc 100644 --- a/crates/ppvm-pauli-word/src/loss/data.rs +++ b/crates/ppvm-pauli-word/src/loss/data.rs @@ -422,7 +422,7 @@ impl WordReset for LossyPauliWord { // The identity on the full qubit space is `I + L`. - fn full_reset(&self, addr0: usize) -> impl Iterator + use { + fn word_reset(&self, addr0: usize) -> impl Iterator + use { matches!(self.get(addr0), Pauli::I | Pauli::Z) .then(|| [self.set_new(addr0, Pauli::I), self.set_new(addr0, Pauli::L)]) .into_iter() diff --git a/crates/ppvm-pauli-word/src/word/data.rs b/crates/ppvm-pauli-word/src/word/data.rs index 1154ce159..9dc67e98d 100644 --- a/crates/ppvm-pauli-word/src/word/data.rs +++ b/crates/ppvm-pauli-word/src/word/data.rs @@ -393,7 +393,7 @@ impl { // No loss representable: a full reset is a state reset. - fn full_reset(&self, addr0: usize) -> impl Iterator + use { + fn word_reset(&self, addr0: usize) -> impl Iterator + use { matches!(self.get(addr0), Pauli::I | Pauli::Z) .then(|| self.set_new(addr0, Pauli::I)) .into_iter() diff --git a/crates/ppvm-traits/src/traits/reset.rs b/crates/ppvm-traits/src/traits/reset.rs index e03200e7d..cf5db9054 100644 --- a/crates/ppvm-traits/src/traits/reset.rs +++ b/crates/ppvm-traits/src/traits/reset.rs @@ -17,7 +17,7 @@ pub trait WordReset: crate::traits::PauliWordTrait { /// /// `I` and `Z` on `addr0` map to the identity on the full qubit space /// (for lossy words, `I + L`); `X`, `Y` and `L` drop out. - fn full_reset(&self, addr0: usize) -> impl Iterator + use; + fn word_reset(&self, addr0: usize) -> impl Iterator + use; } /// Reset one qubit to a computational/Pauli basis state. From 1e0fa77cda5b6aa96a01fd013ee2ddec191de7aa Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 6 Nov 2026 17:07:20 +0100 Subject: [PATCH 3/5] feat(stim): full reset in MR, add I_ERROR[reset_state] and Python reset_state - MR/MRX/MRY now read out first (lost -> None, leaked -> pinned bit), then do a full reset like R, so lost / leaked qubits come back live. - New I_ERROR[reset_state] tag maps to ResetState::reset_state, which resets live qubits only. - Python: reset_state on GeneralizedTableau and GeneralizedTableauSum. - Document the new reset semantics in the ppvm-usage skill. Refs #283 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/interface_tableau.rs | 6 +++ .../src/interface_tableau_sum.rs | 6 +++ crates/ppvm-stim/benches/stim-circuits.rs | 1 + crates/ppvm-stim/src/executor.rs | 32 +++++++-------- crates/ppvm-stim/src/validate.rs | 1 + crates/ppvm-stim/tests/executor.rs | 21 ++++++++++ crates/stim-parser/src/ast/extended.rs | 6 +++ crates/stim-parser/src/instructions/mod.rs | 3 +- crates/stim-parser/src/pipeline/lower.rs | 27 ++++++++---- crates/stim-parser/src/print/mod.rs | 6 ++- crates/stim-parser/tests/proptest_ast.rs | 5 +++ ppvm-python/src/ppvm/_core.pyi | 2 + ppvm-python/src/ppvm/generalized_tableau.py | 10 +++++ .../src/ppvm/generalized_tableau_sum.py | 10 +++++ .../test/generalized_tableau/test_loss.py | 41 +++++++++++++++++++ skills/ppvm-usage/SKILL.md | 5 ++- 16 files changed, 152 insertions(+), 30 deletions(-) diff --git a/crates/ppvm-python-native/src/interface_tableau.rs b/crates/ppvm-python-native/src/interface_tableau.rs index f7af06a07..b66244f10 100644 --- a/crates/ppvm-python-native/src/interface_tableau.rs +++ b/crates/ppvm-python-native/src/interface_tableau.rs @@ -291,6 +291,12 @@ macro_rules! create_interface { self.inner.reset_many(targets.as_slice()); } + pub fn reset_state(&mut self, targets: Vec) { + for q in targets { + self.inner.reset_state(q); + } + } + pub fn reset_x(&mut self, targets: Vec) { self.inner.reset_x_many(targets.as_slice()); } diff --git a/crates/ppvm-python-native/src/interface_tableau_sum.rs b/crates/ppvm-python-native/src/interface_tableau_sum.rs index d3cc51088..2f2e78cb9 100644 --- a/crates/ppvm-python-native/src/interface_tableau_sum.rs +++ b/crates/ppvm-python-native/src/interface_tableau_sum.rs @@ -241,6 +241,12 @@ macro_rules! create_sum_interface { self.inner.reset_many(targets.as_slice()); } + pub fn reset_state(&mut self, targets: Vec) { + for q in targets { + self.inner.reset_state(q); + } + } + /// Force a truncation pass. /// /// Truncation is already applied automatically inside every noise diff --git a/crates/ppvm-stim/benches/stim-circuits.rs b/crates/ppvm-stim/benches/stim-circuits.rs index aa48df8a4..5b316ace2 100644 --- a/crates/ppvm-stim/benches/stim-circuits.rs +++ b/crates/ppvm-stim/benches/stim-circuits.rs @@ -80,6 +80,7 @@ fn required_qubits(program: &ExtendedProgram) -> usize { | ExtendedInstruction::Loss { targets, .. } | ExtendedInstruction::Leakage { targets, .. } | ExtendedInstruction::ResetLoss { targets, .. } + | ExtendedInstruction::ResetState { targets, .. } | ExtendedInstruction::ResetLeakage { targets, .. } => { for &q in targets { bump(q); diff --git a/crates/ppvm-stim/src/executor.rs b/crates/ppvm-stim/src/executor.rs index 042ddbcc7..921827c4e 100644 --- a/crates/ppvm-stim/src/executor.rs +++ b/crates/ppvm-stim/src/executor.rs @@ -71,13 +71,13 @@ fn record_bit(record: &[Option], k: usize) -> bool { .unwrap_or(false) } -/// Measure qubit `q` in the Z basis, reset it to |0>, and return the reported -/// (readout-noise-flipped) outcome — the building block for `MR`/`MRX`/`MRY`. +/// Measure qubit `q` in the Z basis, then fully reset it to |0>, and return +/// the reported (readout-noise-flipped) outcome — the building block for +/// `MR`/`MRX`/`MRY`. /// -/// The reset must act on the *true* (pre-flip) outcome, so this cannot delegate -/// to `measure_noisy`; instead it applies the same `flip_with_prob` draw and -/// overwrites the record entry with the reported value, so the measurement -/// record matches the returned result exactly (consistent with `M`). +/// The readout comes first, so a lost or leaked qubit is reported as such +/// before the reset (as for `R`) brings it back. `reset` leaves the +/// measurement record untouched. fn measure_reset_z( tab: &mut GeneralizedTableau, q: usize, @@ -107,12 +107,8 @@ where + Copy, I: TableauIndex + Debug + Send + Sync, { - let true_outcome = tab.measure(q); - if true_outcome == Some(true) { - tab.x(q); - } - let recorded = true_outcome.map(|b| tab.flip_with_prob(b, noise)); - tab.overwrite_last_measurement_record(recorded); + let recorded = tab.measure_noisy(q, noise); + tab.reset(q); recorded } @@ -756,6 +752,11 @@ pub fn execute_validated( tab.reset_leakage_channel(q); } } + ExtendedInstruction::ResetState { targets, .. } => { + for &q in targets { + tab.reset_state(q); + } + } ExtendedInstruction::Measure(MeasureOp { name, args, @@ -778,9 +779,6 @@ pub fn execute_validated( results.extend(tab.measure_many(targets)); } } - // MR cannot delegate to `measure_noisy` because the reset must use - // the *true* outcome — but it shares the readout flip via - // `flip_with_prob`, so the RNG-draw shape matches MZ exactly. MeasureName::MR => { for &q in targets { results.push(measure_reset_z(tab, q, noise)); @@ -808,9 +806,7 @@ pub fn execute_validated( } } // MRX/MRY add a reset to the measured eigenstate - // (`MRX = H M R H`, `MRY = S_DAG H M R H S`). The reset must - // use the *true* outcome, so it mirrors the MR arm rather than - // delegating to `measure_noisy`. + // (`MRX = H M R H`, `MRY = S_DAG H M R H S`). MeasureName::MRX => { for &q in targets { tab.h(q); diff --git a/crates/ppvm-stim/src/validate.rs b/crates/ppvm-stim/src/validate.rs index 1c282960d..07b285dd0 100644 --- a/crates/ppvm-stim/src/validate.rs +++ b/crates/ppvm-stim/src/validate.rs @@ -107,6 +107,7 @@ fn validate_slice( | ExtendedInstruction::Loss { .. } | ExtendedInstruction::CorrelatedLoss { .. } | ExtendedInstruction::ResetLoss { .. } + | ExtendedInstruction::ResetState { .. } | ExtendedInstruction::ResetLeakage { .. } => {} ExtendedInstruction::Leakage { p0, p1, span, .. } => { let line = span.line(line_map); diff --git a/crates/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index 4fb5831b3..1d08e6875 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -167,6 +167,27 @@ fn reset_restores_lost_and_leaked_qubits_to_zero() { assert_eq!(results, vec![Some(false), Some(true)]); } +#[test] +fn measure_reset_reports_loss_and_leakage_then_resets() { + // MR reads out first (lost -> None, leaked -> pinned bit), then fully resets. + let (results, tab) = run( + "I_ERROR[loss](1.0) 0\nI_ERROR[leakage](0.0, 1.0) 1\nMR 0 1\nM 0 1", + 2, + ); + assert!(!tab.is_lost(0) && !tab.is_leaked(1)); + assert_eq!(results, vec![None, Some(true), Some(false), Some(false)]); +} + +#[test] +fn reset_state_leaves_lost_and_leaked_qubits_alone() { + let (results, tab) = run( + "X 2\nI_ERROR[loss](1.0) 0\nI_ERROR[leakage](0.0, 1.0) 1\nI_ERROR[reset_state] 0 1 2\nM 0 1 2", + 3, + ); + assert!(tab.is_lost(0) && tab.is_leaked(1)); + assert_eq!(results, vec![None, Some(true), Some(false)]); +} + #[test] fn reset_loss_does_not_restore_leaked_qubit() { let (_, tab) = run("I_ERROR[leakage](0.0, 1.0) 0\nI_ERROR[reset_loss] 0", 1); diff --git a/crates/stim-parser/src/ast/extended.rs b/crates/stim-parser/src/ast/extended.rs index afa4d9a2c..09369747a 100644 --- a/crates/stim-parser/src/ast/extended.rs +++ b/crates/stim-parser/src/ast/extended.rs @@ -64,6 +64,10 @@ pub enum ExtendedInstruction { targets: Vec, span: Span, }, + ResetState { + targets: Vec, + span: Span, + }, MPad { tag: String, prob: Option, @@ -126,6 +130,7 @@ fn max_qubit_in_slice(instructions: &[ExtendedInstruction]) -> Option { | ExtendedInstruction::Loss { targets, .. } | ExtendedInstruction::Leakage { targets, .. } | ExtendedInstruction::ResetLoss { targets, .. } + | ExtendedInstruction::ResetState { targets, .. } | ExtendedInstruction::ResetLeakage { targets, .. } => targets.iter().copied().max(), ExtendedInstruction::CorrelatedLoss { targets, .. } => { targets.iter().flat_map(|&(a, b)| [a, b]).max() @@ -166,6 +171,7 @@ fn count_in_slice(instructions: &[ExtendedInstruction], factor: u64) -> usize { | ExtendedInstruction::CorrelatedLoss { .. } | ExtendedInstruction::Leakage { .. } | ExtendedInstruction::ResetLoss { .. } + | ExtendedInstruction::ResetState { .. } | ExtendedInstruction::ResetLeakage { .. } => {} } } diff --git a/crates/stim-parser/src/instructions/mod.rs b/crates/stim-parser/src/instructions/mod.rs index fa8554561..bb7cbde74 100644 --- a/crates/stim-parser/src/instructions/mod.rs +++ b/crates/stim-parser/src/instructions/mod.rs @@ -75,7 +75,8 @@ pub enum NoiseName { /// `I_ERROR` — accepted at the raw-parse layer regardless of tag (its /// arg count is [`ArgCount::Deferred`]). The lowering pass then promotes /// `I_ERROR[loss]` / `[correlated_loss]` / `[leakage]` / `[reset_loss]` / - /// `[reset_leakage]` to typed loss and leakage instructions + /// `[reset_leakage]` / `[reset_state]` to typed loss, leakage and reset + /// instructions /// and rejects every other tag combination (including untagged) by emitting /// an `"invalid-tag"` diagnostic. IError, diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 3aa578f51..9cbf9941d 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -353,7 +353,7 @@ fn lower_noise( span, })) } - (IError, tag @ ("reset_loss" | "reset_leakage")) => { + (IError, tag @ ("reset_loss" | "reset_leakage" | "reset_state")) => { if !args.is_empty() { return invalid_tag( tag, @@ -363,25 +363,26 @@ fn lower_noise( sink, ); } - Ok(Some(if tag == "reset_loss" { - ExtendedInstruction::ResetLoss { targets, span } - } else { - ExtendedInstruction::ResetLeakage { targets, span } + Ok(Some(match tag { + "reset_loss" => ExtendedInstruction::ResetLoss { targets, span }, + "reset_leakage" => ExtendedInstruction::ResetLeakage { targets, span }, + _ => ExtendedInstruction::ResetState { targets, span }, })) } (IError, "") => invalid_tag( "", name.canonical_name(), span, - "I_ERROR requires a [loss], [correlated_loss], [leakage], [reset_loss], or \ - [reset_leakage] tag", + "I_ERROR requires a [loss], [correlated_loss], [leakage], [reset_loss], \ + [reset_leakage], or [reset_state] tag", sink, ), (IError, other) => invalid_tag( other, name.canonical_name(), span, - "expected [loss], [correlated_loss], [leakage], [reset_loss], or [reset_leakage]", + "expected [loss], [correlated_loss], [leakage], [reset_loss], [reset_leakage], \ + or [reset_state]", sink, ), _ => Ok(Some(ExtendedInstruction::Noise(NoiseOp { @@ -784,6 +785,16 @@ mod tests { fn i_error_reset_loss_rejects_args() { assert!(lower_extended("I_ERROR[reset_loss](0.1) 0").is_err()); assert!(lower_extended("I_ERROR[reset_leakage](0.1) 0").is_err()); + assert!(lower_extended("I_ERROR[reset_state](0.1) 0").is_err()); + } + + #[test] + fn i_error_reset_state_lowers() { + let prog = lower_extended("I_ERROR[reset_state] 0 2").expect("lower"); + match &prog.instructions[0] { + ExtendedInstruction::ResetState { targets, .. } => assert_eq!(targets, &vec![0, 2]), + other => panic!("{other:?}"), + } } #[test] diff --git a/crates/stim-parser/src/print/mod.rs b/crates/stim-parser/src/print/mod.rs index 12c749828..87da57f12 100644 --- a/crates/stim-parser/src/print/mod.rs +++ b/crates/stim-parser/src/print/mod.rs @@ -377,6 +377,10 @@ impl StimPrint for ExtendedInstruction { write!(out, "I_ERROR[reset_leakage]")?; write_usize_targets(out, targets)?; } + ExtendedInstruction::ResetState { targets, .. } => { + write!(out, "I_ERROR[reset_state]")?; + write_usize_targets(out, targets)?; + } ExtendedInstruction::MPad { tag, prob, bits, .. } => { @@ -442,7 +446,7 @@ mod tests { #[test] fn reset_loss_and_reset_leakage_print_canonically() { - let src = "I_ERROR[reset_loss] 0 1\nI_ERROR[reset_leakage] 2\n"; + let src = "I_ERROR[reset_loss] 0 1\nI_ERROR[reset_leakage] 2\nI_ERROR[reset_state] 3\n"; assert_eq!(parse_extended(src).unwrap().to_stim(), src); } diff --git a/crates/stim-parser/tests/proptest_ast.rs b/crates/stim-parser/tests/proptest_ast.rs index 24ba75c2d..262a62e35 100644 --- a/crates/stim-parser/tests/proptest_ast.rs +++ b/crates/stim-parser/tests/proptest_ast.rs @@ -431,6 +431,10 @@ fn ext_flat() -> impl Strategy { targets, span: span0(), }), + one_q_targets().prop_map(|targets| ExtendedInstruction::ResetState { + targets, + span: span0(), + }), (prob_lit(), prob_lit(), one_q_targets()).prop_map(|(p0, p1, targets)| { ExtendedInstruction::Leakage { p0, @@ -520,6 +524,7 @@ fn zero_spans_ext(instrs: &mut [ExtendedInstruction]) { | ExtendedInstruction::CorrelatedLoss { span, .. } | ExtendedInstruction::Leakage { span, .. } | ExtendedInstruction::ResetLoss { span, .. } + | ExtendedInstruction::ResetState { span, .. } | ExtendedInstruction::ResetLeakage { span, .. } | ExtendedInstruction::MPad { span, .. } => *span = span0(), ExtendedInstruction::Repeat { body, span, .. } => { diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index a2c435cdb..d6da337f2 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -173,6 +173,7 @@ class _GeneralizedTableauBase: 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_state(self, targets: Sequence[int]) -> None: ... def reset_x(self, targets: Sequence[int]) -> None: ... def reset_y(self, targets: Sequence[int]) -> None: ... def reset_z(self, targets: Sequence[int]) -> None: ... @@ -293,6 +294,7 @@ class _GeneralizedTableauSumBase: def correlated_loss_channel(self, addr0: int, addr1: int, p: Sequence[float]) -> None: ... def reset_loss_channel(self, addr0: int) -> None: ... def reset(self, targets: Sequence[int]) -> None: ... + def reset_state(self, targets: Sequence[int]) -> None: ... def truncate(self) -> None: ... def sampler(self) -> _TableauSumSamplerBase: ... diff --git a/ppvm-python/src/ppvm/generalized_tableau.py b/ppvm-python/src/ppvm/generalized_tableau.py index 11b01ef29..2c262bac8 100644 --- a/ppvm-python/src/ppvm/generalized_tableau.py +++ b/ppvm-python/src/ppvm/generalized_tableau.py @@ -295,6 +295,16 @@ def reset(self, *targets: int | Iterable[int]) -> None: """ self._interface.reset(_normalize_targets(targets)) + def reset_state(self, *targets: int | Iterable[int]) -> None: + """Reset the state of each live target qubit to the |0> state. + + Lost and leaked qubits are left untouched. Use `reset` for a full reset. + + Args: + *targets: The indices of the target qubits. + """ + self._interface.reset_state(_normalize_targets(targets)) + def reset_x(self, *targets: int | Iterable[int]) -> None: """Reset each target qubit to the |+> state. diff --git a/ppvm-python/src/ppvm/generalized_tableau_sum.py b/ppvm-python/src/ppvm/generalized_tableau_sum.py index 26e152194..d6dd0651f 100644 --- a/ppvm-python/src/ppvm/generalized_tableau_sum.py +++ b/ppvm-python/src/ppvm/generalized_tableau_sum.py @@ -138,6 +138,16 @@ def reset(self, *targets: int | Iterable[int]) -> None: """ self._interface.reset(_normalize_targets(targets)) + def reset_state(self, *targets: int | Iterable[int]) -> None: + """Reset the state of each live target qubit to the |0> state. + + Lost qubits are left untouched. Use `reset` for a full reset. + + Args: + *targets: The indices of the target qubits. + """ + self._interface.reset_state(_normalize_targets(targets)) + def reset_loss_channel(self, addr0: int) -> None: """Reset a lost qubit to being active again. diff --git a/ppvm-python/test/generalized_tableau/test_loss.py b/ppvm-python/test/generalized_tableau/test_loss.py index 5f1fd6a88..1c1aff02d 100644 --- a/ppvm-python/test/generalized_tableau/test_loss.py +++ b/ppvm-python/test/generalized_tableau/test_loss.py @@ -1,5 +1,6 @@ from ppvm import GeneralizedTableau, LossyPauliSum from ppvm.generalized_tableau import MeasurementResult +from ppvm.generalized_tableau_sum import GeneralizedTableauSum def test_is_lost_initially_false(): @@ -374,3 +375,43 @@ def test_loss_of_leaked_qubit_clears_leakage(): assert not tab.is_leaked(0) assert tab.leakage_values() == [False] assert tab.loss_values() == [True] + + +def test_reset_recovers_lost_and_leaked_qubits(): + tab = GeneralizedTableau(n_qubits=2, seed=0) + tab.x(0) + tab.loss_channel(0, 1.0) + tab.leakage_channel(1, 0.0, 1.0) + tab.reset(0, 1) + assert not tab.is_lost(0) and not tab.is_leaked(1) + assert tab.measure(0) == MeasurementResult.ZERO + assert tab.measure(1) == MeasurementResult.ZERO + + +def test_reset_state_leaves_lost_and_leaked_qubits(): + tab = GeneralizedTableau(n_qubits=3, seed=0) + tab.loss_channel(0, 1.0) + tab.leakage_channel(1, 0.0, 1.0) + tab.x(2) + tab.reset_state(0, 1, 2) + assert tab.is_lost(0) and tab.is_leaked(1) + assert tab.measure(0) == MeasurementResult.LOST + assert tab.measure(1) == MeasurementResult.ONE + assert tab.measure(2) == MeasurementResult.ZERO + + +def test_tableau_sum_reset_reloads_lost_qubit(): + tab = GeneralizedTableauSum(n_qubits=1, seed=0) + tab.h(0) + tab.loss_channel(0, 0.5) + tab.reset(0) + probs = tab.measure(0) + assert abs(probs.get(MeasurementResult.ZERO, 0.0) - 1.0) < 1e-12 + + +def test_tableau_sum_reset_state_leaves_lost_qubit(): + tab = GeneralizedTableauSum(n_qubits=1, seed=0) + tab.loss_channel(0, 1.0) + tab.reset_state(0) + probs = tab.measure(0) + assert abs(probs.get(MeasurementResult.LOST, 0.0) - 1.0) < 1e-12 diff --git a/skills/ppvm-usage/SKILL.md b/skills/ppvm-usage/SKILL.md index c2621e9a6..a20fbdab5 100644 --- a/skills/ppvm-usage/SKILL.md +++ b/skills/ppvm-usage/SKILL.md @@ -150,7 +150,7 @@ 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`. -Stim leakage is `I_ERROR[leakage](p0, p1)` — same `p0`/`p1` as `leakage_channel`. A leaked qubit measures the pinned `ZERO`/`ONE`, not `LOST`. `I_ERROR[reset_loss] q…` / `I_ERROR[reset_leakage] q…` (no args) map to `reset_loss_channel` / `reset_leakage_channel`. +Stim leakage is `I_ERROR[leakage](p0, p1)` — same `p0`/`p1` as `leakage_channel`. A leaked qubit measures the pinned `ZERO`/`ONE`, not `LOST`. `I_ERROR[reset_loss] q…` / `I_ERROR[reset_leakage] q…` / `I_ERROR[reset_state] q…` (no args) map to `reset_loss_channel` / `reset_leakage_channel` / `reset_state`. `R`/`RX`/`RY` and the reset half of `MR`/`MRX`/`MRY` are full resets: lost and leaked qubits come back live (`MR` reports `LOST` / the pinned bit first). ## Rust API @@ -269,7 +269,8 @@ Important: the six off-diagonal two-qubit rotations (`rxy`, `rxz`, `ryx`, `ryz`, | Method | R | P-S | P-T | |-----------------------------------------------------------------------------|---|-----|-----| | `measure(q)` → `MeasurementResult` (`ZERO`/`ONE`/`LOST` on tableau) | ✓ | — | ✓ | -| `reset(q)` | ✓ | — | ✓ | +| `reset(q)` (also un-loses / un-leaks) | ✓ | — | ✓ | +| `reset_state(q)` (live qubits only; lost / leaked untouched) | ✓ | — | — | | `depolarize1(q, p=...)` | ✓ | ✓ | ✓ | | `depolarize2(q0, q1, p=...)` | ✓ | — | — | | `pauli_error(q, [px,py,pz])` | ✓ | ✓ | ✓ | From 2199c91c5bd99ad8b89fd633e262e8ccad48c79c Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 6 Nov 2026 19:56:03 +0100 Subject: [PATCH 4/5] perf(stim): avoid extra measurement in MR reset Reuse the true readout outcome to flip live qubits back to |0>, then reset loss and leakage, instead of a full `reset` that measures again. Refs #283 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ppvm-stim/src/executor.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/ppvm-stim/src/executor.rs b/crates/ppvm-stim/src/executor.rs index 921827c4e..d80dba676 100644 --- a/crates/ppvm-stim/src/executor.rs +++ b/crates/ppvm-stim/src/executor.rs @@ -76,8 +76,8 @@ fn record_bit(record: &[Option], k: usize) -> bool { /// `MR`/`MRX`/`MRY`. /// /// The readout comes first, so a lost or leaked qubit is reported as such -/// before the reset (as for `R`) brings it back. `reset` leaves the -/// measurement record untouched. +/// before the reset (as for `R`) brings it back. The state reset reuses the +/// true (pre-flip) outcome instead of measuring again. fn measure_reset_z( tab: &mut GeneralizedTableau, q: usize, @@ -107,8 +107,15 @@ where + Copy, I: TableauIndex + Debug + Send + Sync, { - let recorded = tab.measure_noisy(q, noise); - tab.reset(q); + let true_outcome = tab.measure(q); + let recorded = true_outcome.map(|b| tab.flip_with_prob(b, noise)); + tab.overwrite_last_measurement_record(recorded); + // `x` is skipped on inactive qubits; lost qubits already sit in |0⟩. + if true_outcome == Some(true) { + tab.x(q); + } + tab.reset_loss_channel(q); + tab.reset_leakage_channel(q); recorded } From aed94c56e799adc1f0032cfdbb99362b5e1a8064 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Mon, 9 Nov 2026 09:55:25 +0100 Subject: [PATCH 5/5] Some minor fixes --- crates/ppvm-python-native/src/interface_tableau.rs | 4 +--- crates/ppvm-python-native/src/interface_tableau_sum.rs | 4 +--- crates/ppvm-tableau/src/gates/reset.rs | 10 +++++++--- crates/ppvm-traits/src/traits/reset.rs | 7 +++++++ ppvm-python/src/ppvm/generalized_tableau.py | 6 ++++++ skills/ppvm-usage/SKILL.md | 2 +- 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/crates/ppvm-python-native/src/interface_tableau.rs b/crates/ppvm-python-native/src/interface_tableau.rs index b66244f10..268986c67 100644 --- a/crates/ppvm-python-native/src/interface_tableau.rs +++ b/crates/ppvm-python-native/src/interface_tableau.rs @@ -292,9 +292,7 @@ macro_rules! create_interface { } pub fn reset_state(&mut self, targets: Vec) { - for q in targets { - self.inner.reset_state(q); - } + self.inner.reset_state_many(targets.as_slice()); } pub fn reset_x(&mut self, targets: Vec) { diff --git a/crates/ppvm-python-native/src/interface_tableau_sum.rs b/crates/ppvm-python-native/src/interface_tableau_sum.rs index 2f2e78cb9..0f8f477fb 100644 --- a/crates/ppvm-python-native/src/interface_tableau_sum.rs +++ b/crates/ppvm-python-native/src/interface_tableau_sum.rs @@ -242,9 +242,7 @@ macro_rules! create_sum_interface { } pub fn reset_state(&mut self, targets: Vec) { - for q in targets { - self.inner.reset_state(q); - } + self.inner.reset_state_many(targets.as_slice()); } /// Force a truncation pass. diff --git a/crates/ppvm-tableau/src/gates/reset.rs b/crates/ppvm-tableau/src/gates/reset.rs index 28d0e3839..06c59d431 100644 --- a/crates/ppvm-tableau/src/gates/reset.rs +++ b/crates/ppvm-tableau/src/gates/reset.rs @@ -96,8 +96,12 @@ where { fn reset(&mut self, addr0: usize) { // Un-lose / un-leak first, otherwise `reset_state` skips the qubit. - self.reset_loss_channel(addr0); - self.reset_leakage_channel(addr0); - self.reset_state(addr0); + // `reset_leakage_channel` already re-zeroes the qubit. + if self.qubit_status[addr0] == QubitStatus::Leaked { + self.reset_leakage_channel(addr0); + } else { + self.reset_loss_channel(addr0); + self.reset_state(addr0); + } } } diff --git a/crates/ppvm-traits/src/traits/reset.rs b/crates/ppvm-traits/src/traits/reset.rs index cf5db9054..826dfcb56 100644 --- a/crates/ppvm-traits/src/traits/reset.rs +++ b/crates/ppvm-traits/src/traits/reset.rs @@ -6,6 +6,13 @@ pub trait ResetState { /// Reset the qubit at `addr0` to `|0⟩` if it is live; no-op otherwise. fn reset_state(&mut self, addr0: usize); + + /// Batched [`ResetState::reset_state`]. + fn reset_state_many(&mut self, targets: &[usize]) { + for &q in targets { + self.reset_state(q); + } + } } /// Full reset to `|0⟩` of a single Pauli word, in the Heisenberg picture. diff --git a/ppvm-python/src/ppvm/generalized_tableau.py b/ppvm-python/src/ppvm/generalized_tableau.py index 2c262bac8..528e5139d 100644 --- a/ppvm-python/src/ppvm/generalized_tableau.py +++ b/ppvm-python/src/ppvm/generalized_tableau.py @@ -308,6 +308,8 @@ def reset_state(self, *targets: int | Iterable[int]) -> None: def reset_x(self, *targets: int | Iterable[int]) -> None: """Reset each target qubit to the |+> state. + Lost and leaked qubits are first returned to the computational subspace. + Args: *targets: The indices of the target qubits. """ @@ -316,6 +318,8 @@ def reset_x(self, *targets: int | Iterable[int]) -> None: def reset_y(self, *targets: int | Iterable[int]) -> None: """Reset each target qubit to the |+i> state. + Lost and leaked qubits are first returned to the computational subspace. + Args: *targets: The indices of the target qubits. """ @@ -324,6 +328,8 @@ def reset_y(self, *targets: int | Iterable[int]) -> None: def reset_z(self, *targets: int | Iterable[int]) -> None: """Reset each target qubit to the |0> state. + Lost and leaked qubits are first returned to the computational subspace. + Args: *targets: The indices of the target qubits. """ diff --git a/skills/ppvm-usage/SKILL.md b/skills/ppvm-usage/SKILL.md index a20fbdab5..5e0913b71 100644 --- a/skills/ppvm-usage/SKILL.md +++ b/skills/ppvm-usage/SKILL.md @@ -270,7 +270,7 @@ Important: the six off-diagonal two-qubit rotations (`rxy`, `rxz`, `ryx`, `ryz`, |-----------------------------------------------------------------------------|---|-----|-----| | `measure(q)` → `MeasurementResult` (`ZERO`/`ONE`/`LOST` on tableau) | ✓ | — | ✓ | | `reset(q)` (also un-loses / un-leaks) | ✓ | — | ✓ | -| `reset_state(q)` (live qubits only; lost / leaked untouched) | ✓ | — | — | +| `reset_state(q)` (live qubits only; lost / leaked untouched) | ✓ | — | ✓ | | `depolarize1(q, p=...)` | ✓ | ✓ | ✓ | | `depolarize2(q0, q1, p=...)` | ✓ | — | — | | `pauli_error(q, [px,py,pz])` | ✓ | ✓ | ✓ |