Skip to main content

ppvm_tableau/
expectation.rs

1// SPDX-FileCopyrightText: 2026 The PPVM Authors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pauli-string expectation values for `GeneralizedTableau`.
5//!
6//! Two entry points:
7//!
8//! - [`GeneralizedTableau::expectation`] — single-Pauli `⟨ψ|P|ψ⟩` for a
9//!   `PauliWord`. Conjugates `P` through the tableau and overlaps the
10//!   resulting Pauli with the sparse coefficient vector using the same
11//!   formulas as the measurement code.
12//! - [`GeneralizedTableau::trace`] — `Σ_{P matches pattern} ⟨ψ|P|ψ⟩` for a
13//!   `PauliPattern`. Enumerates the matching Paulis and sums their
14//!   expectations.
15//!
16//! Decision 9 in the multi-backend plan calls these out as the natural
17//! primitive for the tableau backend; semantics intentionally diverge from
18//! the PauliSum trace.
19
20use crate::data::GeneralizedTableau;
21use crate::prelude::*;
22use bitvec::view::BitView;
23use num::PrimInt;
24use num::complex::{Complex, Complex64, ComplexFloat};
25use num::traits::{One, ToPrimitive, Zero};
26use ppvm_pauli_word::pattern::PauliPattern;
27use ppvm_traits::FoldHashMap as HashMap;
28use std::fmt::Debug;
29
30impl<T, I, C> GeneralizedTableau<T, I, C>
31where
32    T: Config,
33    <<T as Config>::Storage as BitView>::Store: PrimInt,
34    C: SparseVector<Complex<T::Coeff>, I> + Debug,
35    T::Coeff:
36        One + Zero + Clone + num::Num + ToPrimitive + Debug + std::ops::Mul<f64> + PartialOrd<f64>,
37    Complex<T::Coeff>: std::ops::Mul<Output = Complex<T::Coeff>>
38        + From<Complex64>
39        + std::ops::MulAssign
40        + std::ops::AddAssign
41        + One
42        + ComplexFloat
43        + Copy,
44    I: TableauIndex + Debug,
45{
46    /// `⟨ψ|word|ψ⟩` for the multi-qubit Pauli `word`.
47    ///
48    /// Conjugates `word` through the Clifford tableau (giving a Pauli on the
49    /// canonical basis: an X-mask, Z-mask, and `i^φ` phase), then sums
50    /// `⟨α|P_conj|β⟩ c_α* c_β` over the sparse coefficient vector. Always
51    /// returns a real number (Hermitian operator on a normalized state).
52    pub fn expectation<W: PauliWordTrait>(&self, word: &W) -> f64 {
53        let (phase, stab_anticomm, destab_anticomm) = self.compute_decomposition_word(word);
54        if stab_anticomm == I::zero() {
55            let entries: Vec<(Complex<T::Coeff>, I)> = self.coefficients.iter().copied().collect();
56            Self::compute_overlap_case_b(&entries, phase, destab_anticomm)
57        } else {
58            let coeff_map: HashMap<I, Complex<T::Coeff>> =
59                self.coefficients.iter().map(|&(c, i)| (i, c)).collect();
60            let odd_phase_mask = self.odd_phase_destabilizer_mask();
61            Self::compute_overlap_case_a(
62                &coeff_map,
63                phase,
64                destab_anticomm,
65                stab_anticomm,
66                odd_phase_mask,
67            )
68        }
69    }
70
71    /// `Σ_{P matches pattern} ⟨ψ|P|ψ⟩`.
72    ///
73    /// Enumerates every `PauliWord` accepted by `pattern` via
74    /// [`PauliPattern::enumerate_matches`] and sums their expectations.
75    /// Star quantifiers (`X*`) panic — the pattern must be bounded; use
76    /// counted repetition (`Z?{n}`) or positional anchors instead.
77    pub fn trace(&self, pattern: &PauliPattern) -> f64 {
78        let mut sum = 0.0f64;
79        for word in pattern.enumerate_matches::<T::Storage>(self.n_qubits()) {
80            sum += self.expectation(&word);
81        }
82        sum
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use ppvm_pauli_sum::config::fxhash::ByteF64;
90
91    type TestTableau = GeneralizedTableau<ByteF64<1>>;
92
93    fn word(s: &str) -> PauliWord<u64> {
94        s.into()
95    }
96
97    fn assert_close(actual: f64, expected: f64, tol: f64) {
98        assert!(
99            (actual - expected).abs() < tol,
100            "expected {expected}, got {actual} (|Δ| = {})",
101            (actual - expected).abs()
102        );
103    }
104
105    // ─── Single-qubit expectations ──────────────────────────────────────
106
107    #[test]
108    fn expectation_z_on_zero_state_is_one() {
109        let tab: TestTableau = GeneralizedTableau::new(1, 1e-12);
110        assert_close(tab.expectation(&word("Z")), 1.0, 1e-12);
111    }
112
113    #[test]
114    fn expectation_x_on_zero_state_is_zero() {
115        let tab: TestTableau = GeneralizedTableau::new(1, 1e-12);
116        assert_close(tab.expectation(&word("X")), 0.0, 1e-12);
117    }
118
119    #[test]
120    fn expectation_identity_on_zero_state_is_one() {
121        let tab: TestTableau = GeneralizedTableau::new(1, 1e-12);
122        assert_close(tab.expectation(&word("I")), 1.0, 1e-12);
123    }
124
125    #[test]
126    fn expectation_x_on_plus_state_is_one() {
127        let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12);
128        tab.h(0);
129        assert_close(tab.expectation(&word("X")), 1.0, 1e-12);
130        assert_close(tab.expectation(&word("Z")), 0.0, 1e-12);
131    }
132
133    // ─── Bell state ⟨Φ+|·|Φ+⟩ ────────────────────────────────────────────
134
135    fn bell() -> TestTableau {
136        let mut tab: TestTableau = GeneralizedTableau::new(2, 1e-12);
137        tab.h(0);
138        tab.cnot(0, 1);
139        tab
140    }
141
142    #[test]
143    fn bell_state_pauli_expectations() {
144        let tab = bell();
145        assert_close(tab.expectation(&word("II")), 1.0, 1e-12);
146        assert_close(tab.expectation(&word("ZZ")), 1.0, 1e-12);
147        assert_close(tab.expectation(&word("XX")), 1.0, 1e-12);
148        assert_close(tab.expectation(&word("YY")), -1.0, 1e-12);
149        // Cross terms vanish for the Bell state.
150        assert_close(tab.expectation(&word("IZ")), 0.0, 1e-12);
151        assert_close(tab.expectation(&word("ZI")), 0.0, 1e-12);
152        assert_close(tab.expectation(&word("XZ")), 0.0, 1e-12);
153        assert_close(tab.expectation(&word("YX")), 0.0, 1e-12);
154    }
155
156    // ─── GHZ state ────────────────────────────────────────────────────
157
158    #[test]
159    fn ghz_state_expectations() {
160        let mut tab: TestTableau = GeneralizedTableau::new(3, 1e-12);
161        tab.h(0);
162        tab.cnot(0, 1);
163        tab.cnot(1, 2);
164        // GHZ = (|000⟩ + |111⟩)/√2. For Z^z: eigenvalue is (-1)^{popcount(z)·x}
165        // on |xxx⟩, so on the two basis states it agrees iff popcount(z) is
166        // even; the diagonal expectation is then +1, otherwise 0.
167        assert_close(tab.expectation(&word("III")), 1.0, 1e-12); // popcount 0 → +1
168        assert_close(tab.expectation(&word("ZZZ")), 0.0, 1e-12); // popcount 3 → 0
169        assert_close(tab.expectation(&word("ZIZ")), 1.0, 1e-12); // popcount 2 → +1
170        assert_close(tab.expectation(&word("ZZI")), 1.0, 1e-12); // popcount 2 → +1
171        assert_close(tab.expectation(&word("IZI")), 0.0, 1e-12); // popcount 1 → 0
172        // XXX flips |000⟩ ↔ |111⟩, both in the GHZ superposition → +1.
173        assert_close(tab.expectation(&word("XXX")), 1.0, 1e-12);
174        // Y has off-diagonal action with imaginary phase; YYY contributes 0.
175        assert_close(tab.expectation(&word("YYY")), 0.0, 1e-12);
176    }
177
178    // ─── Single-qubit rotation: |ψ⟩ = RY(θ)|0⟩ ────────────────────────
179
180    #[test]
181    fn ry_rotation_z_expectation_is_cos_theta() {
182        // RY(θ)|0⟩ = cos(θ/2)|0⟩ + sin(θ/2)|1⟩.  ⟨ψ|Z|ψ⟩ = cos(θ).
183        for theta in [0.0, 0.3, 1.0, std::f64::consts::PI / 2.0] {
184            let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12);
185            tab.ry(0, theta);
186            assert_close(tab.expectation(&word("Z")), theta.cos(), 1e-12);
187            assert_close(tab.expectation(&word("X")), theta.sin(), 1e-12);
188        }
189    }
190
191    // ─── X / Y on a non-Clifford superposition ───────────────────────
192    //
193    // After H(0); T(0) the state is |ψ⟩ = (|0⟩ + e^{iπ/4}|1⟩)/√2:
194    //   ⟨ψ|X|ψ⟩ = cos(π/4) = √2/2,
195    //   ⟨ψ|Y|ψ⟩ = sin(π/4) = √2/2.
196    // The T gate populates two branches in the sparse coefficient vector,
197    // so the overlap is a cross-product between them — case_a, not case_b.
198    // Y in particular drives `phase_decomp` to an odd value (Y on a |+⟩-style
199    // frame conjugates to -Y), forcing the `phase == 1 | 3` arms of
200    // `overlap_case_a`'s `match`. A sign bug there would flip ⟨Y⟩.
201
202    #[test]
203    fn t_plus_x_expectation_is_cos_pi_over_4() {
204        let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12);
205        tab.h(0);
206        tab.t(0);
207        assert_close(
208            tab.expectation(&word("X")),
209            std::f64::consts::FRAC_1_SQRT_2,
210            1e-12,
211        );
212    }
213
214    #[test]
215    fn t_plus_y_expectation_is_sin_pi_over_4() {
216        let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12);
217        tab.h(0);
218        tab.t(0);
219        assert_close(
220            tab.expectation(&word("Y")),
221            std::f64::consts::FRAC_1_SQRT_2,
222            1e-12,
223        );
224    }
225
226    // ─── trace(pattern) ───────────────────────────────────────────────
227
228    #[test]
229    fn trace_of_z_or_identity_pattern_on_bell_is_two() {
230        // For |Φ+⟩ = (|00⟩+|11⟩)/√2:
231        //   Σ_{P ∈ {I,Z}^2} ⟨Φ+|P|Φ+⟩ = ⟨II⟩ + ⟨IZ⟩ + ⟨ZI⟩ + ⟨ZZ⟩
232        //                              = 1     + 0     + 0     + 1 = 2.
233        //   Equivalently 2^n |⟨0…0|Φ+⟩|² = 4 · 1/2 = 2.
234        let tab = bell();
235        let pat = PauliPattern::parse("Z?{2}").expect("parse Z?{2}");
236        assert_close(tab.trace(&pat), 2.0, 1e-12);
237    }
238
239    #[test]
240    fn trace_of_y_or_identity_pattern_on_bell_is_zero() {
241        // For |Φ+⟩, Σ_{P ∈ {I,Y}^2} ⟨Φ+|P|Φ+⟩ = ⟨II⟩ + ⟨IY⟩ + ⟨YI⟩ + ⟨YY⟩
242        //                                       = 1     + 0     + 0     + (-1) = 0.
243        // Equivalently 2^n |⟨+i+i|Φ+⟩|² = 4 · 0 = 0 — the projection onto
244        // the all-|+i⟩ state has zero amplitude. `trace` does the
245        // enumeration sum here without ever mutating state or calling
246        // `normalize`, so the zero-probability case doesn't panic.
247        let tab = bell();
248        let pat = PauliPattern::parse("Y?{2}").expect("parse Y?{2}");
249        assert_close(tab.trace(&pat), 0.0, 1e-12);
250    }
251
252    #[test]
253    fn trace_of_positional_pattern_on_bell_matches_single_pauli() {
254        // `Z0Z1` matches exactly the word ZZ; trace should equal ⟨ZZ⟩ = 1.
255        let tab = bell();
256        let pat = PauliPattern::parse("Z0Z1").expect("parse Z0Z1");
257        assert_close(tab.trace(&pat), 1.0, 1e-12);
258    }
259
260    // ─── Cross-backend: forward tableau shots vs backward PauliSum ────────
261    //
262    // Run a noisy Clifford circuit many times on a tableau and average the
263    // per-shot ⟨ψ|ZZ|ψ⟩. Independently, Heisenberg-propagate ZZ backward
264    // through the same circuit via `PauliSum` and read off ⟨0…0|U†ZZ U|0…0⟩
265    // as the sum of coefficients over Z/I-only Paulis. The Monte-Carlo
266    // average and the deterministic value must agree within sampling error.
267    //
268    // `PauliSum::g(i)` performs O → g† O g, so the backward sweep applies
269    // gates in reverse time order. Depolarize is self-dual under Heisenberg.
270    #[test]
271    fn forward_shots_match_backward_pauli_sum_under_depolarizing_noise() {
272        use ppvm_pauli_sum::config::indexmap::ByteFxHashF64;
273
274        let p = 0.05_f64;
275        let n_shots: u64 = 4000;
276        let n_qubits = 2;
277
278        let mut sum = 0.0_f64;
279        for shot in 0..n_shots {
280            let mut tab: TestTableau = GeneralizedTableau::new_with_seed(n_qubits, 1e-12, shot);
281            tab.h(0);
282            tab.depolarize1(0, p);
283            tab.cnot(0, 1);
284            tab.depolarize1(0, p);
285            tab.depolarize1(1, p);
286            sum += tab.expectation(&word("ZZ"));
287        }
288        let avg = sum / (n_shots as f64);
289
290        let mut ps: PauliSum<ByteFxHashF64<1>> = PauliSum::builder().n_qubits(n_qubits).build();
291        ps += ("ZZ", 1.0);
292        ps.depolarize1(1, p);
293        ps.depolarize1(0, p);
294        ps.cnot(0, 1);
295        ps.depolarize1(0, p);
296        ps.h(0);
297        let z_or_i = PauliPattern::parse("Z?{2}").expect("parse Z?{2}");
298        let exact = ps.trace(&z_or_i);
299
300        // Per-shot |⟨ZZ⟩| ≤ 1 ⇒ σ_mean ≤ 1/√N; 5σ keeps this robust to RNG draws.
301        let tol = 5.0 / (n_shots as f64).sqrt();
302        assert!(
303            (avg - exact).abs() < tol,
304            "tableau avg {avg} vs PauliSum exact {exact}, |Δ|={} (tol {tol})",
305            (avg - exact).abs()
306        );
307    }
308}