Skip to main content

ppvm_tableau/
tableau_like.rs

1// SPDX-FileCopyrightText: 2026 The PPVM Authors
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`TableauLike`](crate::tableau_like::TableauLike) trait: shared interface for stabilizer-tableau backends.
5//!
6//! Any type implementing [`TableauLike`](crate::tableau_like::TableauLike) gets default implementations of
7//! single- and two-qubit Pauli noise channels (Depolarizing, PauliError,
8//! Depolarizing2, TwoQubitPauliError). New tableau backends only need to
9//! provide [`TableauLike::rng_mut`](crate::tableau_like::TableauLike::rng_mut) and (optionally) override
10//! [`TableauLike::is_qubit_lost`](crate::tableau_like::TableauLike::is_qubit_lost)
11//! (which reports lost *or* leaked qubits, both skipped by the noise channels).
12
13use num::Zero;
14use rand::{Rng, RngExt};
15
16use ppvm_traits::traits::{Clifford, Coefficient};
17
18// `Zero` is used for `Self::Coeff::zero()` inside default method bodies; the
19// bound itself is redundant on `Self::Coeff` because `Coefficient: num::Zero`.
20
21/// `true` iff `p` lies in the closed unit interval `[0, 1]`.
22///
23/// Spelled out with two explicit comparisons rather than
24/// `(0.0..=1.0).contains(p)` because the coefficient types here only implement
25/// `PartialOrd<f64>`, not the `PartialOrd<Self>` that `RangeInclusive::contains`
26/// requires. Keeping the two comparisons in separate statements also avoids the
27/// `clippy::manual_range_contains` rewrite, which would not compile here.
28#[inline]
29fn is_probability<C: PartialOrd<f64>>(p: &C) -> bool {
30    if *p < 0.0 {
31        return false;
32    }
33    *p <= 1.0
34}
35
36/// A stabilizer-tableau-like backend that supports Clifford gates and an RNG.
37///
38/// Implementing this trait grants default implementations of the Pauli noise
39/// channels via [`TableauLike::depolarize_impl`] and friends. The associated
40/// `Rng` type lets each backend choose its own RNG; nothing in this trait
41/// depends on `SmallRng`.
42pub trait TableauLike: Clifford {
43    /// Coefficient type used for probabilities.
44    type Coeff: Coefficient + PartialOrd<f64>;
45
46    /// RNG type backing the stochastic channels.
47    type Rng: Rng + RngExt;
48
49    /// Mutable access to the backend's RNG.
50    fn rng_mut(&mut self) -> &mut Self::Rng;
51
52    /// Whether the qubit at `addr` is inactive (lost or leaked), so the
53    /// Pauli noise channels skip it. Default: never inactive.
54    #[inline]
55    fn is_qubit_lost(&self, _addr: usize) -> bool {
56        false
57    }
58
59    /// Single-qubit depolarizing channel.
60    ///
61    /// RNG is consumed unconditionally; the selected Clifford gate is expected
62    /// to no-op on lost qubits. This preserves seeded RNG sequences across
63    /// loss events.
64    #[inline]
65    fn depolarize_impl(&mut self, addr0: usize, p: Self::Coeff) {
66        debug_assert!(is_probability(&p));
67        let r = self.rng_mut().random::<f64>();
68        if p <= r {
69            return;
70        }
71        if p > r * 3.0 {
72            // p / 3 > r >= 0
73            self.x(addr0);
74        } else if p > r * 1.5 {
75            // 2p/3 > r >= p / 3
76            self.y(addr0);
77        } else {
78            // p > r >= 2p/3
79            self.z(addr0);
80        }
81    }
82
83    /// Single-qubit Pauli-error channel (X, Y, Z with given probabilities).
84    ///
85    /// RNG is consumed unconditionally; the selected Clifford gate is expected
86    /// to no-op on lost qubits. This preserves seeded RNG sequences across
87    /// loss events.
88    #[inline]
89    fn pauli_error_impl(&mut self, addr0: usize, p: [Self::Coeff; 3]) {
90        debug_assert!(p.iter().all(is_probability));
91        let r = self.rng_mut().random::<f64>();
92        let mut cumulative = Self::Coeff::zero();
93        for (i, p_) in p.iter().enumerate() {
94            cumulative += p_.clone();
95            if cumulative > r {
96                match i {
97                    0 => self.x(addr0),
98                    1 => self.y(addr0),
99                    _ => self.z(addr0),
100                }
101                return;
102            }
103        }
104    }
105
106    /// Two-qubit Pauli-error channel (15 non-identity Pauli combinations).
107    #[inline]
108    fn two_qubit_pauli_error_impl(&mut self, addr0: usize, addr1: usize, p: [Self::Coeff; 15]) {
109        if self.is_qubit_lost(addr0) || self.is_qubit_lost(addr1) {
110            return;
111        }
112        debug_assert!(p.iter().all(is_probability));
113        let r = self.rng_mut().random::<f64>();
114        let sum = Self::Coeff::zero();
115        let idx = p
116            .iter()
117            .scan(sum, |acc, p_| {
118                *acc += p_.clone();
119                Some(acc.clone())
120            })
121            .position(|cum_prob| cum_prob > r);
122
123        if let Some(i) = idx {
124            // Two-qubit Pauli pairs indexed by `i + 1` (so II at index 0 is
125            // skipped). Encoding: 0 = I, 1 = X, 2 = Y, 3 = Z; the first entry
126            // acts on addr0, the second on addr1.
127            //
128            // `rustfmt::skip` keeps the rows grouped by the first Pauli (a
129            // readable 4-wide grid); without it rustfmt repacks the tuples to
130            // fill the line width and the grouping is lost.
131            #[rustfmt::skip]
132            const PAULI_PAIRS: [(u8, u8); 16] = [
133                (0,0),(0,1),(0,2),(0,3),
134                (1,0),(1,1),(1,2),(1,3),
135                (2,0),(2,1),(2,2),(2,3),
136                (3,0),(3,1),(3,2),(3,3),
137            ];
138            let cartesian_index = PAULI_PAIRS[i + 1]; // skip II entry
139
140            match cartesian_index.0 {
141                0 => {}
142                1 => self.x(addr0),
143                2 => self.y(addr0),
144                _ => self.z(addr0),
145            }
146
147            match cartesian_index.1 {
148                0 => {}
149                1 => self.x(addr1),
150                2 => self.y(addr1),
151                _ => self.z(addr1),
152            }
153        }
154    }
155
156    /// Two-qubit depolarizing channel: spreads `p` over the 15 non-identity
157    /// two-qubit Pauli errors.
158    #[inline]
159    fn depolarize2_impl(&mut self, addr0: usize, addr1: usize, p: Self::Coeff) {
160        if self.is_qubit_lost(addr0) || self.is_qubit_lost(addr1) {
161            return;
162        }
163        debug_assert!(is_probability(&p));
164        let p_arr: [Self::Coeff; 15] = core::array::from_fn(|_| p.clone() * (1.0 / 15.0));
165        self.two_qubit_pauli_error_impl(addr0, addr1, p_arr);
166    }
167}