diff --git a/Cargo.lock b/Cargo.lock index caa60ec3c..6370265da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,6 +1020,7 @@ dependencies = [ "itertools 0.14.0", "num", "ppvm-pauli-sum", + "ppvm-pauli-word", "ppvm-traits", "rand 0.10.1", "rayon", diff --git a/crates/ppvm-tableau/Cargo.toml b/crates/ppvm-tableau/Cargo.toml index 72826d5a9..6b5c4dc07 100644 --- a/crates/ppvm-tableau/Cargo.toml +++ b/crates/ppvm-tableau/Cargo.toml @@ -17,6 +17,7 @@ ppvm-traits = { version = "0.1.0", path = "../ppvm-traits" } ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } rand = "0.10.1" smallvec = "1.15" +ppvm-pauli-word = { version = "0.1.0", path = "../ppvm-pauli-word" } # rayon needs OS threads — native only. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/ppvm-tableau/src/data.rs b/crates/ppvm-tableau/src/data.rs index d35e3e700..a386fc147 100644 --- a/crates/ppvm-tableau/src/data.rs +++ b/crates/ppvm-tableau/src/data.rs @@ -54,8 +54,7 @@ pub struct Tableau { } impl Tableau { - /// Construct a fresh tableau initialised to `|0…0⟩`. - pub fn new(n_qubits: usize) -> Self { + fn new_data(n_qubits: usize) -> Vec> { // Initialize tableau for 0 state let mut data: Vec> = Vec::with_capacity(2 * n_qubits); @@ -72,7 +71,12 @@ impl Tableau { pw.set(i, Pauli::Z); data.push(pw); } + data + } + /// Construct a fresh tableau initialised to `|0…0⟩`. + pub fn new(n_qubits: usize) -> Self { + let data = Tableau::::new_data(n_qubits); Self { n_qubits, data, @@ -87,6 +91,11 @@ impl Tableau { t } + pub fn reset_all(&mut self) { + let data = Tableau::::new_data(self.n_qubits); + self.data = data; + } + /// View of the stabilizer rows (the upper half of the tableau). #[inline] pub fn stabilizers(&self) -> &[PhasedPauliWordNoHash] { @@ -537,7 +546,9 @@ where Complex: std::ops::Mul> + std::ops::AddAssign + From + Copy, { - if items.len() >= RAYON_COEFF_THRESHOLD { + // See `branch_coefficients_parallel`: avoid nesting rayon inside shot-level + // parallelism; the main-thread (single-shot) path is unaffected. + if items.len() >= RAYON_COEFF_THRESHOLD && rayon::current_thread_index().is_none() { use rayon::prelude::*; return items @@ -675,6 +686,22 @@ where s } + pub fn reset_all(&mut self) { + self.tableau.reset_all(); + + let mut coefficients = C::new(); + let complex_one = Complex { + re: T::Coeff::one(), + im: T::Coeff::zero(), + }; + coefficients.unsafe_insert(I::zero(), complex_one); + self.coefficients = coefficients; + for l in self.is_lost.iter_mut() { + *l &= false; + } + self.measurement_record.clear(); + } + /// Clone the quantum state but reinitialize the RNG, producing an independent simulation /// branch. If `seed` is `Some`, the new RNG is seeded deterministically; if `None`, it is /// seeded from OS entropy. @@ -848,6 +875,36 @@ where (p_word.phase, stab_anticomm_bits, destab_anticomm_bits) } + /// Multi-qubit generalization of [`compute_decomposition`]: conjugate an + /// arbitrary `PauliWord` through the tableau and return the same triple + /// `(phase, stab_anticomm_bits, destab_anticomm_bits)`. + /// + /// Algorithm: call [`compute_decomposition`] for each non-identity qubit + /// in the input, then multiply the resulting single-qubit conjugates in + /// canonical-basis form `i^φ X^x Z^z`. Pauli multiplication picks up a + /// `(-1)^{popcount(z_running & x_new)}` cross-phase from + /// `Z^z_a X^x_b = (-1)^{z_a · x_b} X^x_b Z^z_a`. + pub(crate) fn compute_decomposition_word(&self, word: &W) -> (u8, I, I) + where + <::Storage as BitView>::Store: PrimInt, + { + let mut phase = 0u8; + let mut stab_anticomm = I::zero(); + let mut destab_anticomm = I::zero(); + for q in 0..self.n_qubits() { + let p_q = word.get(q); + if p_q == Pauli::I { + continue; + } + let (q_phase, q_stab, q_destab) = self.compute_decomposition(q, p_q); + let cross = 2 * (symplectic_inner(destab_anticomm, q_stab) as u8 % 2); + phase = (phase + q_phase + cross) % 4; + stab_anticomm = stab_anticomm ^ q_stab; + destab_anticomm = destab_anticomm ^ q_destab; + } + (phase, stab_anticomm, destab_anticomm) + } + /// every basis index is a bit string alpha defining the basis state /// the phase when applying a Pauli is the product of all destabilizer phases /// and the phase contributions from the commutation relations @@ -1415,4 +1472,75 @@ mod tests { snapshot_tableau(&tab2.tableau) ); } + + // ─── reset_all ──────────────────────────────────────────────────── + + /// `GeneralizedTableau::reset_all` restores the full state to a fresh + /// `|0…0⟩` tableau: identical stabilizer/destabilizer rows and a single + /// identity coefficient, even after non-Clifford branching. + #[test] + fn reset_all_restores_fresh_state() { + let mut tab: TestTableau = GeneralizedTableau::new(3, 1e-12); + let fresh: TestTableau = GeneralizedTableau::new(3, 1e-12); + + tab.h(0); + tab.cnot(0, 1); + tab.ry(2, 0.7); // non-Clifford: branches the coefficient vector + assert!( + tab.coefficients.iter().count() > 1, + "rotation should branch the coefficient vector" + ); + + tab.reset_all(); + + assert_eq!( + snapshot_tableau(&tab.tableau), + snapshot_tableau(&fresh.tableau) + ); + let coeffs: Vec<_> = tab.coefficients.iter().copied().collect(); + let fresh_coeffs: Vec<_> = fresh.coefficients.iter().copied().collect(); + assert_eq!(coeffs, fresh_coeffs); + } + + /// A full reset clears the measurement record. Regression guard: an earlier + /// version left it intact, so `current_measurement_record` returned stale + /// outcomes after a reset. + #[test] + fn reset_all_clears_measurement_record() { + let mut tab: TestTableau = GeneralizedTableau::new(2, 1e-12); + tab.append_measurement_record(Some(true)); + tab.append_measurement_record(None); + assert_eq!(tab.current_measurement_record().len(), 2); + + tab.reset_all(); + + assert!(tab.current_measurement_record().is_empty()); + } + + /// A full reset clears per-qubit loss flags. + #[test] + fn reset_all_clears_loss_flags() { + let mut tab: TestTableau = GeneralizedTableau::new(3, 1e-12); + tab.is_lost[0] = true; + tab.is_lost[2] = true; + + tab.reset_all(); + + assert!(tab.is_lost.iter().all(|&lost| !lost)); + } + + /// `Tableau::reset_all` restores the fresh identity tableau rows. + #[test] + fn tableau_reset_all_restores_fresh_rows() { + let mut tab: Tableau = Tableau::new(4); + let fresh: Tableau = Tableau::new(4); + + tab.h(0); + tab.s(1); + tab.h(3); + + tab.reset_all(); + + assert_eq!(snapshot_tableau(&tab), snapshot_tableau(&fresh)); + } } diff --git a/crates/ppvm-tableau/src/expectation.rs b/crates/ppvm-tableau/src/expectation.rs new file mode 100644 index 000000000..6ac17e11c --- /dev/null +++ b/crates/ppvm-tableau/src/expectation.rs @@ -0,0 +1,308 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Pauli-string expectation values for `GeneralizedTableau`. +//! +//! Two entry points: +//! +//! - [`GeneralizedTableau::expectation`] — single-Pauli `⟨ψ|P|ψ⟩` for a +//! `PauliWord`. Conjugates `P` through the tableau and overlaps the +//! resulting Pauli with the sparse coefficient vector using the same +//! formulas as the measurement code. +//! - [`GeneralizedTableau::trace`] — `Σ_{P matches pattern} ⟨ψ|P|ψ⟩` for a +//! `PauliPattern`. Enumerates the matching Paulis and sums their +//! expectations. +//! +//! Decision 9 in the multi-backend plan calls these out as the natural +//! primitive for the tableau backend; semantics intentionally diverge from +//! the PauliSum trace. + +use crate::data::GeneralizedTableau; +use crate::prelude::*; +use bitvec::view::BitView; +use fxhash::FxHashMap as HashMap; +use num::PrimInt; +use num::complex::{Complex, Complex64, ComplexFloat}; +use num::traits::{One, ToPrimitive, Zero}; +use ppvm_pauli_word::pattern::PauliPattern; +use std::fmt::Debug; + +impl GeneralizedTableau +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + C: SparseVector, I> + Debug, + T::Coeff: + One + Zero + Clone + num::Num + ToPrimitive + Debug + std::ops::Mul + PartialOrd, + Complex: std::ops::Mul> + + From + + std::ops::MulAssign + + std::ops::AddAssign + + One + + ComplexFloat + + Copy, + I: TableauIndex + Debug, +{ + /// `⟨ψ|word|ψ⟩` for the multi-qubit Pauli `word`. + /// + /// Conjugates `word` through the Clifford tableau (giving a Pauli on the + /// canonical basis: an X-mask, Z-mask, and `i^φ` phase), then sums + /// `⟨α|P_conj|β⟩ c_α* c_β` over the sparse coefficient vector. Always + /// returns a real number (Hermitian operator on a normalized state). + pub fn expectation(&self, word: &W) -> f64 { + let (phase, stab_anticomm, destab_anticomm) = self.compute_decomposition_word(word); + if stab_anticomm == I::zero() { + let entries: Vec<(Complex, I)> = self.coefficients.iter().copied().collect(); + Self::compute_overlap_case_b(&entries, phase, destab_anticomm) + } else { + let coeff_map: HashMap> = + self.coefficients.iter().map(|&(c, i)| (i, c)).collect(); + let odd_phase_mask = self.odd_phase_destabilizer_mask(); + Self::compute_overlap_case_a( + &coeff_map, + phase, + destab_anticomm, + stab_anticomm, + odd_phase_mask, + ) + } + } + + /// `Σ_{P matches pattern} ⟨ψ|P|ψ⟩`. + /// + /// Enumerates every `PauliWord` accepted by `pattern` via + /// [`PauliPattern::enumerate_matches`] and sums their expectations. + /// Star quantifiers (`X*`) panic — the pattern must be bounded; use + /// counted repetition (`Z?{n}`) or positional anchors instead. + pub fn trace(&self, pattern: &PauliPattern) -> f64 { + let mut sum = 0.0f64; + for word in pattern.enumerate_matches::(self.n_qubits()) { + sum += self.expectation(&word); + } + sum + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ppvm_pauli_sum::config::fxhash::ByteF64; + + type TestTableau = GeneralizedTableau>; + + fn word(s: &str) -> PauliWord { + s.into() + } + + fn assert_close(actual: f64, expected: f64, tol: f64) { + assert!( + (actual - expected).abs() < tol, + "expected {expected}, got {actual} (|Δ| = {})", + (actual - expected).abs() + ); + } + + // ─── Single-qubit expectations ────────────────────────────────────── + + #[test] + fn expectation_z_on_zero_state_is_one() { + let tab: TestTableau = GeneralizedTableau::new(1, 1e-12); + assert_close(tab.expectation(&word("Z")), 1.0, 1e-12); + } + + #[test] + fn expectation_x_on_zero_state_is_zero() { + let tab: TestTableau = GeneralizedTableau::new(1, 1e-12); + assert_close(tab.expectation(&word("X")), 0.0, 1e-12); + } + + #[test] + fn expectation_identity_on_zero_state_is_one() { + let tab: TestTableau = GeneralizedTableau::new(1, 1e-12); + assert_close(tab.expectation(&word("I")), 1.0, 1e-12); + } + + #[test] + fn expectation_x_on_plus_state_is_one() { + let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12); + tab.h(0); + assert_close(tab.expectation(&word("X")), 1.0, 1e-12); + assert_close(tab.expectation(&word("Z")), 0.0, 1e-12); + } + + // ─── Bell state ⟨Φ+|·|Φ+⟩ ──────────────────────────────────────────── + + fn bell() -> TestTableau { + let mut tab: TestTableau = GeneralizedTableau::new(2, 1e-12); + tab.h(0); + tab.cnot(0, 1); + tab + } + + #[test] + fn bell_state_pauli_expectations() { + let tab = bell(); + assert_close(tab.expectation(&word("II")), 1.0, 1e-12); + assert_close(tab.expectation(&word("ZZ")), 1.0, 1e-12); + assert_close(tab.expectation(&word("XX")), 1.0, 1e-12); + assert_close(tab.expectation(&word("YY")), -1.0, 1e-12); + // Cross terms vanish for the Bell state. + assert_close(tab.expectation(&word("IZ")), 0.0, 1e-12); + assert_close(tab.expectation(&word("ZI")), 0.0, 1e-12); + assert_close(tab.expectation(&word("XZ")), 0.0, 1e-12); + assert_close(tab.expectation(&word("YX")), 0.0, 1e-12); + } + + // ─── GHZ state ──────────────────────────────────────────────────── + + #[test] + fn ghz_state_expectations() { + let mut tab: TestTableau = GeneralizedTableau::new(3, 1e-12); + tab.h(0); + tab.cnot(0, 1); + tab.cnot(1, 2); + // GHZ = (|000⟩ + |111⟩)/√2. For Z^z: eigenvalue is (-1)^{popcount(z)·x} + // on |xxx⟩, so on the two basis states it agrees iff popcount(z) is + // even; the diagonal expectation is then +1, otherwise 0. + assert_close(tab.expectation(&word("III")), 1.0, 1e-12); // popcount 0 → +1 + assert_close(tab.expectation(&word("ZZZ")), 0.0, 1e-12); // popcount 3 → 0 + assert_close(tab.expectation(&word("ZIZ")), 1.0, 1e-12); // popcount 2 → +1 + assert_close(tab.expectation(&word("ZZI")), 1.0, 1e-12); // popcount 2 → +1 + assert_close(tab.expectation(&word("IZI")), 0.0, 1e-12); // popcount 1 → 0 + // XXX flips |000⟩ ↔ |111⟩, both in the GHZ superposition → +1. + assert_close(tab.expectation(&word("XXX")), 1.0, 1e-12); + // Y has off-diagonal action with imaginary phase; YYY contributes 0. + assert_close(tab.expectation(&word("YYY")), 0.0, 1e-12); + } + + // ─── Single-qubit rotation: |ψ⟩ = RY(θ)|0⟩ ──────────────────────── + + #[test] + fn ry_rotation_z_expectation_is_cos_theta() { + // RY(θ)|0⟩ = cos(θ/2)|0⟩ + sin(θ/2)|1⟩. ⟨ψ|Z|ψ⟩ = cos(θ). + for theta in [0.0, 0.3, 1.0, std::f64::consts::PI / 2.0] { + let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12); + tab.ry(0, theta); + assert_close(tab.expectation(&word("Z")), theta.cos(), 1e-12); + assert_close(tab.expectation(&word("X")), theta.sin(), 1e-12); + } + } + + // ─── X / Y on a non-Clifford superposition ─────────────────────── + // + // After H(0); T(0) the state is |ψ⟩ = (|0⟩ + e^{iπ/4}|1⟩)/√2: + // ⟨ψ|X|ψ⟩ = cos(π/4) = √2/2, + // ⟨ψ|Y|ψ⟩ = sin(π/4) = √2/2. + // The T gate populates two branches in the sparse coefficient vector, + // so the overlap is a cross-product between them — case_a, not case_b. + // Y in particular drives `phase_decomp` to an odd value (Y on a |+⟩-style + // frame conjugates to -Y), forcing the `phase == 1 | 3` arms of + // `overlap_case_a`'s `match`. A sign bug there would flip ⟨Y⟩. + + #[test] + fn t_plus_x_expectation_is_cos_pi_over_4() { + let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12); + tab.h(0); + tab.t(0); + assert_close( + tab.expectation(&word("X")), + std::f64::consts::FRAC_1_SQRT_2, + 1e-12, + ); + } + + #[test] + fn t_plus_y_expectation_is_sin_pi_over_4() { + let mut tab: TestTableau = GeneralizedTableau::new(1, 1e-12); + tab.h(0); + tab.t(0); + assert_close( + tab.expectation(&word("Y")), + std::f64::consts::FRAC_1_SQRT_2, + 1e-12, + ); + } + + // ─── trace(pattern) ─────────────────────────────────────────────── + + #[test] + fn trace_of_z_or_identity_pattern_on_bell_is_two() { + // For |Φ+⟩ = (|00⟩+|11⟩)/√2: + // Σ_{P ∈ {I,Z}^2} ⟨Φ+|P|Φ+⟩ = ⟨II⟩ + ⟨IZ⟩ + ⟨ZI⟩ + ⟨ZZ⟩ + // = 1 + 0 + 0 + 1 = 2. + // Equivalently 2^n |⟨0…0|Φ+⟩|² = 4 · 1/2 = 2. + let tab = bell(); + let pat = PauliPattern::parse("Z?{2}").expect("parse Z?{2}"); + assert_close(tab.trace(&pat), 2.0, 1e-12); + } + + #[test] + fn trace_of_y_or_identity_pattern_on_bell_is_zero() { + // For |Φ+⟩, Σ_{P ∈ {I,Y}^2} ⟨Φ+|P|Φ+⟩ = ⟨II⟩ + ⟨IY⟩ + ⟨YI⟩ + ⟨YY⟩ + // = 1 + 0 + 0 + (-1) = 0. + // Equivalently 2^n |⟨+i+i|Φ+⟩|² = 4 · 0 = 0 — the projection onto + // the all-|+i⟩ state has zero amplitude. `trace` does the + // enumeration sum here without ever mutating state or calling + // `normalize`, so the zero-probability case doesn't panic. + let tab = bell(); + let pat = PauliPattern::parse("Y?{2}").expect("parse Y?{2}"); + assert_close(tab.trace(&pat), 0.0, 1e-12); + } + + #[test] + fn trace_of_positional_pattern_on_bell_matches_single_pauli() { + // `Z0Z1` matches exactly the word ZZ; trace should equal ⟨ZZ⟩ = 1. + let tab = bell(); + let pat = PauliPattern::parse("Z0Z1").expect("parse Z0Z1"); + assert_close(tab.trace(&pat), 1.0, 1e-12); + } + + // ─── Cross-backend: forward tableau shots vs backward PauliSum ──────── + // + // Run a noisy Clifford circuit many times on a tableau and average the + // per-shot ⟨ψ|ZZ|ψ⟩. Independently, Heisenberg-propagate ZZ backward + // through the same circuit via `PauliSum` and read off ⟨0…0|U†ZZ U|0…0⟩ + // as the sum of coefficients over Z/I-only Paulis. The Monte-Carlo + // average and the deterministic value must agree within sampling error. + // + // `PauliSum::g(i)` performs O → g† O g, so the backward sweep applies + // gates in reverse time order. Depolarize is self-dual under Heisenberg. + #[test] + fn forward_shots_match_backward_pauli_sum_under_depolarizing_noise() { + use ppvm_pauli_sum::config::indexmap::ByteFxHashF64; + + let p = 0.05_f64; + let n_shots: u64 = 4000; + let n_qubits = 2; + + let mut sum = 0.0_f64; + for shot in 0..n_shots { + let mut tab: TestTableau = GeneralizedTableau::new_with_seed(n_qubits, 1e-12, shot); + tab.h(0); + tab.depolarize1(0, p); + tab.cnot(0, 1); + tab.depolarize1(0, p); + tab.depolarize1(1, p); + sum += tab.expectation(&word("ZZ")); + } + let avg = sum / (n_shots as f64); + + let mut ps: PauliSum> = PauliSum::builder().n_qubits(n_qubits).build(); + ps += ("ZZ", 1.0); + ps.depolarize1(1, p); + ps.depolarize1(0, p); + ps.cnot(0, 1); + ps.depolarize1(0, p); + ps.h(0); + let z_or_i = PauliPattern::parse("Z?{2}").expect("parse Z?{2}"); + let exact = ps.trace(&z_or_i); + + // Per-shot |⟨ZZ⟩| ≤ 1 ⇒ σ_mean ≤ 1/√N; 5σ keeps this robust to RNG draws. + let tol = 5.0 / (n_shots as f64).sqrt(); + assert!( + (avg - exact).abs() < tol, + "tableau avg {avg} vs PauliSum exact {exact}, |Δ|={} (tol {tol})", + (avg - exact).abs() + ); + } +} diff --git a/crates/ppvm-tableau/src/lib.rs b/crates/ppvm-tableau/src/lib.rs index 1738e7ac2..76f51a881 100644 --- a/crates/ppvm-tableau/src/lib.rs +++ b/crates/ppvm-tableau/src/lib.rs @@ -34,6 +34,8 @@ pub mod data; /// `Display` implementations for tableau types. pub mod display; +/// Pauli-string expectation values and pattern traces. +pub mod expectation; /// Gate implementations (Clifford, T, rotations). pub mod gates; /// Z-basis measurement, including loss-aware variants.