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}