Skip to main content

ppvm_tableau/
noise.rs

1// SPDX-FileCopyrightText: 2026 The PPVM Authors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fmt::Debug;
5use std::ops::Add;
6
7use bitvec::view::BitView;
8use num::PrimInt;
9use num::complex::{Complex, Complex64, ComplexFloat};
10use num::traits::{One, ToPrimitive, Zero};
11
12use crate::prelude::*;
13use rand::RngExt;
14use rand::rngs::SmallRng;
15
16// === TableauLike impls ===
17//
18// Implementing TableauLike grants automatic implementations of all
19// single- and two-qubit Pauli noise channels via default methods.
20
21impl<T: Config> TableauLike for Tableau<T>
22where
23    T::Coeff: PartialOrd<f64>,
24    // The canonical word-level `Clifford for Tableau<T>` impl (required by the
25    // `TableauLike: Clifford` supertrait) operates on raw storage words, so it
26    // carries `Store: PrimInt` — the same bound the `GeneralizedTableau` side
27    // already requires.
28    <T::Storage as BitView>::Store: PrimInt,
29{
30    type Coeff = T::Coeff;
31    type Rng = SmallRng;
32
33    #[inline]
34    fn rng_mut(&mut self) -> &mut Self::Rng {
35        &mut self.rng
36    }
37}
38
39impl<T: Config, I: TableauIndex, C: SparseVector<Complex<T::Coeff>, I>> TableauLike
40    for GeneralizedTableau<T, I, C>
41where
42    T::Coeff: PartialOrd<f64>,
43    Complex<T::Coeff>: From<Complex<f64>>,
44    <T::Storage as BitView>::Store: PrimInt,
45{
46    type Coeff = T::Coeff;
47    type Rng = SmallRng;
48
49    #[inline]
50    fn rng_mut(&mut self) -> &mut Self::Rng {
51        &mut self.tableau.rng
52    }
53
54    // Leaked qubits are reported too, so Pauli noise skips them like lost ones.
55    #[inline]
56    fn is_qubit_lost(&self, addr: usize) -> bool {
57        self.is_inactive(addr)
58    }
59}
60
61/// `true` iff `p` is an admissible correlated-loss parameter triple.
62///
63/// The channel is completely positive exactly when every event weight is
64/// nonnegative, i.e. `p0, p1 >= 0`, `p0 + 2·p1 <= 1` (`p1` is the probability
65/// that a *named* one of the pair is lost, so the exactly-one event carries
66/// `2·p1`) and `p2 ∈ [0, 1]`. Outside that region the mixture truncates a
67/// negative survivor weight and renormalizes, and the trajectory's cumulative
68/// scan stops being a categorical sampler — both silently.
69///
70/// Coefficients are checked in `Coeff` space via `PartialOrd<f64>` (the
71/// bound every loss-channel impl already carries). Slack still applies
72/// to the `p0 + 2·p1 <= 1` sum so a saturated `[1/3, 1/3, _]` is not
73/// rejected by last-bit rounding.
74pub fn is_admissible_correlated_loss<C>(p: &[C; 3]) -> bool
75where
76    C: Clone + PartialOrd<f64> + Add<Output = C>,
77{
78    // The slack matches `ppvm-tableau-2`'s guard: reject genuinely out-of-region
79    // parameters, not last-bit noise in a legitimately saturated triple. An exact
80    // `<= 1.0` would spuriously fire on e.g. `[1/3, 1/3, _]`, where
81    // `p0 + 2·p1` rounds to `1.0000000000000002`.
82    const SLACK: f64 = 1e-9;
83    p[0] >= 0.0
84        && p[1] >= 0.0
85        && p[2] >= 0.0
86        && p[2] <= 1.0
87        && p[0].clone() + p[1].clone() + p[1].clone() <= 1.0 + SLACK
88}
89
90// === Noise trait impls ===
91//
92// Orphan rules (E0210) forbid `impl<X: TableauLike<Coeff = T::Coeff>> Depolarizing<T> for X`,
93// so we expand the four noise traits per backend via a macro. Each backend only
94// has to list its generics + where-clause once.
95
96macro_rules! impl_tableau_noise {
97    (generics: [$($gen:tt)*], ty: $ty:ty, where: [$($bound:tt)*] $(,)?) => {
98        impl<T: Config $($gen)*> Depolarizing<T> for $ty
99        where $($bound)*
100        {
101            fn depolarize1(&mut self, addr0: usize, p: T::Coeff) {
102                self.depolarize_impl(addr0, p);
103            }
104        }
105
106        impl<T: Config $($gen)*> PauliError<T> for $ty
107        where $($bound)*
108        {
109            fn pauli_error(&mut self, addr0: usize, p: [T::Coeff; 3]) {
110                self.pauli_error_impl(addr0, p);
111            }
112        }
113
114        impl<T: Config $($gen)*> TwoQubitPauliError<T> for $ty
115        where $($bound)*
116        {
117            fn two_qubit_pauli_error(&mut self, addr0: usize, addr1: usize, p: [T::Coeff; 15]) {
118                self.two_qubit_pauli_error_impl(addr0, addr1, p);
119            }
120        }
121
122        impl<T: Config $($gen)*> Depolarizing2<T> for $ty
123        where $($bound)*
124        {
125            fn depolarize2(&mut self, addr0: usize, addr1: usize, p: T::Coeff) {
126                self.depolarize2_impl(addr0, addr1, p);
127            }
128        }
129    };
130}
131
132impl_tableau_noise! {
133    generics: [],
134    ty: Tableau<T>,
135    where: [
136        T::Coeff: PartialOrd<f64>,
137        <T::Storage as BitView>::Store: PrimInt,
138    ],
139}
140
141impl_tableau_noise! {
142    generics: [, I: TableauIndex, C: SparseVector<Complex<T::Coeff>, I>],
143    ty: GeneralizedTableau<T, I, C>,
144    where: [
145        T::Coeff: PartialOrd<f64>,
146        Complex<T::Coeff>: From<Complex<f64>>,
147        <T::Storage as BitView>::Store: PrimInt,
148    ],
149}
150
151// === GeneralizedTableau-specific loss channels (no Tableau equivalent) ===
152
153impl<T: Config, I: TableauIndex + Send + Sync, C: SparseVector<Complex<T::Coeff>, I>> LossChannel<T>
154    for GeneralizedTableau<T, I, C>
155where
156    <<T as Config>::Storage as BitView>::Store: PrimInt,
157    C: std::fmt::Debug,
158    T::Coeff: PartialOrd<f64>
159        + One
160        + Zero
161        + Clone
162        + num::Num
163        + ToPrimitive
164        + std::fmt::Debug
165        + Send
166        + Sync,
167    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
168        + From<Complex64>
169        + std::ops::MulAssign
170        + std::ops::AddAssign
171        + One
172        + ComplexFloat
173        + Copy,
174    I: Debug,
175{
176    fn loss_channel(&mut self, addr0: usize, p: <T as Config>::Coeff) {
177        if p < self.tableau.rng.random::<f64>() {
178            return;
179        }
180        if self.qubit_status[addr0] == QubitStatus::Lost {
181            return;
182        }
183
184        // Temporarily live so the collapse-to-|0⟩ is not skipped on a leaked
185        // qubit. Loss overwrites leakage: after this the status is Lost,
186        // indistinguishable from a qubit lost from the computational subspace.
187        self.qubit_status[addr0] = QubitStatus::Live;
188        // NOTE: this is O(n^2) but also potentially removes coefficients, which is nice
189        let outcome = self.measure(addr0);
190        // A loss event is not a logical measurement: keep the measurement
191        // record neutral by dropping the entry the internal `measure` pushed.
192        self.measurement_record.pop();
193        if let Some(true) = outcome {
194            // flip back to 0
195            self.x(addr0);
196        }
197        self.qubit_status[addr0] = QubitStatus::Lost;
198    }
199}
200
201impl<T: Config, I: TableauIndex + Send + Sync, C: SparseVector<Complex<T::Coeff>, I>>
202    AsymmetricLossChannel<T> for GeneralizedTableau<T, I, C>
203where
204    <<T as Config>::Storage as BitView>::Store: PrimInt,
205    C: std::fmt::Debug,
206    T::Coeff: PartialOrd<f64>
207        + One
208        + Zero
209        + Clone
210        + num::Num
211        + ToPrimitive
212        + std::fmt::Debug
213        + Send
214        + Sync,
215    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
216        + From<Complex64>
217        + std::ops::MulAssign
218        + std::ops::AddAssign
219        + One
220        + ComplexFloat
221        + Copy,
222    I: Debug,
223{
224    /// State-dependent single-qubit loss ("asymmetric loss").
225    ///
226    /// Models a three-level atom whose `|0⟩` and `|1⟩` levels leak into a loss
227    /// state `|L⟩` at different rates: the qubit is lost from `|0⟩` with
228    /// probability `p0` and from `|1⟩` with `p1`. The total loss probability is
229    /// state-dependent,
230    ///
231    /// ```text
232    ///     p_tot = p0 * (1 + ⟨Z⟩)/2 + p1 * (1 - ⟨Z⟩)/2,
233    /// ```
234    ///
235    /// with `⟨Z⟩` the current Z-expectation of `addr0`. With probability `p_tot`
236    /// the qubit is collapsed, reset to `|0⟩`, and marked lost (as in
237    /// [`LossChannel::loss_channel`]); otherwise it is left unchanged.
238    ///
239    /// # Approximation
240    ///
241    /// This is the trajectory *approximation* of the true loss channel. It
242    /// reproduces the loss statistics (which qubits are flagged lost, and how
243    /// often) and is exact in the symmetric limit `p0 == p1`, where it reduces
244    /// to [`LossChannel::loss_channel`]. It does NOT apply the survival
245    /// back-action `K0 = sqrt(1-p0)|0⟩⟨0| + sqrt(1-p1)|1⟩⟨1|`: for `p0 != p1`
246    /// the faithful channel reshapes the *surviving* qubit (population tilts
247    /// toward the less-leaky level, coherences are damped), and this
248    /// implementation skips that reshaping. The back-action is non-Clifford (it
249    /// branches the coefficient vector like an `rz`), so it is omitted to keep
250    /// the channel cheap enough to apply after every gate. See issue #39.
251    fn asymmetric_loss_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff) {
252        if self.qubit_status[addr0] == QubitStatus::Lost {
253            return;
254        }
255        // State-dependent loss probability from the populations pop0/pop1.
256        let z = self.z_expectation(addr0);
257        let p_tot = p0.to_f64().unwrap() * 0.5 * (1.0 + z) + p1.to_f64().unwrap() * 0.5 * (1.0 - z);
258
259        if p_tot < self.tableau.rng.random::<f64>() {
260            return;
261        }
262        // Lost: collapse + reset to |0⟩, mirroring loss_channel. Clear leakage
263        // first so the |1⟩ → |0⟩ flip is not skipped.
264        self.qubit_status[addr0] = QubitStatus::Live;
265        if let Some(true) = self.measure(addr0) {
266            self.x(addr0);
267        }
268        self.measurement_record.pop();
269        self.qubit_status[addr0] = QubitStatus::Lost;
270    }
271}
272
273impl<T: Config, I: TableauIndex + Send + Sync, C: SparseVector<Complex<T::Coeff>, I>>
274    CorrelatedLossChannel<T> for GeneralizedTableau<T, I, C>
275where
276    <<T as Config>::Storage as BitView>::Store: PrimInt,
277    C: std::fmt::Debug,
278    T::Coeff: PartialOrd<f64>
279        + One
280        + Zero
281        + Clone
282        + num::Num
283        + ToPrimitive
284        + std::fmt::Debug
285        + Send
286        + Sync,
287    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
288        + From<Complex64>
289        + std::ops::MulAssign
290        + std::ops::AddAssign
291        + One
292        + ComplexFloat
293        + Copy,
294    I: Debug,
295{
296    /// Apply a correlated loss channel to qubits at `addr0` and `addr1`.
297    ///
298    /// The three probabilities are:
299    /// * `p[0]`: The probability of losing both qubits simultaneously when
300    ///   both of them are in the qubit subspace.
301    /// * `p[1]`: The probability of losing a **named** one of the two qubits when
302    ///   both of them are in the qubit subspace, so the probability of losing
303    ///   *exactly one* is `2·p[1]` and the both-present survivor keeps
304    ///   `1 − 2·p[1] − p[0]` (which qubit is lost is 50/50). See
305    ///   [`ppvm_traits::traits::CorrelatedLossChannel`] for the normative
306    ///   statement.
307    /// * `p[2]`: The probability of losing one qubit when the other one has already
308    ///   been lost prior to the channel.
309    fn correlated_loss_channel(
310        &mut self,
311        addr0: usize,
312        addr1: usize,
313        p: [<T as Config>::Coeff; 3],
314    ) {
315        debug_assert!(
316            is_admissible_correlated_loss(&p),
317            "correlated loss needs p0, p1 >= 0, p0 + 2*p1 <= 1, p2 in [0, 1]; got {p:?}"
318        );
319
320        if self.qubit_status[addr0] == QubitStatus::Lost {
321            self.loss_channel(addr1, p[2].clone());
322            return;
323        } else if self.qubit_status[addr1] == QubitStatus::Lost {
324            self.loss_channel(addr0, p[2].clone());
325            return;
326        }
327        // A leaked partner is still present, so it does not take the p[2] path.
328        // Using the both-present (p[0], p[1]) rates is the current approximation:
329        // leakage would change the two-qubit dynamics, but the channel stays
330        // correlated.
331
332        let r = self.tableau.rng.random::<f64>();
333        let mut cumulative = T::Coeff::zero();
334        for (i, p_i) in p[..2].iter().enumerate() {
335            // `p[1]` is the probability that a *named* one of the pair is lost,
336            // so the exactly-one event carries `2·p[1]` in this categorical
337            // scan; the fair coin below then picks which qubit.
338            cumulative += if i == 1 {
339                p_i.clone() + p_i.clone()
340            } else {
341                p_i.clone()
342            };
343            if cumulative > r {
344                if i == 0 {
345                    // both lost — un-leak first so reset canonicalizes to |0⟩
346                    self.qubit_status[addr0] = QubitStatus::Live;
347                    self.qubit_status[addr1] = QubitStatus::Live;
348                    self.reset(addr0);
349                    self.reset(addr1);
350                    self.qubit_status[addr0] = QubitStatus::Lost;
351                    self.qubit_status[addr1] = QubitStatus::Lost;
352                } else {
353                    // only losing a single qubit,
354                    let choice = self.tableau.rng.random::<bool>();
355                    if choice {
356                        self.qubit_status[addr1] = QubitStatus::Live;
357                        self.reset(addr1);
358                        self.qubit_status[addr1] = QubitStatus::Lost;
359                    } else {
360                        self.qubit_status[addr0] = QubitStatus::Live;
361                        self.reset(addr0);
362                        self.qubit_status[addr0] = QubitStatus::Lost;
363                    }
364                }
365                return;
366            }
367        }
368    }
369}
370
371impl<T: Config, I: TableauIndex, C: SparseVector<Complex<T::Coeff>, I>> ResetLossChannel<T>
372    for GeneralizedTableau<T, I, C>
373{
374    fn reset_loss_channel(&mut self, addr0: usize) {
375        if self.qubit_status[addr0] == QubitStatus::Lost {
376            self.qubit_status[addr0] = QubitStatus::Live;
377        }
378    }
379}
380
381impl<T: Config, I: TableauIndex, C: SparseVector<Complex<T::Coeff>, I>> LeakageChannel<T>
382    for GeneralizedTableau<T, I, C>
383where
384    <<T as Config>::Storage as BitView>::Store: PrimInt,
385    C: std::fmt::Debug,
386    T::Coeff: PartialOrd
387        + PartialOrd<f64>
388        + One
389        + Zero
390        + Clone
391        + num::Num
392        + ToPrimitive
393        + std::fmt::Debug,
394    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
395        + From<Complex64>
396        + std::ops::MulAssign
397        + std::ops::AddAssign
398        + One
399        + ComplexFloat
400        + Copy,
401    I: Debug,
402{
403    fn leakage_channel(&mut self, addr0: usize, p0: T::Coeff, p1: T::Coeff) {
404        if self.is_inactive(addr0) {
405            return;
406        }
407
408        debug_assert!(T::Coeff::zero() <= p0 && p0 <= T::Coeff::one());
409        debug_assert!(T::Coeff::zero() <= p1 && p1 <= T::Coeff::one());
410        debug_assert!(
411            T::Coeff::zero() <= p0.clone() + p1.clone()
412                && p0.clone() + p1.clone() <= T::Coeff::one()
413        );
414
415        let p_tot = p0.clone() + p1;
416        let r = self.tableau.rng.random::<f64>();
417
418        if p_tot <= r {
419            return;
420        }
421
422        // Collapse the qubit to a definite basis state. This internal
423        // measurement is a mechanism, not a logical measurement, so drop the
424        // record entry it pushed (mirrors `loss_channel`).
425        let m = self
426            .measure(addr0)
427            .expect("qubit was checked active before, this should be unreachable");
428        self.measurement_record.pop();
429
430        // Pin the qubit to |0⟩ (prob p0) or |1⟩ (prob p1). r < p_tot = p0 + p1
431        // here, so r < p0 selects |0⟩ and p0 <= r < p_tot selects |1⟩. The pin
432        // must be applied before flagging the qubit leaked, otherwise the `x`
433        // gate would be skipped by `is_inactive`.
434        if p0 > r {
435            if m {
436                self.x(addr0);
437            }
438        } else if !m {
439            self.x(addr0);
440        }
441        self.qubit_status[addr0] = QubitStatus::Leaked;
442    }
443}
444
445impl<T, I, C> ResetLeakageChannel<T> for GeneralizedTableau<T, I, C>
446where
447    T: Config,
448    <<T as Config>::Storage as BitView>::Store: PrimInt,
449    I: TableauIndex + Debug,
450    C: SparseVector<Complex<T::Coeff>, I> + Debug,
451    T::Coeff: One
452        + Zero
453        + Clone
454        + num::Num
455        + ToPrimitive
456        + std::fmt::Debug
457        + std::ops::Mul<f64>
458        + PartialOrd<f64>,
459    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
460        + From<Complex64>
461        + std::ops::MulAssign
462        + std::ops::AddAssign
463        + One
464        + ComplexFloat
465        + Copy,
466{
467    fn reset_leakage_channel(&mut self, addr0: usize) {
468        if self.qubit_status[addr0] != QubitStatus::Leaked {
469            // Live: no-op. Lost: cannot recover a lost qubit.
470            return;
471        }
472
473        self.qubit_status[addr0] = QubitStatus::Live;
474        self.reset(addr0);
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use ppvm_pauli_sum::config::fxhash::ByteF64;
482
483    type TestConfig = ByteF64<1>;
484    type TestTab = GeneralizedTableau<TestConfig>;
485
486    fn tab(n: usize) -> TestTab {
487        GeneralizedTableau::new(n, 1e-12)
488    }
489
490    // === Depolarizing ===
491
492    #[test]
493    fn depolarize_p0_no_change() {
494        let mut t = tab(1);
495        t.depolarize1(0, 0.0);
496        assert!(!t.measure(0).unwrap());
497    }
498
499    #[test]
500    fn depolarize_p1_does_not_mark_lost() {
501        // With p=1.0 an error is always applied; verify is_lost is unaffected
502        let mut t = tab(1);
503        t.depolarize1(0, 1.0);
504        assert!(!t.is_lost(0));
505    }
506
507    // === PauliError ===
508
509    #[test]
510    fn pauli_error_zero_prob_no_change() {
511        let mut t = tab(1);
512        t.pauli_error(0, [0.0, 0.0, 0.0]);
513        assert!(!t.measure(0).unwrap());
514    }
515
516    #[test]
517    fn pauli_error_x_flips_qubit() {
518        let mut t = tab(1);
519        t.pauli_error(0, [1.0, 0.0, 0.0]); // X|0⟩ = |1⟩
520        assert!(t.measure(0).unwrap());
521    }
522
523    #[test]
524    fn pauli_error_y_flips_qubit() {
525        let mut t = tab(1);
526        t.pauli_error(0, [0.0, 1.0, 0.0]); // Y|0⟩ = i|1⟩
527        assert!(t.measure(0).unwrap());
528    }
529
530    #[test]
531    fn pauli_error_z_no_measurement_change() {
532        let mut t = tab(1);
533        t.pauli_error(0, [0.0, 0.0, 1.0]); // Z|0⟩ = -|0⟩, still measures 0
534        assert!(!t.measure(0).unwrap());
535    }
536
537    #[test]
538    fn pauli_error_x_on_excited_qubit_flips_back() {
539        let mut t = tab(1);
540        t.x(0); // |1⟩
541        t.pauli_error(0, [1.0, 0.0, 0.0]); // X|1⟩ = |0⟩
542        assert!(!t.measure(0).unwrap());
543    }
544
545    // === TwoQubitPauliError ===
546
547    #[test]
548    fn two_qubit_pauli_error_zero_prob_no_change() {
549        let mut t = tab(2);
550        t.two_qubit_pauli_error(0, 1, [0.0; 15]);
551        assert!(!t.measure(0).unwrap());
552        assert!(!t.measure(1).unwrap());
553    }
554
555    #[test]
556    fn two_qubit_pauli_error_ix_flips_second_only() {
557        // p[0] = 1.0 → IX: I on addr0, X on addr1
558        let mut t = tab(2);
559        let mut p = [0.0f64; 15];
560        p[0] = 1.0;
561        t.two_qubit_pauli_error(0, 1, p);
562        assert!(!t.measure(0).unwrap());
563        assert!(t.measure(1).unwrap());
564    }
565
566    #[test]
567    fn two_qubit_pauli_error_xi_flips_first_only() {
568        // p[3] = 1.0 → XI: X on addr0, I on addr1
569        let mut t = tab(2);
570        let mut p = [0.0f64; 15];
571        p[3] = 1.0;
572        t.two_qubit_pauli_error(0, 1, p);
573        assert!(t.measure(0).unwrap());
574        assert!(!t.measure(1).unwrap());
575    }
576
577    #[test]
578    fn two_qubit_pauli_error_xx_flips_both() {
579        // p[4] = 1.0 → XX
580        let mut t = tab(2);
581        let mut p = [0.0f64; 15];
582        p[4] = 1.0;
583        t.two_qubit_pauli_error(0, 1, p);
584        assert!(t.measure(0).unwrap());
585        assert!(t.measure(1).unwrap());
586    }
587
588    #[test]
589    fn two_qubit_pauli_error_zz_no_measurement_change() {
590        // p[14] = 1.0 → ZZ: Z|0⟩ = -|0⟩ on both, still measures 0
591        let mut t = tab(2);
592        let mut p = [0.0f64; 15];
593        p[14] = 1.0;
594        t.two_qubit_pauli_error(0, 1, p);
595        assert!(!t.measure(0).unwrap());
596        assert!(!t.measure(1).unwrap());
597    }
598
599    #[test]
600    fn two_qubit_pauli_error_both_lost_no_change() {
601        let mut t = tab(2);
602        t.qubit_status[0] = QubitStatus::Lost;
603        t.qubit_status[1] = QubitStatus::Lost;
604        let mut p = [0.0f64; 15];
605        p[4] = 1.0; // XX — skipped entirely
606        t.two_qubit_pauli_error(0, 1, p);
607        assert!(t.is_lost(0));
608        assert!(t.is_lost(1));
609    }
610
611    #[test]
612    fn two_qubit_pauli_error_first_lost_no_apply() {
613        // addr0 lost; p[0] = 1.0 (IX) → marginal p_x for addr1 = 1.0
614        let mut t = tab(2);
615        t.qubit_status[0] = QubitStatus::Lost;
616        let mut p = [0.0f64; 15];
617        p[0] = 1.0; // IX
618        t.two_qubit_pauli_error(0, 1, p);
619        assert!(!t.measure(1).unwrap()); // nothing applied to addr1
620    }
621
622    // === Depolarizing2 ===
623
624    #[test]
625    fn depolarize2_p0_no_change() {
626        let mut t = tab(2);
627        t.depolarize2(0, 1, 0.0);
628        assert!(!t.measure(0).unwrap());
629        assert!(!t.measure(1).unwrap());
630    }
631
632    #[test]
633    fn depolarize2_both_lost_no_change() {
634        let mut t = tab(2);
635        t.qubit_status[0] = QubitStatus::Lost;
636        t.qubit_status[1] = QubitStatus::Lost;
637        t.depolarize2(0, 1, 1.0);
638        assert!(t.is_lost(0));
639        assert!(t.is_lost(1));
640    }
641
642    #[test]
643    fn depolarize2_first_lost_p0_second_unchanged() {
644        let mut t = tab(2);
645        t.qubit_status[0] = QubitStatus::Lost;
646        t.depolarize2(0, 1, 0.0); // effective p on addr1 = 4/5 * 0 = 0
647        assert!(!t.measure(1).unwrap());
648    }
649
650    #[test]
651    fn depolarize2_second_lost_p0_first_unchanged() {
652        let mut t = tab(2);
653        t.qubit_status[1] = QubitStatus::Lost;
654        t.depolarize2(0, 1, 0.0); // effective p on addr0 = 4/5 * 0 = 0
655        assert!(!t.measure(0).unwrap());
656    }
657
658    // === LossChannel ===
659
660    #[test]
661    fn loss_channel_p0_qubit_not_lost() {
662        let mut t = tab(1);
663        t.loss_channel(0, 0.0);
664        assert!(!t.is_lost(0));
665    }
666
667    #[test]
668    fn loss_channel_p1_qubit_marked_lost() {
669        let mut t = tab(1);
670        t.loss_channel(0, 1.0);
671        assert!(t.is_lost(0));
672    }
673
674    #[test]
675    fn loss_channel_p1_qubit_reset_to_zero() {
676        // Qubit starts in |1⟩; loss_channel should measure, reset to |0⟩, then mark lost
677        let mut t = tab(1);
678        t.x(0);
679        t.loss_channel(0, 1.0);
680        assert!(t.is_lost(0));
681        assert!(t.measure(0).is_none()); // Reset to |0⟩ before marking lost
682    }
683
684    #[test]
685    fn loss_channel_does_not_pollute_measurement_record() {
686        // A loss event is not a logical measurement and must leave the
687        // measurement record untouched.
688        let mut t = tab(1);
689        t.x(0);
690        t.loss_channel(0, 1.0);
691        assert!(t.current_measurement_record().is_empty());
692    }
693
694    #[test]
695    fn loss_channel_p1_subsequent_gate_is_noop() {
696        let mut t = tab(1);
697        t.loss_channel(0, 1.0);
698        t.x(0); // No-op: qubit is lost
699        assert!(t.measure(0).is_none());
700        t.qubit_status[0] = QubitStatus::Live;
701        assert!(!t.measure(0).unwrap()); // still 0
702    }
703
704    #[test]
705    fn loss_channel_p0_second_qubit_unaffected() {
706        let mut t = tab(2);
707        t.loss_channel(0, 0.0);
708        t.loss_channel(1, 0.0);
709        assert!(!t.is_lost(0));
710        assert!(!t.is_lost(1));
711    }
712
713    // === ResetLossChannel ===
714
715    #[test]
716    fn reset_loss_channel_clears_lost_flag() {
717        let mut t = tab(1);
718        t.loss_channel(0, 1.0);
719        assert!(t.is_lost(0));
720        t.reset_loss_channel(0);
721        assert!(!t.is_lost(0));
722    }
723
724    #[test]
725    fn reset_loss_channel_qubit_in_ground_state() {
726        // loss_channel resets qubit to |0⟩; after reset_loss_channel it should still be |0⟩
727        let mut t = tab(1);
728        t.x(0); // |1⟩
729        t.loss_channel(0, 1.0); // measures, resets to |0⟩, marks lost
730        t.reset_loss_channel(0);
731        assert!(!t.measure(0).unwrap()); // back in |0⟩
732    }
733
734    #[test]
735    fn reset_loss_channel_gates_work_again() {
736        let mut t = tab(1);
737        t.loss_channel(0, 1.0);
738        t.reset_loss_channel(0);
739        t.x(0); // should no longer be a no-op
740        assert!(t.measure(0).unwrap());
741    }
742
743    // === Seeded RNG ordering ===
744    //
745    // `Depolarizing` and `PauliError` must consume RNG unconditionally so
746    // that seeded traces are reproducible regardless of loss events. The
747    // selected Clifford gate no-ops on lost qubits (see gates/clifford.rs).
748
749    #[test]
750    fn depolarize_rng_consumed_on_lost_qubit() {
751        let seed = 42u64;
752        let mut t_active = tab(1);
753        t_active.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
754        t_active.depolarize1(0, 0.3);
755        let next_active: f64 = t_active.tableau.rng.random();
756
757        let mut t_lost = tab(1);
758        t_lost.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
759        t_lost.qubit_status[0] = QubitStatus::Lost;
760        t_lost.depolarize1(0, 0.3);
761        let next_lost: f64 = t_lost.tableau.rng.random();
762
763        assert_eq!(next_active, next_lost);
764    }
765
766    #[test]
767    fn pauli_error_rng_consumed_on_lost_qubit() {
768        let seed = 42u64;
769        let mut t_active = tab(1);
770        t_active.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
771        t_active.pauli_error(0, [0.1, 0.1, 0.1]);
772        let next_active: f64 = t_active.tableau.rng.random();
773
774        let mut t_lost = tab(1);
775        t_lost.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
776        t_lost.qubit_status[0] = QubitStatus::Lost;
777        t_lost.pauli_error(0, [0.1, 0.1, 0.1]);
778        let next_lost: f64 = t_lost.tableau.rng.random();
779
780        assert_eq!(next_active, next_lost);
781    }
782
783    // === Statistical tests ===
784
785    #[test]
786    fn depolarize_statistics() {
787        // Starting from |0⟩, P(measure 1) = P(X) + P(Y) = p/3 + p/3 = 2p/3.
788        // Z leaves |0⟩ unchanged; I leaves |0⟩ unchanged.
789        let p = 0.6_f64;
790        let expected = 2.0 * p / 3.0; // 0.4
791        let trials = 500;
792
793        let ones = (0..trials)
794            .filter(|_| {
795                let mut t = tab(1);
796                t.depolarize1(0, p);
797                t.measure(0).unwrap()
798            })
799            .count();
800
801        let fraction = ones as f64 / trials as f64;
802        // tolerance ~5σ: σ = sqrt(expected*(1-expected)/trials) ≈ 0.022
803        assert!(
804            (fraction - expected).abs() < 0.1,
805            "Expected fraction {expected:.3}, got {fraction:.3}"
806        );
807    }
808
809    #[test]
810    fn depolarize2_statistics() {
811        // Starting from |00⟩, errors that flip qubit 0 to |1⟩ are X and Y on that qubit:
812        // XI, XX, XY, XZ, YI, YX, YY, YZ — 8 out of 15, so P(q0=1) = 8p/15.
813        let p = 0.6_f64;
814        let expected = 8.0 * p / 15.0; // 0.32
815        let trials = 500;
816
817        let ones = (0..trials)
818            .filter(|_| {
819                let mut t = tab(2);
820                t.depolarize2(0, 1, p);
821                t.measure(0).unwrap()
822            })
823            .count();
824
825        let fraction = ones as f64 / trials as f64;
826        // tolerance ~5σ: σ = sqrt(expected*(1-expected)/trials) ≈ 0.021
827        assert!(
828            (fraction - expected).abs() < 0.1,
829            "Expected fraction {expected:.3}, got {fraction:.3}"
830        );
831    }
832
833    #[test]
834    fn test_cnot() {
835        let mut t = tab(2);
836        t.x(0);
837        t.cnot(0, 1);
838        t.loss_channel(0, 1.0);
839        assert!(t.measure(0).is_none());
840        assert!(t.measure(1).unwrap());
841
842        let mut t = tab(2);
843        t.loss_channel(0, 1.0);
844        t.x(0);
845        t.cnot(0, 1);
846        assert!(!t.measure(1).unwrap());
847        assert!(t.measure(0).is_none());
848    }
849
850    #[test]
851    fn test_ghz_statistics() {
852        let mut t = tab(2);
853        t.h(0);
854        t.cnot(0, 1);
855
856        let trials = 100u64;
857        let mut z_avg = 0.0;
858        let p = 0.1;
859        for i in 0..trials {
860            let mut t_trial = t.fork(Some(i));
861            t_trial.loss_channel(0, p);
862
863            let outcome0 = t_trial.measure(0);
864            let outcome1 = t_trial.measure(1);
865            if outcome0.unwrap_or(false) == outcome1.unwrap_or(false) {
866                z_avg += 1.0 / trials as f64;
867            } else {
868                z_avg += -1.0 / trials as f64;
869            }
870        }
871
872        println!("{}", z_avg);
873        assert!((z_avg - (1.0 - p)).abs() < 10.0 / trials as f64);
874    }
875
876    // === CorrelatedLossChannel ===
877
878    #[test]
879    fn correlated_loss_p0_no_loss() {
880        // All probabilities zero: neither qubit should be lost.
881        let mut t = tab(2);
882        t.correlated_loss_channel(0, 1, [0.0, 0.0, 0.0]);
883        assert!(!t.is_lost(0));
884        assert!(!t.is_lost(1));
885    }
886
887    #[test]
888    fn correlated_loss_p0_both_lost() {
889        // p[0]=1 → both qubits always lost.
890        let mut t = tab(2);
891        t.correlated_loss_channel(0, 1, [1.0, 0.0, 0.0]);
892        assert!(t.is_lost(0));
893        assert!(t.is_lost(1));
894    }
895
896    #[test]
897    fn correlated_loss_p1_exactly_one_lost() {
898        // p[1]=0.5 → exactly one qubit lost each time, since `p[1]` is the
899        // probability that a *named* one of the pair is lost and so the
900        // exactly-one event carries 2·p[1] = 1. (`[0.0, 1.0, 0.0]`, which this
901        // test used before, is inadmissible under that convention:
902        // p0 + 2·p1 = 2 > 1.)
903        let trials = 200;
904        for seed in 0..trials {
905            let mut t = tab(2);
906            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
907            t.correlated_loss_channel(0, 1, [0.0, 0.5, 0.0]);
908            assert!(
909                t.is_lost(0) ^ t.is_lost(1),
910                "Expected exactly one lost qubit (seed {seed})"
911            );
912        }
913    }
914
915    #[test]
916    fn correlated_loss_p1_both_qubits_chosen_equally() {
917        // With 2·p[1]=1 the coin flip should lose addr0 and addr1 with equal
918        // frequency.
919        let trials = 1000u64;
920        let mut addr0_lost = 0u64;
921        for seed in 0..trials {
922            let mut t = tab(2);
923            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
924            t.correlated_loss_channel(0, 1, [0.0, 0.5, 0.0]);
925            if t.is_lost(0) {
926                addr0_lost += 1;
927            }
928        }
929        let fraction = addr0_lost as f64 / trials as f64;
930        // Expected 0.5; 5σ tolerance with σ ≈ 0.016
931        assert!(
932            (fraction - 0.5).abs() < 0.08,
933            "Expected ~0.5, got {fraction:.3}"
934        );
935    }
936
937    #[test]
938    fn correlated_loss_both_lost_resets_to_zero() {
939        // When both qubits are lost their state should have been reset to |0⟩.
940        let mut t = tab(2);
941        t.x(0);
942        t.x(1);
943        t.correlated_loss_channel(0, 1, [1.0, 0.0, 0.0]);
944        assert!(t.is_lost(0));
945        assert!(t.is_lost(1));
946        // Restore so we can measure.
947        t.qubit_status[0] = QubitStatus::Live;
948        t.qubit_status[1] = QubitStatus::Live;
949        assert!(!t.measure(0).unwrap());
950        assert!(!t.measure(1).unwrap());
951    }
952
953    #[test]
954    fn correlated_loss_single_lost_resets_to_zero() {
955        // The lost qubit should be in |0⟩; the surviving qubit keeps its state.
956        // Use a seed where addr0 ends up being the lost one.
957        // We iterate seeds until we get addr0 lost, then verify.
958        for seed in 0..1000u64 {
959            let mut t = tab(2);
960            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
961            t.x(0); // put addr0 in |1⟩
962            // 2·p[1] = 1: exactly one qubit is lost in every trial.
963            t.correlated_loss_channel(0, 1, [0.0, 0.5, 0.0]);
964            if t.is_lost(0) {
965                t.qubit_status[0] = QubitStatus::Live;
966                assert!(!t.measure(0).unwrap(), "Lost qubit should be reset to |0⟩");
967                return;
968            }
969        }
970        panic!("addr0 was never chosen as the lost qubit in 1000 trials");
971    }
972
973    #[test]
974    fn correlated_loss_addr0_already_lost_applies_p2_to_addr1() {
975        // addr0 already lost → addr1 should be lost with probability p[2]=1.
976        let mut t = tab(2);
977        t.qubit_status[0] = QubitStatus::Lost;
978        t.correlated_loss_channel(0, 1, [0.0, 0.0, 1.0]);
979        assert!(t.is_lost(0));
980        assert!(t.is_lost(1));
981    }
982
983    #[test]
984    fn correlated_loss_addr1_already_lost_applies_p2_to_addr0() {
985        // addr1 already lost → addr0 should be lost with probability p[2]=1.
986        let mut t = tab(2);
987        t.qubit_status[1] = QubitStatus::Lost;
988        t.correlated_loss_channel(0, 1, [0.0, 0.0, 1.0]);
989        assert!(t.is_lost(0));
990        assert!(t.is_lost(1));
991    }
992
993    #[test]
994    fn correlated_loss_addr0_already_lost_p2_zero_addr1_survives() {
995        // addr0 already lost, p[2]=0 → addr1 stays active.
996        let mut t = tab(2);
997        t.qubit_status[0] = QubitStatus::Lost;
998        t.correlated_loss_channel(0, 1, [0.0, 0.0, 0.0]);
999        assert!(!t.is_lost(1));
1000    }
1001
1002    #[test]
1003    fn correlated_loss_leaked_partner_uses_both_present_branch() {
1004        // Leaked atoms are still present, so p[0]=1 loses both (overwriting
1005        // leakage) rather than taking the p[2] early return.
1006        let mut t = tab(2);
1007        t.leakage_channel(0, 0.0, 1.0);
1008        t.correlated_loss_channel(0, 1, [1.0, 0.0, 0.0]);
1009        assert!(t.is_lost(0));
1010        assert!(t.is_lost(1));
1011        assert!(!t.is_leaked(0));
1012    }
1013
1014    #[test]
1015    fn correlated_loss_statistics_both() {
1016        // P(both lost) should converge to p[0].
1017        let p_both = 0.3_f64;
1018        let trials = 1000u64;
1019        let mut both_lost = 0u64;
1020        for seed in 0..trials {
1021            let mut t = tab(2);
1022            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
1023            t.correlated_loss_channel(0, 1, [p_both, 0.0, 0.0]);
1024            if t.is_lost(0) && t.is_lost(1) {
1025                both_lost += 1;
1026            }
1027        }
1028        let fraction = both_lost as f64 / trials as f64;
1029        // 5σ tolerance: σ = sqrt(0.3*0.7/1000) ≈ 0.014
1030        assert!(
1031            (fraction - p_both).abs() < 0.07,
1032            "Expected ~{p_both:.2}, got {fraction:.3}"
1033        );
1034    }
1035
1036    #[test]
1037    fn correlated_loss_statistics_single() {
1038        // `p[1]` is the probability that a *named* one of the pair is lost, so
1039        // P(exactly one lost) converges to 2·p[1]. (This test previously
1040        // asserted `p[1]`, i.e. the rejected convention.)
1041        let p_single = 0.2_f64;
1042        let expected = 2.0 * p_single;
1043        let trials = 1000u64;
1044        let mut one_lost = 0u64;
1045        for seed in 0..trials {
1046            let mut t = tab(2);
1047            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
1048            t.correlated_loss_channel(0, 1, [0.0, p_single, 0.0]);
1049            if t.is_lost(0) ^ t.is_lost(1) {
1050                one_lost += 1;
1051            }
1052        }
1053        let fraction = one_lost as f64 / trials as f64;
1054        // 5σ: σ = sqrt(0.4*0.6/1000) ≈ 0.015
1055        assert!(
1056            (fraction - expected).abs() < 0.08,
1057            "Expected ~{expected:.2}, got {fraction:.3}"
1058        );
1059    }
1060
1061    // === G-040 — the correlated-loss `p[1]` convention ===
1062    //
1063    // The paper (`ppvm-paper/main.tex:462`, `:523`, `:845`) is the definition of
1064    // record: `p[1]` is `p_LQ`, the probability that a **named** one of the pair
1065    // is lost, so `P(exactly one lost) = 2·p[1]` and the both-present survivor
1066    // scales by `1 − 2·p[1] − p[0]`. `ppvm-pauli-sum` already reads it that way;
1067    // these tests pin this trajectory sampler to the same number, because the
1068    // cross-backend disagreement is the actual bug a Python user hits.
1069
1070    type LossyTestSum = PauliSum<
1071        ppvm_pauli_sum::config::fxhash::Byte<
1072            1,
1073            f64,
1074            NoStrategy,
1075            LossyPauliWord<[u8; 1], fxhash::FxBuildHasher>,
1076        >,
1077    >;
1078
1079    /// The Heisenberg scale factor `ppvm-pauli-sum` applies to a fully
1080    /// in-subspace observable, i.e. `1 − p[0] − P(exactly one lost)`.
1081    fn pauli_sum_survivor(p: [f64; 3]) -> f64 {
1082        let mut sum = LossyTestSum::builder().n_qubits(2).build();
1083        sum += ("ZZ", 1.0);
1084        sum.correlated_loss_channel(0, 1, p);
1085        let zz: LossyPauliWord<[u8; 1], fxhash::FxBuildHasher> = "ZZ".into();
1086        *sum.data()
1087            .get(&zz)
1088            .expect("the all-present term survives a pure rescale")
1089    }
1090
1091    #[test]
1092    fn correlated_loss_exactly_one_lost_is_two_p1_and_agrees_with_pauli_sum() {
1093        let p1 = 0.3_f64;
1094        let p = [0.0, p1, 0.0];
1095        let trials = 20_000u64;
1096        let mut one_lost = 0u64;
1097        for seed in 0..trials {
1098            let mut t = tab(2);
1099            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
1100            t.correlated_loss_channel(0, 1, p);
1101            if t.is_lost(0) ^ t.is_lost(1) {
1102                one_lost += 1;
1103            }
1104        }
1105        let trajectory = one_lost as f64 / trials as f64;
1106        // Same number, read off the (already correct) Heisenberg backend.
1107        let pauli_sum = 1.0 - pauli_sum_survivor(p);
1108        assert!(
1109            (pauli_sum - 2.0 * p1).abs() < 1e-12,
1110            "ppvm-pauli-sum P(exactly one) = {pauli_sum}, want 2*p[1] = {}",
1111            2.0 * p1
1112        );
1113        assert!(
1114            (trajectory - 2.0 * p1).abs() < 0.02,
1115            "trajectory P(exactly one lost) = {trajectory:.4}, want 2*p[1] = {} \
1116             (ppvm-pauli-sum says {pauli_sum})",
1117            2.0 * p1
1118        );
1119    }
1120
1121    /// The paper's transport prediction: with `[p/3, p/3, p/3]` the per-event
1122    /// both-present survival is `1 − p[0] − 2·p[1] = 1 − p`.
1123    #[test]
1124    fn correlated_loss_paper_transport_survival_is_one_minus_p() {
1125        let p = 0.15_f64;
1126        let third = p / 3.0;
1127        let probabilities = [third, third, third];
1128        let trials = 20_000u64;
1129        let mut none_lost = 0u64;
1130        for seed in 0..trials {
1131            let mut t = tab(2);
1132            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
1133            t.correlated_loss_channel(0, 1, probabilities);
1134            if !t.is_lost(0) && !t.is_lost(1) {
1135                none_lost += 1;
1136            }
1137        }
1138        let fraction = none_lost as f64 / trials as f64;
1139        assert!(
1140            (fraction - (1.0 - p)).abs() < 0.02,
1141            "trajectory P(no loss) = {fraction:.4}, want 1 - {p} = {}",
1142            1.0 - p
1143        );
1144        let survivor = pauli_sum_survivor(probabilities);
1145        assert!(
1146            (survivor - (1.0 - p)).abs() < 1e-12,
1147            "ppvm-pauli-sum survivor {survivor}, want 1 - {p}"
1148        );
1149    }
1150
1151    // G-043 — the admissible region `p0, p1 >= 0`, `p0 + 2·p1 <= 1`,
1152    // `p2 ∈ [0, 1]` is what makes the channel completely positive. Outside it
1153    // the cumulative scan below silently stops being a categorical sampler.
1154
1155    #[test]
1156    #[cfg(debug_assertions)]
1157    #[should_panic(expected = "p0 + 2*p1 <= 1")]
1158    fn correlated_loss_rejects_inadmissible_probabilities() {
1159        let mut t = tab(2);
1160        t.correlated_loss_channel(0, 1, [0.6, 0.6, 0.0]);
1161    }
1162
1163    #[test]
1164    #[cfg(debug_assertions)]
1165    #[should_panic(expected = "p0 + 2*p1 <= 1")]
1166    fn correlated_loss_rejects_negative_probabilities() {
1167        let mut t = tab(2);
1168        t.correlated_loss_channel(0, 1, [5.0, -3.0, 17.0]);
1169    }
1170
1171    /// Coeff stand-in that cannot be converted to `f64`; used to ensure the helper
1172    /// only relies on `PartialOrd<f64>` comparisons.
1173    #[derive(Clone, Copy)]
1174    struct NoF64(f64);
1175
1176    impl ToPrimitive for NoF64 {
1177        fn to_i64(&self) -> Option<i64> {
1178            None
1179        }
1180        fn to_u64(&self) -> Option<u64> {
1181            None
1182        }
1183        fn to_f64(&self) -> Option<f64> {
1184            None
1185        }
1186    }
1187
1188    impl PartialEq<f64> for NoF64 {
1189        fn eq(&self, other: &f64) -> bool {
1190            self.0 == *other
1191        }
1192    }
1193
1194    impl PartialOrd<f64> for NoF64 {
1195        fn partial_cmp(&self, other: &f64) -> Option<std::cmp::Ordering> {
1196            self.0.partial_cmp(other)
1197        }
1198    }
1199
1200    impl std::ops::Add for NoF64 {
1201        type Output = Self;
1202        fn add(self, rhs: Self) -> Self {
1203            Self(self.0 + rhs.0)
1204        }
1205    }
1206
1207    #[test]
1208    fn is_admissible_rejects_negative_p1() {
1209        assert!(!is_admissible_correlated_loss(&[0.0, -0.1, 0.0]));
1210    }
1211
1212    #[test]
1213    fn is_admissible_accepts_f64_saturated_third() {
1214        assert!(is_admissible_correlated_loss(&[1.0 / 3.0, 1.0 / 3.0, 0.5]));
1215    }
1216
1217    #[test]
1218    fn is_admissible_rejects_when_to_f64_fails() {
1219        let p = [NoF64(0.6), NoF64(0.6), NoF64(0.0)];
1220        assert!(
1221            !is_admissible_correlated_loss(&p),
1222            "p0 + 2·p1 = 1.8 must be inadmissible even without an f64 conversion"
1223        );
1224    }
1225
1226    #[test]
1227    fn is_admissible_accepts_saturated_boundary_when_to_f64_fails() {
1228        let p = [NoF64(1.0 / 3.0), NoF64(1.0 / 3.0), NoF64(0.5)];
1229        assert!(
1230            is_admissible_correlated_loss(&p),
1231            "[1/3, 1/3, _] must stay admissible without an f64 conversion"
1232        );
1233    }
1234
1235    /// The saturated boundary `p0 + 2·p1 == 1` is admissible and must not trip
1236    /// the guard — including the `[1/3, 1/3, _]` triple whose sum rounds above
1237    /// one in binary floating point.
1238    #[test]
1239    fn correlated_loss_saturated_boundary_is_admissible() {
1240        let mut t = tab(2);
1241        t.correlated_loss_channel(0, 1, [0.2, 0.4, 1.0]);
1242        let mut t = tab(2);
1243        t.correlated_loss_channel(0, 1, [1.0 / 3.0, 1.0 / 3.0, 0.5]);
1244    }
1245
1246    // === z_expectation ===
1247
1248    #[test]
1249    fn z_expectation_ground_state_is_plus_one() {
1250        let t = tab(1);
1251        assert!((t.z_expectation(0) - 1.0).abs() < 1e-12);
1252    }
1253
1254    #[test]
1255    fn z_expectation_excited_state_is_minus_one() {
1256        let mut t = tab(1);
1257        t.x(0);
1258        assert!((t.z_expectation(0) + 1.0).abs() < 1e-12);
1259    }
1260
1261    #[test]
1262    fn z_expectation_superposition_is_zero() {
1263        let mut t = tab(1);
1264        t.h(0);
1265        assert!(t.z_expectation(0).abs() < 1e-12);
1266    }
1267
1268    // === AsymmetricLossChannel ===
1269
1270    #[test]
1271    fn asymmetric_loss_ground_state_uses_p0() {
1272        // |0⟩: pop0 = 1, so p_tot = p0.
1273        let mut t = tab(1);
1274        t.asymmetric_loss_channel(0, 1.0, 0.0);
1275        assert!(t.is_lost(0));
1276
1277        let mut t = tab(1);
1278        t.asymmetric_loss_channel(0, 0.0, 1.0); // p_tot = 0
1279        assert!(!t.is_lost(0));
1280    }
1281
1282    #[test]
1283    fn asymmetric_loss_excited_state_uses_p1() {
1284        // |1⟩: pop1 = 1, so p_tot = p1.
1285        let mut t = tab(1);
1286        t.x(0);
1287        t.asymmetric_loss_channel(0, 0.0, 1.0);
1288        assert!(t.is_lost(0));
1289
1290        let mut t = tab(1);
1291        t.x(0);
1292        t.asymmetric_loss_channel(0, 1.0, 0.0); // p_tot = 0
1293        assert!(!t.is_lost(0));
1294    }
1295
1296    #[test]
1297    fn asymmetric_loss_zero_prob_not_lost() {
1298        let mut t = tab(1);
1299        t.asymmetric_loss_channel(0, 0.0, 0.0);
1300        assert!(!t.is_lost(0));
1301    }
1302
1303    #[test]
1304    fn asymmetric_loss_already_lost_is_noop() {
1305        let mut t = tab(1);
1306        t.qubit_status[0] = QubitStatus::Lost;
1307        t.asymmetric_loss_channel(0, 1.0, 1.0);
1308        assert!(t.is_lost(0));
1309    }
1310
1311    #[test]
1312    fn asymmetric_loss_resets_lost_qubit_to_zero() {
1313        // |1⟩ lost with p1 = 1; after un-marking it should read |0⟩.
1314        let mut t = tab(1);
1315        t.x(0);
1316        t.asymmetric_loss_channel(0, 0.0, 1.0);
1317        assert!(t.is_lost(0));
1318        t.qubit_status[0] = QubitStatus::Live;
1319        assert!(!t.measure(0).unwrap());
1320    }
1321
1322    #[test]
1323    fn asymmetric_loss_symmetric_matches_loss_channel() {
1324        // p0 == p1 == p reduces to loss_channel(p); on |+⟩, p_tot = p.
1325        let p = 0.3;
1326        let trials = 1000u64;
1327        let mut lost = 0u64;
1328        for seed in 0..trials {
1329            let mut t = tab(1);
1330            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
1331            t.h(0);
1332            t.asymmetric_loss_channel(0, p, p);
1333            if t.is_lost(0) {
1334                lost += 1;
1335            }
1336        }
1337        let frac = lost as f64 / trials as f64;
1338        assert!((frac - p).abs() < 0.07, "expected ~{p}, got {frac:.3}");
1339    }
1340
1341    #[test]
1342    fn asymmetric_loss_superposition_averages_probs() {
1343        // |+⟩: ⟨Z⟩ = 0 so p_tot = (p0 + p1) / 2.
1344        let (p0, p1) = (0.2, 0.6);
1345        let expected = 0.5 * (p0 + p1); // 0.4
1346        let trials = 1000u64;
1347        let mut lost = 0u64;
1348        for seed in 0..trials {
1349            let mut t = tab(1);
1350            t.tableau.rng = rand::SeedableRng::seed_from_u64(seed);
1351            t.h(0);
1352            t.asymmetric_loss_channel(0, p0, p1);
1353            if t.is_lost(0) {
1354                lost += 1;
1355            }
1356        }
1357        let frac = lost as f64 / trials as f64;
1358        assert!(
1359            (frac - expected).abs() < 0.07,
1360            "expected ~{expected}, got {frac:.3}"
1361        );
1362    }
1363
1364    // === LeakageChannel ===
1365
1366    #[test]
1367    fn leakage_p0_p1_zero_no_leak() {
1368        // p0 = p1 = 0 → p_tot = 0, never leaks; the qubit stays live.
1369        let mut t = tab(1);
1370        t.leakage_channel(0, 0.0, 0.0);
1371        assert!(!t.is_leaked(0));
1372        assert!(!t.is_lost(0));
1373        assert!(!t.measure(0).unwrap());
1374    }
1375
1376    #[test]
1377    fn leakage_to_zero_pins_qubit_to_zero() {
1378        // Start in |1⟩; leak-to-|0⟩ (p0 = 1) must pin the qubit to |0⟩.
1379        let mut t = tab(1);
1380        t.x(0);
1381        t.leakage_channel(0, 1.0, 0.0);
1382        assert!(t.is_leaked(0));
1383        assert!(!t.is_lost(0)); // leaked, not lost
1384        assert_eq!(t.measure(0), Some(false));
1385    }
1386
1387    #[test]
1388    fn leakage_to_one_pins_qubit_to_one() {
1389        // Start in |0⟩; leak-to-|1⟩ (p1 = 1) must pin the qubit to |1⟩.
1390        let mut t = tab(1);
1391        t.leakage_channel(0, 0.0, 1.0);
1392        assert!(t.is_leaked(0));
1393        assert!(!t.is_lost(0));
1394        assert_eq!(t.measure(0), Some(true));
1395    }
1396
1397    #[test]
1398    fn leaked_qubit_reports_a_bit_unlike_lost() {
1399        // A leaked qubit measures a definite bit; a lost qubit returns None.
1400        let mut t = tab(1);
1401        t.leakage_channel(0, 0.0, 1.0);
1402        assert!(t.measure(0).is_some());
1403    }
1404
1405    #[test]
1406    fn leakage_does_not_pollute_measurement_record() {
1407        // The internal collapse is a mechanism, not a logical measurement;
1408        // mirrors `loss_channel`.
1409        let mut t = tab(1);
1410        t.leakage_channel(0, 0.0, 1.0);
1411        assert!(t.current_measurement_record().is_empty());
1412    }
1413
1414    #[test]
1415    fn leakage_collapses_superposition_then_pins() {
1416        // Leaking a superposed qubit collapses and pins it, so later
1417        // measurement is deterministic even though |+⟩ alone would be random.
1418        let mut t = tab(1);
1419        t.tableau.rng = rand::SeedableRng::seed_from_u64(7);
1420        t.h(0); // |+⟩
1421        t.leakage_channel(0, 0.0, 1.0);
1422        assert!(t.is_leaked(0));
1423        assert_eq!(t.measure(0), Some(true));
1424        assert_eq!(t.measure(0), Some(true));
1425    }
1426
1427    #[test]
1428    fn single_qubit_gate_skips_leaked_qubit() {
1429        // Pinned to |1⟩; a subsequent x must be a no-op.
1430        let mut t = tab(1);
1431        t.leakage_channel(0, 0.0, 1.0);
1432        t.x(0);
1433        assert_eq!(t.measure(0), Some(true));
1434    }
1435
1436    #[test]
1437    fn two_qubit_gate_skipped_when_control_leaked() {
1438        // Control leaked to |1⟩; cnot must not flip the (live) target.
1439        let mut t = tab(2);
1440        t.leakage_channel(0, 0.0, 1.0);
1441        t.cnot(0, 1);
1442        assert_eq!(t.measure(1), Some(false));
1443        assert_eq!(t.measure(0), Some(true));
1444    }
1445
1446    #[test]
1447    fn leaked_qubit_stays_deterministic_after_other_ops() {
1448        // A leaked qubit is disentangled and pinned: gating/measuring other
1449        // qubits doesn't disturb its outcome.
1450        let mut t = tab(2);
1451        t.leakage_channel(0, 0.0, 1.0);
1452        t.h(1);
1453        let _ = t.measure(1);
1454        assert_eq!(t.measure(0), Some(true));
1455    }
1456
1457    #[test]
1458    fn leakage_channel_skips_already_leaked() {
1459        // A second leakage on a leaked qubit is a no-op (early return), so the
1460        // pinned value is unchanged.
1461        let mut t = tab(1);
1462        t.leakage_channel(0, 0.0, 1.0); // |1⟩, leaked
1463        t.leakage_channel(0, 1.0, 0.0); // would pin |0⟩ if it ran
1464        assert_eq!(t.measure(0), Some(true));
1465    }
1466
1467    #[test]
1468    fn leaked_qubit_can_still_be_lost() {
1469        // Leaked-then-lost is allowed; loss wins and measurement returns None.
1470        let mut t = tab(1);
1471        t.leakage_channel(0, 0.0, 1.0);
1472        t.loss_channel(0, 1.0);
1473        assert!(t.is_lost(0));
1474        assert!(t.measure(0).is_none());
1475    }
1476
1477    #[test]
1478    fn loss_of_leaked_qubit_is_indistinguishable_from_lost() {
1479        // Loss overwrites leakage: the qubit is Lost, not Leaked-and-Lost.
1480        // After reset_loss it must be a live |0⟩, same as a qubit that was
1481        // lost from the computational subspace — the leaked pin must not
1482        // survive in the tableau.
1483        let mut t = tab(1);
1484        t.leakage_channel(0, 0.0, 1.0); // pin |1⟩
1485        t.loss_channel(0, 1.0);
1486        assert!(t.is_lost(0));
1487        assert!(!t.is_leaked(0));
1488        t.reset_loss_channel(0);
1489        assert!(!t.is_lost(0));
1490        assert!(!t.is_leaked(0));
1491        assert_eq!(t.measure(0), Some(false));
1492    }
1493
1494    #[test]
1495    fn reset_skips_leaked_qubit() {
1496        // reset must not re-zero a leaked qubit.
1497        let mut t = tab(1);
1498        t.leakage_channel(0, 0.0, 1.0); // leaked, |1⟩
1499        t.reset(0);
1500        assert!(t.is_leaked(0));
1501        assert_eq!(t.measure(0), Some(true));
1502    }
1503
1504    // === ResetLeakageChannel ===
1505
1506    #[test]
1507    fn reset_leakage_channel_recovers_qubit_to_zero() {
1508        // A qubit leaked to |1⟩ is un-leaked and re-initialized to |0⟩.
1509        let mut t = tab(1);
1510        t.leakage_channel(0, 0.0, 1.0); // leaked, pinned |1⟩
1511        t.reset_leakage_channel(0);
1512        assert!(!t.is_leaked(0));
1513        assert!(!t.is_lost(0));
1514        assert!(t.current_measurement_record().is_empty()); // record-neutral
1515        assert_eq!(t.measure(0), Some(false)); // back in |0⟩
1516    }
1517
1518    #[test]
1519    fn reset_leakage_channel_gates_work_again() {
1520        // After recovery the qubit is live: gates are no longer skipped.
1521        let mut t = tab(1);
1522        t.leakage_channel(0, 0.0, 1.0);
1523        t.reset_leakage_channel(0);
1524        t.x(0);
1525        assert_eq!(t.measure(0), Some(true));
1526    }
1527
1528    #[test]
1529    fn reset_leakage_channel_does_not_recover_lost() {
1530        // A lost qubit cannot be brought back by leakage reduction.
1531        let mut t = tab(1);
1532        t.qubit_status[0] = QubitStatus::Lost;
1533        t.reset_leakage_channel(0);
1534        assert!(t.is_lost(0));
1535        assert!(t.measure(0).is_none());
1536    }
1537
1538    #[test]
1539    fn reset_leakage_channel_noop_on_live_qubit() {
1540        // A live (never-leaked) qubit is left untouched — not re-zeroed.
1541        let mut t = tab(1);
1542        t.x(0); // |1⟩
1543        t.reset_leakage_channel(0);
1544        assert!(!t.is_leaked(0));
1545        assert_eq!(t.measure(0), Some(true)); // unchanged, still |1⟩
1546    }
1547}