diff --git a/crates/ppvm-python-native/src/interface_tableau.rs b/crates/ppvm-python-native/src/interface_tableau.rs index 9bbcda6b..1528145c 100644 --- a/crates/ppvm-python-native/src/interface_tableau.rs +++ b/crates/ppvm-python-native/src/interface_tableau.rs @@ -54,6 +54,19 @@ macro_rules! create_interface { .collect() } + /// Non-destructive expectation value of a Pauli observable. + /// + /// `observable` is a Stim-style sparse product ("X0*X3*Z5*Y7", + /// optional leading +/-) or a dense "IXYZ…" string of length + /// n_qubits. Returns the expectation in [-1, 1], or `None` if the + /// observable's support touches a lost qubit. Raises `ValueError` + /// for a malformed / out-of-range / repeated-qubit observable. + pub fn peek_observable_expectation(&self, observable: &str) -> PyResult> { + self.inner + .peek_observable_expectation(observable) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + pub fn current_measurement_record(&self) -> Vec { self.inner .current_measurement_record() diff --git a/crates/ppvm-tableau/src/data.rs b/crates/ppvm-tableau/src/data.rs index 7b8d008d..6ff5115b 100644 --- a/crates/ppvm-tableau/src/data.rs +++ b/crates/ppvm-tableau/src/data.rs @@ -20,7 +20,7 @@ use num::{ use rand::SeedableRng; use rand::rngs::SmallRng; -type PhasedPauliWordNoHash = PhasedPauliWord>; +pub(crate) type PhasedPauliWordNoHash = PhasedPauliWord>; /// A `2n`-row stabilizer / destabilizer tableau. /// diff --git a/crates/ppvm-tableau/src/lib.rs b/crates/ppvm-tableau/src/lib.rs index 76f51a88..f7468038 100644 --- a/crates/ppvm-tableau/src/lib.rs +++ b/crates/ppvm-tableau/src/lib.rs @@ -45,6 +45,8 @@ pub mod measure_all; /// Noise channels: depolarizing, Pauli error, loss. pub mod noise; +/// Parsing of Pauli-observable strings for `peek_observable_expectation`. +pub mod observable; /// [`SparseVector`](sparsevec::SparseVector) trait and implementations. pub mod sparsevec; /// [`TableauIndex`](tableau_index::TableauIndex) — abstraction over @@ -57,6 +59,7 @@ pub mod tableau_like; /// Convenience re-exports for downstream code. pub mod prelude { pub use crate::data::{GeneralizedTableau, Tableau}; + pub use crate::observable::ObservableParseError; pub use crate::sparsevec::SparseVector; pub use crate::tableau_index::TableauIndex; pub use crate::tableau_like::TableauLike; diff --git a/crates/ppvm-tableau/src/measure.rs b/crates/ppvm-tableau/src/measure.rs index e386192c..0dd3a14a 100644 --- a/crates/ppvm-tableau/src/measure.rs +++ b/crates/ppvm-tableau/src/measure.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 -use super::data::{compute_phase_with_mask_static, symplectic_inner}; +use super::data::{PhasedPauliWordNoHash, compute_phase_with_mask_static, symplectic_inner}; use crate::prelude::*; use bitvec::view::BitView; use fxhash::FxHashMap as HashMap; @@ -573,7 +573,6 @@ where pub fn z_expectation(&self, addr0: usize) -> f64 { let (phase_decomp, stab_anticomm_bits, destab_anticomm_bits) = self.compute_decomposition(addr0, Pauli::Z); - if stab_anticomm_bits == I::zero() { // Case b: Z is a stabilizer — self-pairing overlap. let entries: Vec<(Complex, I)> = self.coefficients.iter().copied().collect(); @@ -594,6 +593,62 @@ where } } + /// Non-destructive expectation value `⟨O⟩` of a multi-qubit Pauli + /// observable, given as `(qubit, pauli)` factors plus an overall sign. + /// + /// Returns the expectation in `[-1, 1]`, or `None` if any qubit in the + /// observable's support has been lost. For a pure stabilizer state the + /// value is `≈ ±1` or `≈ 0`; with non-Clifford gates or noise trajectories + /// it is generally continuous. + /// + /// Each qubit index must be `< n_qubits` and appear at most once; identity + /// factors are ignored. The quantum state is not disturbed. + pub fn pauli_expectation(&self, factors: &[(usize, Pauli)], negate: bool) -> Option { + for &(qubit, pauli) in factors { + if pauli != Pauli::I && self.is_lost[qubit] { + return None; + } + } + let mut word = PhasedPauliWordNoHash::::new(self.n_qubits()); + for &(qubit, pauli) in factors { + word.set(qubit, pauli); + } + let value = self.expectation(&word.word); + Some(if negate { -value } else { value }) + } + + /// Non-destructive expectation value `⟨O⟩` of a Pauli observable given as a + /// string. + /// + /// `observable` accepts a Stim-style sparse product (`"X0*X3*Z5*Y7"`, with + /// an optional leading `+`/`-`) or a dense `"IXYZ…"` string of length + /// `n_qubits`. See [`crate::observable`] for the full grammar. + /// + /// Returns `Ok(Some(v))` with the expectation in `[-1, 1]`, `Ok(None)` when + /// the observable's support touches a lost qubit, or `Err` for a malformed, + /// out-of-range, or repeated-qubit observable. The state is not disturbed. + /// + /// # Examples + /// + /// ``` + /// use ppvm_tableau::prelude::*; + /// use ppvm_pauli_sum::config::fxhash::ByteF64; + /// + /// let mut tab: GeneralizedTableau> = + /// GeneralizedTableau::new_with_seed(2, 1e-12, 0); + /// tab.h(0); + /// tab.cnot(0, 1); + /// assert!((tab.peek_observable_expectation("Z0*Z1").unwrap().unwrap() - 1.0).abs() < 1e-12); + /// assert!((tab.peek_observable_expectation("-Z0*Z1").unwrap().unwrap() + 1.0).abs() < 1e-12); + /// ``` + pub fn peek_observable_expectation( + &self, + observable: &str, + ) -> Result, crate::observable::ObservableParseError> { + let (negate, factors) = crate::observable::parse_observable(observable, self.n_qubits())?; + Ok(self.pauli_expectation(&factors, negate)) + } + /// Case_b overlap: self-pairing (branch_index = idx), so overlap = ±|c|^2. /// Only even phases contribute to the real part. pub fn compute_overlap_case_b( @@ -677,3 +732,142 @@ where } } } + +#[cfg(test)] +mod expectation_tests { + use crate::observable::ObservableParseError; + use crate::prelude::*; + use ppvm_pauli_sum::config::fxhash::ByteF64; + use ppvm_traits::char::Pauli; + + type TestConfig = ByteF64<1>; + type TestTab = GeneralizedTableau; + + fn tab(n: usize) -> TestTab { + GeneralizedTableau::new_with_seed(n, 1e-12, 0) + } + + /// Mirror of the `stim.TableauSimulator.peek_observable_expectation` + /// docstring example: a Bell pair `H 0; CNOT 0 1` (Schrödinger picture, + /// gates forward). + fn bell() -> TestTab { + let mut t = tab(3); + t.h(0); + t.cnot(0, 1); + t + } + + fn peek(t: &TestTab, obs: &str) -> f64 { + t.peek_observable_expectation(obs) + .expect("valid observable") + .expect("not lost") + } + + #[test] + fn bell_pair_matches_stim_docstring() { + let t = bell(); + assert!((peek(&t, "X0*X1") - 1.0).abs() < 1e-12, "XX"); + assert!((peek(&t, "Y0*Y1") + 1.0).abs() < 1e-12, "YY"); + assert!((peek(&t, "Z0*Z1") - 1.0).abs() < 1e-12, "ZZ"); + assert!((peek(&t, "-Z0*Z1") + 1.0).abs() < 1e-12, "-ZZ"); + assert!(peek(&t, "Z0").abs() < 1e-12, "ZI is random -> 0"); + assert!((peek(&t, "Z2") - 1.0).abs() < 1e-12, "IIZ -> +1"); + } + + #[test] + fn identity_observable_is_plus_one() { + let t = bell(); + // Empty product / all-identity observables. + assert!((peek(&t, "") - 1.0).abs() < 1e-12); + assert!((peek(&t, "III") - 1.0).abs() < 1e-12); + assert!((peek(&t, "-III") + 1.0).abs() < 1e-12); + } + + #[test] + fn single_qubit_z_agrees_with_z_expectation() { + let mut t = tab(2); + t.h(0); + t.t(1); // non-Clifford on qubit 1, leaves ⟨Z1⟩ = 1 + assert!((peek(&t, "Z0") - t.z_expectation(0)).abs() < 1e-12); + assert!((peek(&t, "Z1") - t.z_expectation(1)).abs() < 1e-12); + } + + #[test] + fn dense_and_sparse_forms_agree() { + let t = bell(); + assert!((peek(&t, "ZZI") - peek(&t, "Z0*Z1")).abs() < 1e-12); + assert!((peek(&t, "XXI") - peek(&t, "X0*X1")).abs() < 1e-12); + } + + #[test] + fn stim_underscore_dense_form_matches_i_dense_form() { + // The Bloch probe feeds stim-style dense PauliStrings using `_` for + // identity (e.g. "+ZZ_", "+X__"); they agree with the `I` form. + let t = bell(); + assert!( + (peek(&t, "ZZ_") - peek(&t, "ZZI")).abs() < 1e-12, + "ZZ_ == ZZI" + ); + assert!((peek(&t, "ZZ_") - 1.0).abs() < 1e-12, "Z0*Z1 -> +1"); + assert!(peek(&t, "X__").abs() < 1e-12, "X0 alone is random -> 0"); + } + + #[test] + fn typed_pauli_expectation_matches_string() { + let t = bell(); + let v = t + .pauli_expectation(&[(0, Pauli::Z), (1, Pauli::Z)], false) + .unwrap(); + assert!((v - 1.0).abs() < 1e-12); + } + + #[test] + fn lost_support_qubit_returns_none() { + let mut t = tab(2); + t.h(0); + t.cnot(0, 1); + t.loss_channel(1, 1.0); + assert!(t.is_lost[1]); + // Observable touches the lost qubit -> None. + assert_eq!(t.peek_observable_expectation("Z0*Z1").unwrap(), None); + // Observable avoids the lost qubit -> a value. + assert!(t.peek_observable_expectation("Z0").unwrap().is_some()); + } + + #[test] + fn parser_rejects_malformed_observables() { + let t = tab(3); + assert_eq!( + t.peek_observable_expectation("Z5"), + Err(ObservableParseError::QubitOutOfRange { + qubit: 5, + n_qubits: 3 + }) + ); + assert_eq!( + t.peek_observable_expectation("Z0*Z0"), + Err(ObservableParseError::RepeatedQubit(0)) + ); + assert!(matches!( + t.peek_observable_expectation("Q0"), + Err(ObservableParseError::BadToken(_)) + )); + assert_eq!( + t.peek_observable_expectation("ZZ"), + Err(ObservableParseError::DenseLengthMismatch { + got: 2, + n_qubits: 3 + }) + ); + } + + #[test] + fn non_clifford_state_gives_continuous_value() { + // |+⟩ rotated by Rx(θ) about X stays an eigenstate of X; instead use a + // small Ry to get a continuous ⟨Z⟩. + let mut t = tab(1); + t.ry(0, 0.7); + let expected = (0.7f64).cos(); // ⟨Z⟩ for Ry(θ)|0⟩ + assert!((peek(&t, "Z0") - expected).abs() < 1e-9); + } +} diff --git a/crates/ppvm-tableau/src/observable.rs b/crates/ppvm-tableau/src/observable.rs new file mode 100644 index 00000000..c4dadfa8 --- /dev/null +++ b/crates/ppvm-tableau/src/observable.rs @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Parsing of Pauli-observable strings for +//! [`peek_observable_expectation`](crate::data::GeneralizedTableau::peek_observable_expectation). +//! +//! Two notations are accepted, each with an optional leading `+`/`-` sign: +//! +//! * **Sparse product** (Stim `MPP` syntax): factors joined by `*`, e.g. +//! `"X0*X3*Z5*Y7"`, `"-Z0*Y1"`, `"Z0"`. Each factor is a Pauli letter +//! followed by a 0-based qubit index. +//! * **Dense**: a string of `I`/`X`/`Y`/`Z` of length `n_qubits`, e.g. +//! `"IXIZ"`, where position `i` is the Pauli on qubit `i`. `_` is accepted as +//! an alias for `I` so Stim-style `PauliString` text (e.g. `"+__X_Y_Z"`) +//! parses directly. +//! +//! The empty string (optionally just a sign) is the identity observable. + +use ppvm_traits::char::Pauli; + +/// Error returned when an observable string cannot be parsed against a tableau +/// of a given size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObservableParseError { + /// A sparse factor was empty or malformed (e.g. `"X0**Y1"`, `"Q0"`, `"X"`). + BadToken(String), + /// A qubit index is `>= n_qubits`. + QubitOutOfRange { qubit: usize, n_qubits: usize }, + /// The same qubit appears in more than one factor. + RepeatedQubit(usize), + /// A dense observable's length does not match `n_qubits`. + DenseLengthMismatch { got: usize, n_qubits: usize }, +} + +impl std::fmt::Display for ObservableParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ObservableParseError::BadToken(t) => { + write!(f, "malformed Pauli factor {t:?}; expected e.g. \"X0\"") + } + ObservableParseError::QubitOutOfRange { qubit, n_qubits } => { + write!( + f, + "qubit {qubit} out of range for a {n_qubits}-qubit system" + ) + } + ObservableParseError::RepeatedQubit(q) => { + write!(f, "qubit {q} appears in more than one factor") + } + ObservableParseError::DenseLengthMismatch { got, n_qubits } => write!( + f, + "dense observable has length {got}, expected {n_qubits} (one Pauli per qubit)" + ), + } + } +} + +impl std::error::Error for ObservableParseError {} + +fn pauli_from_char(c: char) -> Option { + match c.to_ascii_uppercase() { + // `_` is Stim's identity glyph in dense `PauliString` text (e.g. + // "+__X_Y_Z"); accept it interchangeably with `I`. + 'I' | '_' => Some(Pauli::I), + 'X' => Some(Pauli::X), + 'Y' => Some(Pauli::Y), + 'Z' => Some(Pauli::Z), + _ => None, + } +} + +/// Parse an observable string into `(negate, factors)`. +/// +/// `negate` reflects a leading `-`. `factors` lists the non-identity Paulis as +/// `(qubit, pauli)` pairs; identity factors are dropped. See the +/// [module docs](self) for the accepted notations. +pub fn parse_observable( + s: &str, + n_qubits: usize, +) -> Result<(bool, Vec<(usize, Pauli)>), ObservableParseError> { + let s = s.trim(); + let (negate, rest) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s.strip_prefix('+').unwrap_or(s)), + }; + let rest = rest.trim(); + + if rest.is_empty() { + // Identity observable (`""`, `"+"`, or `"-"`). + return Ok((negate, Vec::new())); + } + + // Sparse iff it carries explicit indices (digits) or factor separators. + let is_sparse = rest.contains('*') || rest.chars().any(|c| c.is_ascii_digit()); + if is_sparse { + parse_sparse(rest, n_qubits, negate) + } else { + parse_dense(rest, n_qubits, negate) + } +} + +fn parse_sparse( + rest: &str, + n_qubits: usize, + negate: bool, +) -> Result<(bool, Vec<(usize, Pauli)>), ObservableParseError> { + let mut factors: Vec<(usize, Pauli)> = Vec::new(); + let mut seen: Vec = Vec::new(); + + for raw in rest.split('*') { + let token = raw.trim(); + let mut chars = token.chars(); + let pauli = match chars.next().and_then(pauli_from_char) { + Some(p) => p, + None => return Err(ObservableParseError::BadToken(token.to_string())), + }; + let idx_str: String = chars.collect(); + let qubit: usize = idx_str + .parse() + .map_err(|_| ObservableParseError::BadToken(token.to_string()))?; + + if qubit >= n_qubits { + return Err(ObservableParseError::QubitOutOfRange { qubit, n_qubits }); + } + if seen.contains(&qubit) { + return Err(ObservableParseError::RepeatedQubit(qubit)); + } + seen.push(qubit); + if pauli != Pauli::I { + factors.push((qubit, pauli)); + } + } + Ok((negate, factors)) +} + +fn parse_dense( + rest: &str, + n_qubits: usize, + negate: bool, +) -> Result<(bool, Vec<(usize, Pauli)>), ObservableParseError> { + if rest.chars().count() != n_qubits { + return Err(ObservableParseError::DenseLengthMismatch { + got: rest.chars().count(), + n_qubits, + }); + } + let mut factors: Vec<(usize, Pauli)> = Vec::new(); + for (qubit, c) in rest.chars().enumerate() { + let pauli = + pauli_from_char(c).ok_or_else(|| ObservableParseError::BadToken(c.to_string()))?; + if pauli != Pauli::I { + factors.push((qubit, pauli)); + } + } + Ok((negate, factors)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sparse_with_sign_and_indices() { + assert_eq!( + parse_observable("-X0*Z2", 3), + Ok((true, vec![(0, Pauli::X), (2, Pauli::Z)])) + ); + assert_eq!(parse_observable("Y7", 8), Ok((false, vec![(7, Pauli::Y)]))); + assert_eq!( + parse_observable("+Z0*Y1", 2), + Ok((false, vec![(0, Pauli::Z), (1, Pauli::Y)])) + ); + } + + #[test] + fn dense_form() { + assert_eq!( + parse_observable("IXZ", 3), + Ok((false, vec![(1, Pauli::X), (2, Pauli::Z)])) + ); + assert_eq!(parse_observable("-III", 3), Ok((true, vec![]))); + } + + #[test] + fn dense_accepts_stim_underscore_identity() { + // Stim's `PauliString` text writes identity as `_` (e.g. "+__X_Y_Z"), + // so `_` is accepted interchangeably with `I`. + assert_eq!( + parse_observable("+__X", 3), + Ok((false, vec![(2, Pauli::X)])) + ); + assert_eq!(parse_observable("-_Y_", 3), Ok((true, vec![(1, Pauli::Y)]))); + assert_eq!(parse_observable("___", 3), Ok((false, vec![]))); + // Mixed `_` and `I` identities in one dense string. + assert_eq!(parse_observable("I_Z", 3), Ok((false, vec![(2, Pauli::Z)]))); + } + + #[test] + fn identity_variants() { + assert_eq!(parse_observable("", 3), Ok((false, vec![]))); + assert_eq!(parse_observable("+", 3), Ok((false, vec![]))); + assert_eq!(parse_observable("-", 3), Ok((true, vec![]))); + } + + #[test] + fn sparse_drops_identity_factors_but_tracks_qubit() { + assert_eq!( + parse_observable("I0*Z1", 2), + Ok((false, vec![(1, Pauli::Z)])) + ); + // Repeated qubit even when one factor is identity. + assert_eq!( + parse_observable("I0*Z0", 2), + Err(ObservableParseError::RepeatedQubit(0)) + ); + } + + #[test] + fn errors() { + assert_eq!( + parse_observable("Z5", 3), + Err(ObservableParseError::QubitOutOfRange { + qubit: 5, + n_qubits: 3 + }) + ); + assert_eq!( + parse_observable("Z0*Z0", 3), + Err(ObservableParseError::RepeatedQubit(0)) + ); + assert!(matches!( + parse_observable("Q0", 3), + Err(ObservableParseError::BadToken(_)) + )); + // Sparse factor missing its index. + assert!(matches!( + parse_observable("X0*Y", 3), + Err(ObservableParseError::BadToken(_)) + )); + // Dangling separator yields an empty factor. + assert!(matches!( + parse_observable("X0*", 3), + Err(ObservableParseError::BadToken(_)) + )); + // A bare letter run is dense; here it just mismatches the qubit count. + assert_eq!( + parse_observable("ZZ", 3), + Err(ObservableParseError::DenseLengthMismatch { + got: 2, + n_qubits: 3 + }) + ); + // ... and is valid dense when the length matches. + assert_eq!(parse_observable("X", 1), Ok((false, vec![(0, Pauli::X)]))); + } +} diff --git a/ppvm-python/src/ppvm/__init__.py b/ppvm-python/src/ppvm/__init__.py index 8eaff949..1ce4a2f6 100644 --- a/ppvm-python/src/ppvm/__init__.py +++ b/ppvm-python/src/ppvm/__init__.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 from ._core import StimProgram as StimProgram +from .generalized_tableau import ExpectationResult as ExpectationResult from .generalized_tableau import GeneralizedTableau as GeneralizedTableau from .generalized_tableau import MeasurementResult as MeasurementResult from .generalized_tableau import sample_stim as sample_stim diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index bd890adc..5f5f1c2a 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -114,6 +114,7 @@ class _GeneralizedTableauBase: def __deepcopy__(self, memo: object) -> _GeneralizedTableauBase: ... def measure(self, addr0: int) -> int: ... def measure_many(self, targets: Sequence[int]) -> list[int]: ... + def peek_observable_expectation(self, observable: str) -> float | None: ... def current_measurement_record(self) -> list[int]: ... def coefficients(self) -> dict[int, complex]: ... def num_coefficients(self) -> int: ... diff --git a/ppvm-python/src/ppvm/generalized_tableau.py b/ppvm-python/src/ppvm/generalized_tableau.py index 06253710..5a13a07d 100644 --- a/ppvm-python/src/ppvm/generalized_tableau.py +++ b/ppvm-python/src/ppvm/generalized_tableau.py @@ -4,6 +4,7 @@ import enum from collections.abc import Iterable from dataclasses import InitVar, dataclass, field +from typing import ClassVar from . import _core from ._core import StimProgram @@ -52,6 +53,36 @@ class MeasurementResult(enum.IntEnum): _BY_VALUE = (MeasurementResult.ZERO, MeasurementResult.ONE, MeasurementResult.LOST) +@dataclass(frozen=True) +class ExpectationResult: + """The outcome of a non-destructive observable-expectation peek. + + The float analog of `MeasurementResult`: it carries the expectation value + ``⟨O⟩ ∈ [-1, 1]`` of a Pauli observable, or signals that the observable's + support touched a lost qubit. When lost, ``value`` is ``None`` and the + result compares equal to the ``ExpectationResult.LOST`` sentinel. + + Attributes: + value: The expectation value in ``[-1, 1]``, or ``None`` if lost. + """ + + value: float | None + LOST: ClassVar["ExpectationResult"] + + @property + def is_lost(self) -> bool: + """Whether the observable's support touched a lost qubit.""" + return self.value is None + + def __float__(self) -> float: + if self.value is None: + raise ValueError("observable expectation is LOST (a support qubit is lost)") + return self.value + + +ExpectationResult.LOST = ExpectationResult(None) + + @dataclass(frozen=True) class GeneralizedTableau( CliffordMixin, @@ -198,6 +229,48 @@ def measure_many(self, *targets: int | Iterable[int]) -> list[MeasurementResult] """ return [_BY_VALUE[v] for v in self._interface.measure_many(_normalize_targets(targets))] + def peek_observable_expectation(self, observable: str) -> ExpectationResult: + """Expected value ``⟨O⟩`` of a Pauli observable, without disturbing the state. + + This is a non-physical operation: it reports the expectation value of + the observable on the current state without collapsing or otherwise + mutating it. Unlike a pure stabilizer simulator (where the value is + always ``-1``, ``0``, or ``+1``), ``GeneralizedTableau`` represents + arbitrary states, so the result is generally a continuous float in + ``[-1, 1]``. + + Args: + observable: The Pauli observable, as a string. Two notations are + accepted, each with an optional leading ``+``/``-`` sign: + + - **Sparse product** (Stim ``MPP`` syntax): factors joined by + ``*``, e.g. ``"X0*X3*Z5*Y7"``, ``"-Z0*Y1"``, ``"Z0"``. + - **Dense**: a string of ``I``/``X``/``Y``/``Z`` of length + ``n_qubits``, e.g. ``"IXIZ"``. + + The empty string (optionally just a sign) is the identity. + + Returns: + An `ExpectationResult` carrying the float ``⟨O⟩``, or + `ExpectationResult.LOST` if a qubit in the observable's support has + been lost. + + Raises: + ValueError: If the observable is malformed, names an out-of-range + qubit, or repeats a qubit. + + Example: + ```python + tab = GeneralizedTableau(2) + tab.h(0) + tab.cnot(0, 1) # Bell state + float(tab.peek_observable_expectation("Z0*Z1")) # 1.0 + float(tab.peek_observable_expectation("-Z0*Z1")) # -1.0 + ``` + """ + value = self._interface.peek_observable_expectation(observable) + return ExpectationResult.LOST if value is None else ExpectationResult(value) + def current_measurement_record(self) -> list[MeasurementResult]: """Return all measurement outcomes recorded so far. diff --git a/ppvm-python/test/generalized_tableau/test_peek_expectation.py b/ppvm-python/test/generalized_tableau/test_peek_expectation.py new file mode 100644 index 00000000..9daa3f6d --- /dev/null +++ b/ppvm-python/test/generalized_tableau/test_peek_expectation.py @@ -0,0 +1,147 @@ +import math + +import pytest + +from ppvm import ExpectationResult, GeneralizedTableau + + +def bell() -> GeneralizedTableau: + """The Stim docstring's Bell pair: H 0; CNOT 0 1 (forward/Schrödinger).""" + tab = GeneralizedTableau(3, seed=0) + tab.h(0) + tab.cnot(0, 1) + return tab + + +def peek(tab: GeneralizedTableau, obs: str) -> float: + result = tab.peek_observable_expectation(obs) + assert not result.is_lost + return float(result) + + +def test_matches_stim_docstring(): + tab = bell() + assert peek(tab, "X0*X1") == pytest.approx(1.0) + assert peek(tab, "Y0*Y1") == pytest.approx(-1.0) + assert peek(tab, "Z0*Z1") == pytest.approx(1.0) + assert peek(tab, "-Z0*Z1") == pytest.approx(-1.0) + assert peek(tab, "Z0") == pytest.approx(0.0) + assert peek(tab, "Z2") == pytest.approx(1.0) + + +def test_dense_and_sparse_agree(): + tab = bell() + assert peek(tab, "ZZI") == pytest.approx(peek(tab, "Z0*Z1")) + assert peek(tab, "XXI") == pytest.approx(peek(tab, "X0*X1")) + + +def test_identity_is_plus_one(): + tab = bell() + assert peek(tab, "III") == pytest.approx(1.0) + assert peek(tab, "-III") == pytest.approx(-1.0) + + +def test_non_clifford_continuous_value(): + tab = GeneralizedTableau(1, seed=0) + tab.ry(0, 0.7) + assert peek(tab, "Z0") == pytest.approx(math.cos(0.7)) + + +def test_lost_support_qubit_returns_lost(): + tab = GeneralizedTableau(2, seed=0) + tab.h(0) + tab.cnot(0, 1) + tab.loss_channel(1, 1.0) + assert tab.is_lost(1) + + result = tab.peek_observable_expectation("Z0*Z1") + assert result.is_lost + assert result is ExpectationResult.LOST or result.value is None + with pytest.raises(ValueError): + float(result) + + # An observable that avoids the lost qubit still returns a value. + assert not tab.peek_observable_expectation("Z0").is_lost + + +def test_peek_does_not_disturb_state(): + tab = bell() + before = tab.coefficients() + tab.peek_observable_expectation("X0*X1") + tab.peek_observable_expectation("Z0*Z1") + assert tab.coefficients() == before + + +def test_malformed_observable_raises(): + tab = GeneralizedTableau(3, seed=0) + with pytest.raises(ValueError): + tab.peek_observable_expectation("Z5") # out of range + with pytest.raises(ValueError): + tab.peek_observable_expectation("Z0*Z0") # repeated qubit + with pytest.raises(ValueError): + tab.peek_observable_expectation("Q0") # bad token + + +def test_expectation_result_value_object(): + r = ExpectationResult(0.5) + assert r.value == 0.5 + assert not r.is_lost + assert float(r) == 0.5 + assert ExpectationResult.LOST.is_lost + assert ExpectationResult.LOST.value is None + + +def test_large_qubit_count_uses_wide_index(): + # n=70 selects the u128-indexed native interface (GeneralizedTableau2), + # exercising the decomposition's bit-shifts beyond 64 bits. + n = 70 + tab = GeneralizedTableau(n, seed=0) + tab.h(0) + tab.cnot(0, 69) # Bell pair on the extreme qubits + assert peek(tab, "Z0*Z69") == pytest.approx(1.0) + assert peek(tab, "X0*X69") == pytest.approx(1.0) + assert peek(tab, "Z0") == pytest.approx(0.0) + assert peek(tab, f"Z{n - 1}") == pytest.approx(0.0) + + +def test_cross_validate_against_stim(): + """For Clifford circuits, ppvm's peek must agree with Stim's exactly. + + Stim's TableauSimulator is the Schrödinger-picture reference (gates forward, + same as GeneralizedTableau) and returns the exact {-1, 0, +1} eigenvalue. + """ + import random + + stim = pytest.importorskip("stim") + + rng = random.Random(20260626) + n = 5 + single = ["h", "s", "x", "y", "z", "sqrt_x"] + two = ["cnot", "cz"] + + for _ in range(25): + tab = GeneralizedTableau(n, seed=0) + sim = stim.TableauSimulator() + for _ in range(40): + if rng.random() < 0.3: + g = rng.choice(two) + a = rng.randrange(n) + b = rng.randrange(n) + while b == a: + b = rng.randrange(n) + getattr(tab, g)(a, b) + getattr(sim, g)(a, b) + else: + g = rng.choice(single) + q = rng.randrange(n) + getattr(tab, g)(q) + getattr(sim, g)(q) + + for _ in range(12): + dense = "".join(rng.choice("IXYZ") for _ in range(n)) + sign = rng.choice(["+", "-"]) + expected = sim.peek_observable_expectation(stim.PauliString(sign + dense)) + got = float(tab.peek_observable_expectation(sign + dense)) + assert got == pytest.approx(expected, abs=1e-9), ( + f"observable {sign + dense!r}: ppvm {got} vs stim {expected}" + )