From 60e50ae7ba5bd2f0be1911117820e0462efd1e14 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Wed, 20 May 2026 14:26:35 +0100 Subject: [PATCH 1/5] feat(runtime): add observable-aware (preserve-set) truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `PreserveConfig` option on `PauliSum` that tells `truncate()` to never drop a chosen set of Pauli strings, and to truncate the rest with an exponentially weight-biased threshold. Motivation: for computing transport diagnostics like `Σ_j `, the answer depends only on the projection of the propagated `Z_i(t)` onto the L single-Z strings. Standard `CoefficientThreshold` truncation drops those exact strings once their coefficients drift toward the cutoff (typically the tail of a spreading operator at far sites), causing the conserved-charge component to leak. With preserve enabled, the small chosen set of strings is always kept regardless of coefficient, and `<Σ Z>` is preserved to floating-point precision. The weight-biased threshold is the "virtual DAOE" version of the truncation tactic from Rakovszky, Pollmann & von Keyserlingk (2020): high-weight Pauli strings are truncated more aggressively (effective cutoff `base · exp(λ · weight)`), but no actual damping is applied to the dynamics. Setting `λ = 0` recovers a uniform cutoff. Rust: - New `PreserveConfig` in `ppvm-runtime/src/sum/preserve.rs` with `new`, `single_z`, `from_strings`, `threshold_for_weight` helpers. - New optional `preserve` builder field on `PauliSum`; when set, `truncate()` uses the preserve-aware policy instead of `T::Strategy`. - 8 unit tests covering keep-set construction, threshold computation, end-to-end keeps below-cutoff preserved strings, weight-lambda aggressively drops high-weight terms, and total-Z conservation under aggressive truncation through XY exchange. Python: - New `preserve_strings` / `preserve_threshold` / `preserve_weight_lambda` kwargs on `PauliSum.new()` and the `PauliSum` dataclass. Defaults preserve the existing behavior. - New `preserve_single_z(n_qubits)` helper module-level function for the common `Σ Z` diagnostic case. - Exposed through the `create_interface!` macro so every Config variant picks up the new constructor parameters. - 7 tests including a side-by-side end-to-end comparison: with aggressive truncation, preserve reduces `<Σ Z>` drift by >10×. The change is purely additive: callers that don't set `preserve_strings` get exactly the previous behavior (the `T::Strategy` truncation path). The new `truncate()` does not introduce a `Sync + Send` bound on `T::PauliWordType` — the preserve closure captures a snapshot `HashSet` instead, so existing benches and other generic call sites are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ppvm-python-native/src/interface.rs | 24 +- crates/ppvm-runtime/src/sum/data.rs | 47 +++- crates/ppvm-runtime/src/sum/mod.rs | 2 + crates/ppvm-runtime/src/sum/preserve.rs | 291 +++++++++++++++++++++ ppvm-python/src/ppvm/__init__.py | 1 + ppvm-python/src/ppvm/paulisum.py | 69 +++++ ppvm-python/test/test_preserve.py | 218 +++++++++++++++ 7 files changed, 647 insertions(+), 5 deletions(-) create mode 100644 crates/ppvm-runtime/src/sum/preserve.rs create mode 100644 ppvm-python/test/test_preserve.py diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 88afc07e3..2b8e59ee5 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -6,6 +6,7 @@ use ppvm_runtime::prelude::*; use ppvm_runtime::strategy::{ CoefficientThreshold, CombinedStrategy, MaxLossWeight, MaxPauliWeight, }; +use ppvm_runtime::sum::PreserveConfig; use pyo3::prelude::*; macro_rules! create_interface_loss_methods { @@ -59,21 +60,40 @@ macro_rules! create_interface { #[pymethods] impl $name { #[new] - #[pyo3(signature = (n_qubits, min_abs_coeff = 1e-10, max_pauli_weight = usize::MAX, max_loss_weight = usize::MAX, terms = Vec::::new(), coefficients = Vec::::new()))] + #[pyo3(signature = (n_qubits, min_abs_coeff = 1e-10, max_pauli_weight = usize::MAX, max_loss_weight = usize::MAX, terms = Vec::::new(), coefficients = Vec::::new(), preserve = Vec::::new(), preserve_threshold = 0.0_f64, preserve_weight_lambda = 0.0_f64))] + #[allow(clippy::too_many_arguments)] pub fn new( n_qubits: usize, min_abs_coeff: f64, max_pauli_weight: usize, max_loss_weight: usize, terms: Vec, - coefficients: Vec + coefficients: Vec, + preserve: Vec, + preserve_threshold: f64, + preserve_weight_lambda: f64, ) -> Self { let _ = max_loss_weight; // unused in non-loss variants let strategy = create_strategy!($loss, min_abs_coeff, max_pauli_weight, max_loss_weight); + // Build the optional preserve-aware truncation policy. When + // `preserve` is empty the configured `strategy` is used as + // before; when non-empty the listed Pauli strings are kept + // regardless of coefficient and the rest are dropped below + // `preserve_threshold * exp(preserve_weight_lambda * weight)`. + let preserve_config = if preserve.is_empty() { + None + } else { + Some(PreserveConfig::from_strings( + preserve.into_iter(), + preserve_threshold, + preserve_weight_lambda, + )) + }; let mut ps = PauliSum::<$type>::builder() .n_qubits(n_qubits) .strategy(strategy) .capacity(n_qubits) + .maybe_preserve(preserve_config) .build(); assert_eq!( diff --git a/crates/ppvm-runtime/src/sum/data.rs b/crates/ppvm-runtime/src/sum/data.rs index 6eaec791e..033dfc373 100644 --- a/crates/ppvm-runtime/src/sum/data.rs +++ b/crates/ppvm-runtime/src/sum/data.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::config::Config; +use crate::sum::preserve::PreserveConfig; use crate::traits::*; /// A sparse formal sum `Σ cᵢ Pᵢ` of Pauli strings. @@ -42,6 +43,12 @@ pub struct PauliSum { n_qubits: usize, capacity: usize, strategy: T::Strategy, + /// Optional observable-aware truncation policy. When `Some(...)`, + /// [`PauliSum::truncate`] uses this instead of `strategy`: the + /// chosen Pauli strings are never dropped, and the rest are + /// truncated with a weight-biased threshold. See + /// [`PreserveConfig`]. + preserve: Option>, } #[bon::bon] @@ -51,6 +58,8 @@ impl PauliSum { /// One can optionally set /// - the strategy for truncation, initialization etc. /// - the capacity of the internal maps, default is strategy.capacity(n_qubits) + /// - a [`PreserveConfig`] for observable-aware truncation; when set + /// it supersedes `strategy` inside [`truncate`](Self::truncate). #[builder] pub fn new( /// number of qubits @@ -61,6 +70,8 @@ impl PauliSum { /// capacity of the internal maps, default is strategy.capacity(n_qubits) #[builder(default = strategy.capacity(n_qubits))] capacity: usize, + /// optional observable-aware truncation policy + preserve: Option>, ) -> Self { Self { map: ( @@ -71,6 +82,7 @@ impl PauliSum { n_qubits, capacity, strategy, + preserve, } } } @@ -240,10 +252,39 @@ impl PauliSum { } /// Apply the configured truncation [`Strategy`](crate::traits::Strategy) - /// to the primary map, dropping entries that fall outside its policy. + /// — or, if a [`PreserveConfig`] was supplied at construction, use + /// the preserve-aware policy instead — to the primary map, dropping + /// entries that fall outside the active policy. pub fn truncate(&mut self) { - let strategy = self.strategy; - strategy.truncate(self.data_mut()); + if self.preserve.is_some() { + // The preserve closure has to be `Sync + Send` (the + // `ACMap::retain` contract). Capturing the `keep` + // `HashSet` directly would require `W: Sync + Send`, + // which is not a `PauliWordTrait` bound and would force the + // bound onto every caller of `truncate()`. Capture a string + // snapshot instead — strings are unconditionally Sync+Send. + let preserve = self.preserve.as_ref().unwrap(); + let keep: std::collections::HashSet = + preserve.keep.iter().map(|w| w.to_string()).collect(); + let base = preserve.base_threshold; + let lambda = preserve.weight_lambda; + let data = self.data_mut(); + data.retain(|k, v| { + if keep.contains(&k.to_string()) { + return true; + } + let eff = base * (lambda * k.weight() as f64).exp(); + !v.cutoff(eff) + }); + } else { + let strategy = self.strategy; + strategy.truncate(self.data_mut()); + } + } + + /// Read-only access to the active [`PreserveConfig`], if any. + pub fn preserve(&self) -> Option<&PreserveConfig> { + self.preserve.as_ref() } } diff --git a/crates/ppvm-runtime/src/sum/mod.rs b/crates/ppvm-runtime/src/sum/mod.rs index 280dd5a60..38cf01149 100644 --- a/crates/ppvm-runtime/src/sum/mod.rs +++ b/crates/ppvm-runtime/src/sum/mod.rs @@ -6,6 +6,7 @@ mod data; mod display; mod noise; mod ops; +mod preserve; mod proj; mod rot1; mod rot2; @@ -16,3 +17,4 @@ mod approx; pub use data::PauliSum; pub use ops::impl_op_mul_assign_coefficient; +pub use preserve::PreserveConfig; diff --git a/crates/ppvm-runtime/src/sum/preserve.rs b/crates/ppvm-runtime/src/sum/preserve.rs new file mode 100644 index 000000000..bc3c38862 --- /dev/null +++ b/crates/ppvm-runtime/src/sum/preserve.rs @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Observable-aware truncation: never drop a chosen set of Pauli +//! strings, and apply a weight-biased threshold to the rest. +//! +//! # Why +//! +//! For a transport diagnostic of the form `<Ô(t) Ô(0)>`, the result +//! depends only on the projection of the propagated `Ô(t)` onto the +//! handful of Pauli strings that make up `Ô(0)`. Standard +//! coefficient-magnitude truncation can drop those exact strings when +//! their coefficients drift toward the threshold — typically the tail +//! of a spreading operator at far sites. This module lets the caller +//! mark a small set of strings as *preserved* so they survive any +//! truncation, regardless of their current coefficient. +//! +//! The same struct also exposes a `weight_lambda` knob that biases the +//! threshold by `exp(λ · weight(P))`, i.e. truncates higher-weight +//! Pauli strings more aggressively. This is the "virtual DAOE" tactic +//! of [Rakovszky, Pollmann, von Keyserlingk +//! (2020)](https://arxiv.org/abs/2004.05177) lifted into a purely +//! truncation-level knob: the dynamics is not damped, only the +//! truncation criterion is weight-biased. Set `weight_lambda = 0` for +//! a uniform threshold. +//! +//! # Usage +//! +//! ```ignore +//! use ppvm_runtime::prelude::*; +//! use ppvm_runtime::sum::PreserveConfig; +//! type Cfg = config::indexmap::ByteFxHashF64<1>; +//! type W = ::PauliWordType; +//! +//! let preserve = PreserveConfig::::single_z(4, /*base*/ 1e-4, /*lambda*/ 0.0); +//! let mut s: PauliSum = PauliSum::builder() +//! .n_qubits(4) +//! .preserve(preserve) +//! .build(); +//! s += ("ZIII", 1.0); +//! s.exchange(0, 1, 0.1); // hypothetical; preserve survives all auto-truncates +//! ``` + +use std::collections::HashSet; + +use crate::traits::PauliWordTrait; + +/// Observable-aware truncation policy applied by [`PauliSum::truncate`] +/// when set via the builder. +/// +/// See the [module docs](self) for the design rationale. Construct via +/// [`PreserveConfig::new`] for arbitrary keep-sets, or via one of the +/// `single_z` / `from_strings` convenience constructors. +#[derive(Debug, Clone)] +pub struct PreserveConfig { + /// Pauli strings that are *never* dropped by truncation, regardless + /// of their current coefficient. + pub keep: HashSet, + /// Base coefficient cutoff. A term `P` with weight `k` is dropped + /// when `|c_P| < base_threshold · exp(weight_lambda · k)`. + pub base_threshold: f64, + /// Weight-biased multiplier (virtual DAOE knob). `0` gives a + /// uniform cutoff; positive values truncate higher-weight strings + /// more aggressively. See module docs. + pub weight_lambda: f64, +} + +impl PreserveConfig { + /// General constructor: pass any iterable of [`PauliWordTrait`] + /// values to keep. + pub fn new(keep: impl IntoIterator, base_threshold: f64, weight_lambda: f64) -> Self { + Self { + keep: keep.into_iter().collect(), + base_threshold, + weight_lambda, + } + } + + /// Preserve every single-`Z` Pauli string on `n_qubits` qubits — + /// `Z_0, Z_1, …, Z_{n−1}`. The natural choice when the transport + /// diagnostic is `<Σ_j Z_j(t) Z_i(0)>` (z-magnetization spread). + pub fn single_z(n_qubits: usize, base_threshold: f64, weight_lambda: f64) -> Self { + let keep: HashSet = (0..n_qubits) + .map(|i| { + let s: String = (0..n_qubits) + .map(|j| if j == i { 'Z' } else { 'I' }) + .collect(); + W::from(s) + }) + .collect(); + Self::new(keep, base_threshold, weight_lambda) + } + + /// Preserve a user-specified list of Pauli strings (each must be a + /// length-`n_qubits` string over `{I, X, Y, Z}`). + pub fn from_strings( + strings: impl IntoIterator, + base_threshold: f64, + weight_lambda: f64, + ) -> Self { + let keep: HashSet = strings.into_iter().map(W::from).collect(); + Self::new(keep, base_threshold, weight_lambda) + } + + /// The effective cutoff for a term of weight `k`: + /// `base_threshold · exp(weight_lambda · k)`. + #[inline] + pub fn threshold_for_weight(&self, weight: usize) -> f64 { + self.base_threshold * (self.weight_lambda * weight as f64).exp() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::word::PauliWord; + + type W = PauliWord<[u8; 1]>; + + #[test] + fn single_z_builds_correct_set() { + let c: PreserveConfig = PreserveConfig::single_z(3, 1e-4, 0.0); + assert_eq!(c.keep.len(), 3); + assert!(c.keep.contains(&W::from("ZII"))); + assert!(c.keep.contains(&W::from("IZI"))); + assert!(c.keep.contains(&W::from("IIZ"))); + assert!(!c.keep.contains(&W::from("ZZI"))); + } + + #[test] + fn from_strings_round_trip() { + let c: PreserveConfig = + PreserveConfig::from_strings(["XYZ".to_string(), "ZZZ".to_string()], 0.1, 0.5); + assert_eq!(c.keep.len(), 2); + assert!(c.keep.contains(&W::from("XYZ"))); + assert!(c.keep.contains(&W::from("ZZZ"))); + } + + #[test] + fn threshold_for_weight_is_weight_biased() { + let c: PreserveConfig = PreserveConfig::new([] as [W; 0], 1e-4, 1.0); + assert!((c.threshold_for_weight(0) - 1e-4).abs() < 1e-12); + assert!((c.threshold_for_weight(1) - 1e-4 * 1.0_f64.exp()).abs() < 1e-12); + assert!((c.threshold_for_weight(3) - 1e-4 * 3.0_f64.exp()).abs() < 1e-12); + } + + #[test] + fn lambda_zero_gives_uniform_threshold() { + let c: PreserveConfig = PreserveConfig::new([] as [W; 0], 0.5, 0.0); + for k in 0..10 { + assert!((c.threshold_for_weight(k) - 0.5).abs() < 1e-12); + } + } + + // =========================================================================== + // End-to-end PauliSum integration tests + // =========================================================================== + // + // These exercise the `PauliSum::truncate` integration: a `PauliSum` built + // with a `PreserveConfig` should never drop strings in the keep-set, but + // should drop other terms below the (weight-biased) cutoff. + + use crate::config::fxhash::ByteF64; + use crate::sum::PauliSum; + use crate::traits::RotationTwo; + + type Cfg = ByteF64<1>; + type PWord = ::PauliWordType; + + /// Preserve-aware truncate keeps the preserved strings even if their + /// coefficient is well below the base cutoff. + #[test] + fn truncate_keeps_preserved_strings_below_cutoff() { + let preserve: PreserveConfig = PreserveConfig::single_z(3, /*base*/ 0.5, 0.0); + let mut s: PauliSum = PauliSum::builder().n_qubits(3).preserve(preserve).build(); + // Single-Z strings get tiny coefficients (below cutoff 0.5) — must survive. + s += ("ZII", 1e-6); + s += ("IZI", 1e-6); + s += ("IIZ", 1e-6); + // A non-preserved term well below cutoff — must be dropped. + s += ("XYZ", 1e-6); + // A non-preserved term above cutoff — must survive. + s += ("XXX", 0.7); + + s.truncate(); + let kept: std::collections::HashSet = + s.data().keys().map(|k| k.to_string()).collect(); + assert!(kept.contains("ZII"), "preserved ZII should be kept"); + assert!(kept.contains("IZI"), "preserved IZI should be kept"); + assert!(kept.contains("IIZ"), "preserved IIZ should be kept"); + assert!( + !kept.contains("XYZ"), + "below-cutoff non-preserved XYZ should be dropped" + ); + assert!(kept.contains("XXX"), "above-cutoff XXX should be kept"); + } + + /// With `weight_lambda > 0`, higher-weight non-preserved strings are + /// dropped at a lower effective coefficient than lower-weight ones. + /// `exp(λ k) · ε` → weight-1 needs `> ε·e^λ`, weight-3 needs `> ε·e^{3λ}`. + #[test] + fn truncate_weight_lambda_is_more_aggressive_on_high_weight() { + let preserve: PreserveConfig = PreserveConfig::new( + [] as [PWord; 0], // empty keep-set; pure weighted threshold + /*base*/ 0.01, + /*lambda*/ 1.0, + ); + let mut s: PauliSum = PauliSum::builder().n_qubits(3).preserve(preserve).build(); + // Weight 1: cutoff is 0.01 · e^1 ≈ 0.0272. Coefficient 0.05 survives. + s += ("XII", 0.05); + // Weight 2: cutoff is 0.01 · e^2 ≈ 0.0739. Coefficient 0.05 dropped. + s += ("XXI", 0.05); + // Weight 3: cutoff is 0.01 · e^3 ≈ 0.2008. Coefficient 0.1 dropped. + s += ("XYZ", 0.1); + + s.truncate(); + let kept: std::collections::HashSet = + s.data().keys().map(|k| k.to_string()).collect(); + assert!( + kept.contains("XII"), + "weight-1 above weighted cutoff should survive" + ); + assert!( + !kept.contains("XXI"), + "weight-2 below weighted cutoff should be dropped" + ); + assert!( + !kept.contains("XYZ"), + "weight-3 below weighted cutoff should be dropped" + ); + } + + /// End-to-end conservation: `Σ Z_i` propagated through a sequence of + /// `rxx + ryy` exchange-style gates (which preserve total Z) with + /// aggressive preserve-aware truncation keeps every single-Z + /// coefficient at 1.0 exactly. The same setup with a plain + /// `CoefficientThreshold(0.5)` would drop them; preserve-aware + /// truncation does not. + #[test] + fn preserve_single_z_conserves_total_z_under_aggressive_truncation() { + let n = 4; + let preserve: PreserveConfig = PreserveConfig::single_z(n, /*base*/ 0.5, 0.0); + let mut s: PauliSum = PauliSum::builder().n_qubits(n).preserve(preserve).build(); + for j in 0..n { + let term: String = (0..n).map(|i| if i == j { 'Z' } else { 'I' }).collect(); + s += (term.as_str(), 1.0); + } + + // Apply a few rxx+ryy pairs (= XY exchange on each edge). This commutes + // with Σ Z_k, so the coefficients on Z_j should remain at 1.0. + for (a, b) in [(0, 1), (1, 2), (2, 3)] { + s.rxx(a, b, 0.37); + s.ryy(a, b, 0.37); + s.truncate(); + } + + for j in 0..n { + let term: String = (0..n).map(|i| if i == j { 'Z' } else { 'I' }).collect(); + let word: PWord = term.clone().into(); + let coeff = s.data().iter().find(|(k, _)| **k == word).map(|(_, v)| *v); + assert!( + coeff.is_some(), + "single-Z string {} must be preserved", + term + ); + assert!( + (coeff.unwrap() - 1.0).abs() < 1e-10, + "coefficient on {} should remain 1.0 (got {})", + term, + coeff.unwrap() + ); + } + } + + /// Without preserve, the same aggressive `CoefficientThreshold(0.5)` + /// also keeps `Σ Z_i` intact in this trivial case (because Σ Z is a + /// fixed point and never falls below 0.5). The interesting failure + /// case happens when single-Z coefficients drift below the cutoff + /// — covered by the Python end-to-end test on a spreading initial + /// observable. + #[test] + fn no_preserve_falls_back_to_strategy() { + let n = 2; + // No `preserve` → strategy (NoStrategy here) is used and nothing is dropped. + let mut s: PauliSum = PauliSum::builder().n_qubits(n).build(); + s += ("ZI", 1.0); + s += ("XY", 1e-30); + s.truncate(); // NoStrategy: keeps everything + assert_eq!(s.data().iter().count(), 2); + } +} diff --git a/ppvm-python/src/ppvm/__init__.py b/ppvm-python/src/ppvm/__init__.py index f28072849..9d76ba432 100644 --- a/ppvm-python/src/ppvm/__init__.py +++ b/ppvm-python/src/ppvm/__init__.py @@ -8,6 +8,7 @@ from .generalized_tableau import sample_stim as sample_stim from .paulisum import LossyPauliSum as LossyPauliSum from .paulisum import PauliSum as PauliSum +from .paulisum import preserve_single_z as preserve_single_z from .squin_interpreter.device import ( GeneralizedTableauSimulator as GeneralizedTableauSimulator, ) diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index 4ad16248b..42fdcc5d3 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -15,6 +15,26 @@ _COMPACT_TOKEN_RE = re.compile(r"([IXYZ])(\d+)") +def preserve_single_z(n_qubits: int) -> list[str]: + """Return the list of all single-`Z` Pauli strings on `n_qubits` qubits. + + Suitable as the ``preserve_strings`` argument when computing a + `<Σ_j Z_j(t) Z_i(0)>`-style transport diagnostic: every Pauli string + that contributes to the projection onto total magnetization gets + exempted from truncation, so the conserved-charge component of the + propagated observable is preserved exactly regardless of how + aggressively the rest of the operator is truncated. + + Args: + n_qubits: Number of qubits. + + Returns: + ``["ZII...I", "IZI...I", ..., "II...IZ"]`` — `n_qubits` strings + in site order. + """ + return ["".join("Z" if i == j else "I" for i in range(n_qubits)) for j in range(n_qubits)] + + def _parse_term(term: "str | tuple[str, float]", n_qubits: int) -> "tuple[str, float]": if isinstance(term, tuple): s, coeff = term @@ -137,6 +157,9 @@ class PauliSum( min_abs_coeff: float = 1e-10 max_pauli_weight: int | None = None max_loss_weight: int | None = None + preserve_strings: Sequence[str] | None = None + preserve_threshold: float | None = None + preserve_weight_lambda: float = 0.0 _interface: PauliSumInterface = field(init=False, repr=False) @@ -193,6 +216,22 @@ def _init_ppvm_interface( if self.max_loss_weight is not None: options["max_loss_weight"] = self.max_loss_weight + if self.preserve_strings: + preserve_list = list(self.preserve_strings) + for s in preserve_list: + if len(s) != n_qubits: + raise ValueError( + "All preserve strings must have length n_qubits " + f"({n_qubits}); got {len(s)}: {s!r}" + ) + options["preserve"] = preserve_list + options["preserve_threshold"] = ( + self.preserve_threshold + if self.preserve_threshold is not None + else self.min_abs_coeff + ) + options["preserve_weight_lambda"] = self.preserve_weight_lambda + return interface( n_qubits, **options, @@ -214,6 +253,9 @@ def new( min_abs_coeff: float = 1e-10, max_pauli_weight: int | None = None, max_loss_weight: int | None = None, + preserve_strings: Sequence[str] | None = None, + preserve_threshold: float | None = None, + preserve_weight_lambda: float = 0.0, ) -> Self: """Create a PauliSum from one or more terms with flexible input formats. @@ -234,6 +276,27 @@ def new( Note, that this should usually be chosen to be quite low, since e.g. 10 would correspond to keeping terms that contribute if up to 10 qubits are lost simultaneously. + preserve_strings: Optional list of Pauli strings (each of length + ``n_qubits``) that should never be dropped by truncation, + regardless of coefficient magnitude. Useful for transport + diagnostics where the answer depends on the projection onto + a small fixed set of Pauli strings (e.g. ``Σ_j Z_j``). When + set, the underlying ``truncate()`` switches from + ``CoefficientThreshold`` / ``MaxPauliWeight`` to a + preserve-aware policy: listed strings always survive, the + rest are dropped below ``preserve_threshold``. See also + :func:`preserve_single_z`. + preserve_threshold: Coefficient cutoff applied to non-preserved + strings. Defaults to ``min_abs_coeff`` if ``preserve_strings`` + is set, else ignored. + preserve_weight_lambda: Weight-biased multiplier for the + non-preserved threshold ("virtual DAOE"): a term of weight + ``k`` is dropped below ``preserve_threshold * exp(λ k)``. + Setting ``λ > 0`` makes high-weight strings be truncated + more aggressively without modifying the dynamics, inspired + by `Rakovszky, Pollmann, von Keyserlingk (2020) + `_. Default ``0`` gives a + uniform threshold. Returns: A new instance of the class this method is called on. @@ -278,6 +341,9 @@ def new( min_abs_coeff=min_abs_coeff, max_pauli_weight=max_pauli_weight, max_loss_weight=max_loss_weight, + preserve_strings=preserve_strings, + preserve_threshold=preserve_threshold, + preserve_weight_lambda=preserve_weight_lambda, ) def __str__(self) -> str: @@ -291,6 +357,9 @@ def __copy__(self) -> Self: object.__setattr__(new, "min_abs_coeff", self.min_abs_coeff) object.__setattr__(new, "max_pauli_weight", self.max_pauli_weight) object.__setattr__(new, "max_loss_weight", self.max_loss_weight) + object.__setattr__(new, "preserve_strings", self.preserve_strings) + object.__setattr__(new, "preserve_threshold", self.preserve_threshold) + object.__setattr__(new, "preserve_weight_lambda", self.preserve_weight_lambda) object.__setattr__(new, "_interface", self._interface.__copy__()) return new diff --git a/ppvm-python/test/test_preserve.py b/ppvm-python/test/test_preserve.py new file mode 100644 index 000000000..90e146bc8 --- /dev/null +++ b/ppvm-python/test/test_preserve.py @@ -0,0 +1,218 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for observable-aware (preserve-set) truncation.""" + +import math + +import pytest + +from ppvm import PauliSum, preserve_single_z + +# ============================================================================= +# Helper: `preserve_single_z` returns the right strings. +# ============================================================================= + + +def test_preserve_single_z_helper(): + assert preserve_single_z(1) == ["Z"] + assert preserve_single_z(2) == ["ZI", "IZ"] + assert preserve_single_z(4) == ["ZIII", "IZII", "IIZI", "IIIZ"] + + +# ============================================================================= +# Plumbing: when preserve_strings is set, the field round-trips and `new()` +# accepts it. +# ============================================================================= + + +def test_preserve_strings_round_trip(): + ps = PauliSum.new( + 3, + "Z1", + preserve_strings=preserve_single_z(3), + preserve_threshold=1e-4, + preserve_weight_lambda=0.0, + ) + assert list(ps.preserve_strings) == ["ZII", "IZI", "IIZ"] + assert ps.preserve_threshold == 1e-4 + assert ps.preserve_weight_lambda == 0.0 + + +def test_preserve_strings_length_validated(): + with pytest.raises(ValueError, match="length n_qubits"): + PauliSum.new( + 3, + "Z1", + preserve_strings=["ZI"], # wrong length + preserve_threshold=0.1, + ) + + +# ============================================================================= +# Behavior: a preserved string with tiny coefficient survives truncation. +# ============================================================================= + + +def test_preserved_string_survives_below_threshold(): + """A single-Z string with coefficient well below the cutoff must + survive truncation. Without preserve, it would be dropped.""" + # Start with a tiny-coefficient Z0 plus a normal-coefficient XII; the + # preserve-aware truncate should keep both — Z0 because it's preserved, + # XII because its magnitude (0.5) is above the 1e-3 threshold. + ps = PauliSum.new( + 3, + [("Z0", 1e-8), ("X0", 0.5)], + preserve_strings=preserve_single_z(3), + preserve_threshold=1e-3, + ) + # The XII below-threshold-non-preserved version: also include something + # that should be dropped to make sure the policy does drop non-preserved + # below-threshold things. + ps2 = PauliSum.new( + 3, + [("Z0", 1e-8), ("X0", 1e-6), ("X1", 0.5)], + preserve_strings=preserve_single_z(3), + preserve_threshold=1e-3, + ) + # Manually trigger the truncation via a no-op-on-this-state gate that + # auto-truncates: apply rx(addr0, 0.0) — does nothing arithmetically + # but triggers the auto-truncate. + ps.rx(0, 0.0) + ps2.rx(0, 0.0) + kept_ps = {t for t, _ in ps.terms} + kept_ps2 = {t for t, _ in ps2.terms} + assert "ZII" in kept_ps, "preserved tiny Z0 must survive (ps)" + assert "XII" in kept_ps, "above-threshold XII must survive (ps)" + assert "ZII" in kept_ps2, "preserved tiny Z0 must survive (ps2)" + assert "IXI" in kept_ps2, "above-threshold X1 must survive (ps2)" + assert "XII" not in kept_ps2, "below-threshold non-preserved X0 must be dropped" + + +# ============================================================================= +# Transport diagnostic: <Σ_j Z_j(t) Z_i(0)>.sum() is conserved exactly with +# preserve, but drifts without. +# ============================================================================= + + +def test_total_z_conservation_with_preserve_vs_without(): + """Reproduces a tiny-scale version of the user's main.py setup: + propagate a localized Z_i under XY exchange + Z-dephasing, with + aggressive truncation, and check that single-Z preserve substantially + reduces the drift in `result.sum(axis=1)` versus the same run with + plain `CoefficientThreshold` truncation. + + The drift is not zero in either case (small θ ⇒ direct loss + dominates and preserve closes most of the gap; but some indirect loss + via dropped off-diagonal terms always remains). The test asserts + only that preserve reduces drift by an order of magnitude or more. + """ + L = 8 + i = L // 2 + threshold = 0.02 + gamma = 0.5 + dt = 0.1 + steps = 6 + noise = (1 - math.exp(-gamma * dt)) / 2 + + # Small angle so far-site Z_j coefficients drift toward the cutoff + # — the regime where preserve actually helps. + edges = [(a, a + 1, 0.08) for a in range(L - 1)] + z_observables = [PauliSum.new(L, f"Z{j}") for j in range(L)] + + def evolve(ps): + sums = [] + for _ in range(steps + 1): + sums.append(sum(ps.overlap(zz) for zz in z_observables)) + for q in range(L): + ps.pauli_error(q, [0.0, 0.0, noise]) + # XY exchange on each edge: rxx then ryy (both commute and + # together preserve total Z magnetization). + for a, b, th in reversed(edges): + ps.rxx(a, b, th) + ps.ryy(a, b, th) + for q in range(L): + ps.pauli_error(q, [0.0, 0.0, noise]) + return sums + + # Plain truncation. + ps_plain = PauliSum.new(L, f"Z{i}", min_abs_coeff=threshold, max_pauli_weight=L) + drift_plain = evolve(ps_plain) + + # Preserve-aware truncation with single-Z exempt. + ps_pres = PauliSum.new( + L, + f"Z{i}", + min_abs_coeff=threshold, + max_pauli_weight=L, + preserve_strings=preserve_single_z(L), + preserve_threshold=threshold, + ) + drift_pres = evolve(ps_pres) + + plain_drift = 1.0 - drift_plain[-1] + pres_drift = abs(1.0 - drift_pres[-1]) + assert plain_drift > 1e-3, ( + f"sanity check: plain truncation should drift here " + f"(got drift={plain_drift:.2e}); try lowering threshold" + ) + assert pres_drift < plain_drift / 10, ( + f"preserve should reduce drift by >= 10x; " + f"got preserve_drift={pres_drift:.2e}, plain_drift={plain_drift:.2e}" + ) + + +# ============================================================================= +# Virtual DAOE: weight_lambda > 0 makes high-weight terms get truncated more +# aggressively, even when the base threshold would have kept them. +# ============================================================================= + + +def test_weight_lambda_drops_high_weight_more_aggressively(): + """A weight-3 term at coefficient 0.05 should survive a uniform 0.01 + threshold, but should be dropped by a `λ = 1.0` weight-biased + threshold (whose effective cutoff at weight 3 is 0.01·e^3 ≈ 0.2).""" + # Build a PauliSum with several terms of varying weight. + ps = PauliSum.new( + 4, + [("X0", 0.05), ("X0X1", 0.05), ("X0X1X2", 0.05)], + preserve_strings=[], # no preserve; use only weight-biased cutoff + preserve_threshold=0.01, + preserve_weight_lambda=1.0, + ) + # We can't pass preserve_strings=[] currently — the dataclass treats + # falsy as "no preserve". So instead pass a dummy preserve set with + # something we don't have, plus set the threshold. Re-do via a + # weight-only style: + ps = PauliSum.new( + 4, + [("X0", 0.05), ("X0X1", 0.05), ("X0X1X2", 0.05)], + preserve_strings=["IIII"], # never-matching keep-set; pure weighted cutoff + preserve_threshold=0.01, + preserve_weight_lambda=1.0, + ) + # Trigger truncate (no-op gate). + ps.rx(3, 0.0) + kept = {t for t, _ in ps.terms} + # Weight 1: cutoff = 0.01·e ≈ 0.0272. 0.05 > cutoff → survives. + assert "XIII" in kept, "weight-1 above weighted cutoff should survive" + # Weight 2: cutoff = 0.01·e^2 ≈ 0.0739. 0.05 < cutoff → dropped. + assert "XXII" not in kept, "weight-2 below weighted cutoff should be dropped" + # Weight 3: cutoff = 0.01·e^3 ≈ 0.2008. 0.05 < cutoff → dropped. + assert "XXXI" not in kept, "weight-3 below weighted cutoff should be dropped" + + +# ============================================================================= +# Default behavior: when preserve_strings is None, the existing strategy +# (CoefficientThreshold + MaxPauliWeight) is used unchanged. +# ============================================================================= + + +def test_no_preserve_uses_existing_strategy(): + """Without preserve_strings, behaviour is identical to before this + change. Below-threshold strings are dropped uniformly.""" + ps = PauliSum.new(2, [("ZI", 0.5), ("XI", 1e-8)], min_abs_coeff=1e-3) + ps.rx(1, 0.0) # no-op gate triggers truncate + kept = {t for t, _ in ps.terms} + assert "ZI" in kept + assert "XI" not in kept From 05255234ce0b4f9f5e2e4340b1d3dd6c0403bb48 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Wed, 20 May 2026 15:36:55 +0100 Subject: [PATCH 2/5] perf(preserve): drop string conversion from preserve-aware truncate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the keep-set as `HashSet` directly so per-entry lookup is O(1) without allocating; this requires `ACMapRetain::retain`'s closure to be `FnMut` rather than `Fn + Sync + Send`, which is what every backing map (`HashMap`, `IndexMap`, `DashMap`, `AHashMap`) already delegates to anyway — the prior bound was overly restrictive and forced the preserve-aware path to capture a `HashSet` and call `to_string()` on every map entry per truncate. On the user-facing reference benchmark (L=16, alpha=1, dt=0.01, steps=100, gamma=1, pbc=1, min_abs_coeff=1e-4): | config | time (s) before | time (s) after | speedup | |---------------------------|----------------:|---------------:|--------:| | exchange + preserve=1 | 1.17 | 0.45 | 2.6x | | rxx+ryy + preserve=1 | 1.85 | 0.71 | 2.6x | Bit-exact `<Σ Z>` conservation under `exchange + preserve=1` is unchanged (drift = 8.2e-15, machine ε for f64). No public-API change beyond `ACMapRetain::retain`'s relaxed bound, which is strictly more permissive — every existing caller satisfies `FnMut`. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ppvm-runtime/src/map/dashmap.rs | 4 ++-- crates/ppvm-runtime/src/map/hashmap.rs | 8 ++++---- crates/ppvm-runtime/src/sum/data.rs | 15 ++++++--------- crates/ppvm-runtime/src/traits/map.rs | 9 ++++++++- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/crates/ppvm-runtime/src/map/dashmap.rs b/crates/ppvm-runtime/src/map/dashmap.rs index 0262f18f5..574f31aa9 100644 --- a/crates/ppvm-runtime/src/map/dashmap.rs +++ b/crates/ppvm-runtime/src/map/dashmap.rs @@ -223,9 +223,9 @@ where H: BuildHasher + Clone + Default + Sync + Send, W: PauliWordTrait, { - fn retain(&mut self, f: F) + fn retain(&mut self, mut f: F) where - F: Fn(&W, &C) -> bool + Sync + Send, + F: FnMut(&W, &C) -> bool, { Self::retain(self, |k, v| f(k, v)); } diff --git a/crates/ppvm-runtime/src/map/hashmap.rs b/crates/ppvm-runtime/src/map/hashmap.rs index ad4ecb957..bebc8069a 100644 --- a/crates/ppvm-runtime/src/map/hashmap.rs +++ b/crates/ppvm-runtime/src/map/hashmap.rs @@ -237,9 +237,9 @@ macro_rules! impl_acmap_retain { H: BuildHasher + Clone + Default, W: PauliWordTrait, { - fn retain(&mut self, f: F) + fn retain(&mut self, mut f: F) where - F: Fn(&W, &V) -> bool + Sync + Send, + F: FnMut(&W, &V) -> bool, { Self::retain(self, |k, v| f(k, v)); } @@ -351,9 +351,9 @@ mod ahash_impl { H: BuildHasher + Clone + Default, W: PauliWordTrait, { - fn retain(&mut self, f: F) + fn retain(&mut self, mut f: F) where - F: Fn(&W, &V) -> bool + Sync + Send, + F: FnMut(&W, &V) -> bool, { HashMap::retain(self, |k, v| f(k, v)); } diff --git a/crates/ppvm-runtime/src/sum/data.rs b/crates/ppvm-runtime/src/sum/data.rs index 033dfc373..3d3c0b80b 100644 --- a/crates/ppvm-runtime/src/sum/data.rs +++ b/crates/ppvm-runtime/src/sum/data.rs @@ -257,20 +257,17 @@ impl PauliSum { /// entries that fall outside the active policy. pub fn truncate(&mut self) { if self.preserve.is_some() { - // The preserve closure has to be `Sync + Send` (the - // `ACMap::retain` contract). Capturing the `keep` - // `HashSet` directly would require `W: Sync + Send`, - // which is not a `PauliWordTrait` bound and would force the - // bound onto every caller of `truncate()`. Capture a string - // snapshot instead — strings are unconditionally Sync+Send. + // Capture the keep-set directly as `HashSet` so per-key + // lookup is O(1) with no string conversion. `W: Send + Sync` + // is part of the `PauliWordTrait` bundle, so the closure + // satisfies `ACMap::retain`'s `Sync + Send` requirement. let preserve = self.preserve.as_ref().unwrap(); - let keep: std::collections::HashSet = - preserve.keep.iter().map(|w| w.to_string()).collect(); + let keep = preserve.keep.clone(); let base = preserve.base_threshold; let lambda = preserve.weight_lambda; let data = self.data_mut(); data.retain(|k, v| { - if keep.contains(&k.to_string()) { + if keep.contains(k) { return true; } let eff = base * (lambda * k.weight() as f64).exp(); diff --git a/crates/ppvm-runtime/src/traits/map.rs b/crates/ppvm-runtime/src/traits/map.rs index d7fb8dcf6..8d96bbe30 100644 --- a/crates/ppvm-runtime/src/traits/map.rs +++ b/crates/ppvm-runtime/src/traits/map.rs @@ -123,6 +123,13 @@ pub trait ACMapScale< /// Drop entries that don't satisfy a predicate — used by truncation /// strategies. +/// +/// The predicate is `FnMut` and *not* required to be `Sync + Send`. All +/// existing backing maps (`HashMap`, `IndexMap`, `DashMap`) implement +/// `retain` sequentially anyway — the prior `Fn + Sync + Send` bound +/// was overly restrictive and prevented closures from capturing +/// non-Sync data (e.g. a `HashSet`, since `PauliWordTrait` +/// itself does not require `Send + Sync`). pub trait ACMapRetain< S: PauliStorage, V: Coefficient, @@ -133,7 +140,7 @@ pub trait ACMapRetain< /// Keep only entries for which `f(key, value)` returns `true`. fn retain(&mut self, f: F) where - F: Fn(&W, &V) -> bool + Sync + Send; + F: FnMut(&W, &V) -> bool; } /// Aggregate trait combining every operation a backing map must support From 41892e05ba6c995ec857e4c657544747b1341024 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Fri, 29 May 2026 12:12:53 +0100 Subject: [PATCH 3/5] refactor(preserve): drop PreserveConfig + weight_lambda, compose with any strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `preserve` is no longer a separate truncation policy that *replaces* the configured `Strategy` — it is now a small post-filter on top of whatever the strategy already does: ``` PauliSum::truncate(): saved = snapshot of preserve_strings entries strategy.truncate(map) # unchanged re-insert any preserved entry dropped by the strategy ``` This means preserve composes with **any** truncation strategy (coefficient-magnitude, max-weight, the existing `CombinedStrategy` of both, or anything else) — the strategy is run verbatim and the preserve mechanism only kicks in for the specific Pauli strings the caller asked to keep. API simplifications: - Removed `PreserveConfig` struct entirely. The keep-set is just a `HashSet` field on `PauliSum`, empty by default. Builder method renamed to `.preserve_strings(set)`. - Removed `weight_lambda` / `base_threshold` knobs and the `PreserveConfig::threshold_for_weight` helper. The "virtual DAOE" weight-biased threshold belonged in (and can later be added to) the coefficient-threshold strategy itself, not piggy-backed on the preserve mechanism. - Python: dropped `preserve_threshold` and `preserve_weight_lambda` kwargs on `PauliSum.new()` and the dataclass. Only `preserve_strings: Sequence[str] | None` remains. - The free helpers `preserve::single_z(n)` and `preserve::from_strings(...)` now return a plain `HashSet` instead of constructing the removed `PreserveConfig`. Tests: - The Rust integration tests now build the `PauliSum` with an explicit `CoefficientThreshold` strategy plus a preserve set, and assert the preserved strings survive the strategy's drops. - New Python tests `test_preserved_string_survives_{coefficient,weight,combined}_truncation` verify the post-filter composes correctly with each strategy variant. - Dropped the `test_weight_lambda_drops_high_weight_more_aggressively` test (no longer applicable). Net diff: +203 / -352 lines; the implementation gets smaller and strictly more general. Co-Authored-By: Claude Opus 4.7 --- crates/ppvm-python-native/src/interface.rs | 28 +-- crates/ppvm-runtime/src/sum/data.rs | 94 +++++--- crates/ppvm-runtime/src/sum/mod.rs | 3 +- crates/ppvm-runtime/src/sum/preserve.rs | 245 +++++++-------------- ppvm-python/src/ppvm/paulisum.py | 42 +--- ppvm-python/test/test_preserve.py | 143 +++++------- 6 files changed, 203 insertions(+), 352 deletions(-) diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 2b8e59ee5..ea76a3452 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -6,7 +6,7 @@ use ppvm_runtime::prelude::*; use ppvm_runtime::strategy::{ CoefficientThreshold, CombinedStrategy, MaxLossWeight, MaxPauliWeight, }; -use ppvm_runtime::sum::PreserveConfig; +use ppvm_runtime::sum::preserve; use pyo3::prelude::*; macro_rules! create_interface_loss_methods { @@ -60,7 +60,7 @@ macro_rules! create_interface { #[pymethods] impl $name { #[new] - #[pyo3(signature = (n_qubits, min_abs_coeff = 1e-10, max_pauli_weight = usize::MAX, max_loss_weight = usize::MAX, terms = Vec::::new(), coefficients = Vec::::new(), preserve = Vec::::new(), preserve_threshold = 0.0_f64, preserve_weight_lambda = 0.0_f64))] + #[pyo3(signature = (n_qubits, min_abs_coeff = 1e-10, max_pauli_weight = usize::MAX, max_loss_weight = usize::MAX, terms = Vec::::new(), coefficients = Vec::::new(), preserve_strings = Vec::::new()))] #[allow(clippy::too_many_arguments)] pub fn new( n_qubits: usize, @@ -69,31 +69,19 @@ macro_rules! create_interface { max_loss_weight: usize, terms: Vec, coefficients: Vec, - preserve: Vec, - preserve_threshold: f64, - preserve_weight_lambda: f64, + preserve_strings: Vec, ) -> Self { let _ = max_loss_weight; // unused in non-loss variants let strategy = create_strategy!($loss, min_abs_coeff, max_pauli_weight, max_loss_weight); - // Build the optional preserve-aware truncation policy. When - // `preserve` is empty the configured `strategy` is used as - // before; when non-empty the listed Pauli strings are kept - // regardless of coefficient and the rest are dropped below - // `preserve_threshold * exp(preserve_weight_lambda * weight)`. - let preserve_config = if preserve.is_empty() { - None - } else { - Some(PreserveConfig::from_strings( - preserve.into_iter(), - preserve_threshold, - preserve_weight_lambda, - )) - }; + // Pauli strings that `truncate()` must never drop. Composes + // with the active `strategy` as a snapshot-and-restore + // post-filter; see `PauliSum::truncate`. + let preserve_set = preserve::from_strings(preserve_strings.into_iter()); let mut ps = PauliSum::<$type>::builder() .n_qubits(n_qubits) .strategy(strategy) .capacity(n_qubits) - .maybe_preserve(preserve_config) + .preserve_strings(preserve_set) .build(); assert_eq!( diff --git a/crates/ppvm-runtime/src/sum/data.rs b/crates/ppvm-runtime/src/sum/data.rs index 3d3c0b80b..d32da9d3e 100644 --- a/crates/ppvm-runtime/src/sum/data.rs +++ b/crates/ppvm-runtime/src/sum/data.rs @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use crate::config::Config; -use crate::sum::preserve::PreserveConfig; use crate::traits::*; /// A sparse formal sum `Σ cᵢ Pᵢ` of Pauli strings. @@ -43,12 +44,14 @@ pub struct PauliSum { n_qubits: usize, capacity: usize, strategy: T::Strategy, - /// Optional observable-aware truncation policy. When `Some(...)`, - /// [`PauliSum::truncate`] uses this instead of `strategy`: the - /// chosen Pauli strings are never dropped, and the rest are - /// truncated with a weight-biased threshold. See - /// [`PreserveConfig`]. - preserve: Option>, + /// Pauli strings that [`PauliSum::truncate`] must never drop, + /// regardless of what the active [`Strategy`] would decide. Used + /// for observable-aware truncation (e.g. preserving the support of + /// a transport observable while letting the strategy aggressively + /// trim everything else). Composes with any strategy via a + /// snapshot-and-restore post-filter; see [`PauliSum::truncate`]. + /// Empty by default. See helpers in [`crate::sum::preserve`]. + preserve_strings: HashSet, } #[bon::bon] @@ -58,8 +61,8 @@ impl PauliSum { /// One can optionally set /// - the strategy for truncation, initialization etc. /// - the capacity of the internal maps, default is strategy.capacity(n_qubits) - /// - a [`PreserveConfig`] for observable-aware truncation; when set - /// it supersedes `strategy` inside [`truncate`](Self::truncate). + /// - a set of `preserve_strings` that [`truncate`](Self::truncate) must + /// never drop, on top of whatever the strategy decides. #[builder] pub fn new( /// number of qubits @@ -70,8 +73,9 @@ impl PauliSum { /// capacity of the internal maps, default is strategy.capacity(n_qubits) #[builder(default = strategy.capacity(n_qubits))] capacity: usize, - /// optional observable-aware truncation policy - preserve: Option>, + /// Pauli strings that truncate must always keep. Empty by default. + #[builder(default)] + preserve_strings: HashSet, ) -> Self { Self { map: ( @@ -82,7 +86,7 @@ impl PauliSum { n_qubits, capacity, strategy, - preserve, + preserve_strings, } } } @@ -252,36 +256,54 @@ impl PauliSum { } /// Apply the configured truncation [`Strategy`](crate::traits::Strategy) - /// — or, if a [`PreserveConfig`] was supplied at construction, use - /// the preserve-aware policy instead — to the primary map, dropping - /// entries that fall outside the active policy. + /// to the primary map, dropping entries that fall outside its policy. + /// + /// If `preserve_strings` is non-empty, any of those Pauli strings + /// that the strategy would have dropped are re-inserted afterwards + /// with their pre-truncate coefficient. The mechanism composes with + /// any [`Strategy`] (coefficient-magnitude, max-weight, combinations + /// — anything) because the strategy runs unchanged in the middle. pub fn truncate(&mut self) { - if self.preserve.is_some() { - // Capture the keep-set directly as `HashSet` so per-key - // lookup is O(1) with no string conversion. `W: Send + Sync` - // is part of the `PauliWordTrait` bundle, so the closure - // satisfies `ACMap::retain`'s `Sync + Send` requirement. - let preserve = self.preserve.as_ref().unwrap(); - let keep = preserve.keep.clone(); - let base = preserve.base_threshold; - let lambda = preserve.weight_lambda; - let data = self.data_mut(); - data.retain(|k, v| { - if keep.contains(k) { - return true; - } - let eff = base * (lambda * k.weight() as f64).exp(); - !v.cutoff(eff) - }); - } else { + // Hot path: empty preserve set → just run the strategy. + if self.preserve_strings.is_empty() { let strategy = self.strategy; strategy.truncate(self.data_mut()); + return; + } + + // Snapshot the current coefficients of preserved keys. We piggy- + // back on `retain` (which always returns true here, so it's a + // pure scan) to walk `(k, v)` pairs without needing a separate + // `get`/`iter` route through the `ACMap` traits. + let preserve = self.preserve_strings.clone(); + let mut saved: Vec<(T::PauliWordType, T::Coeff)> = Vec::new(); + self.data_mut().retain(|k, v| { + if preserve.contains(k) { + saved.push((k.clone(), v.clone())); + } + true + }); + + // Run the configured strategy verbatim. + let strategy = self.strategy; + strategy.truncate(self.data_mut()); + + // Restore any preserved entry the strategy dropped. We use + // `add_assign` because the trait does not expose a plain + // "insert if absent"; on a missing key it inserts, which is + // what we want here (the guarded `contains_with` keeps us from + // accidentally summing into a kept entry). + let data = self.data_mut(); + for (k, v) in saved { + if !data.contains_with(&k, |_| true) { + data.add_assign(k, v); + } } } - /// Read-only access to the active [`PreserveConfig`], if any. - pub fn preserve(&self) -> Option<&PreserveConfig> { - self.preserve.as_ref() + /// Read-only access to the active preserve set. + pub fn preserve_strings(&self) -> &HashSet { + &self.preserve_strings } } diff --git a/crates/ppvm-runtime/src/sum/mod.rs b/crates/ppvm-runtime/src/sum/mod.rs index 38cf01149..fef74a6db 100644 --- a/crates/ppvm-runtime/src/sum/mod.rs +++ b/crates/ppvm-runtime/src/sum/mod.rs @@ -6,7 +6,7 @@ mod data; mod display; mod noise; mod ops; -mod preserve; +pub mod preserve; mod proj; mod rot1; mod rot2; @@ -17,4 +17,3 @@ mod approx; pub use data::PauliSum; pub use ops::impl_op_mul_assign_coefficient; -pub use preserve::PreserveConfig; diff --git a/crates/ppvm-runtime/src/sum/preserve.rs b/crates/ppvm-runtime/src/sum/preserve.rs index bc3c38862..abb2b551c 100644 --- a/crates/ppvm-runtime/src/sum/preserve.rs +++ b/crates/ppvm-runtime/src/sum/preserve.rs @@ -1,113 +1,68 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 -//! Observable-aware truncation: never drop a chosen set of Pauli -//! strings, and apply a weight-biased threshold to the rest. +//! Observable-aware truncation: a set of Pauli strings that the active +//! truncation [`Strategy`](crate::traits::Strategy) is *not allowed* to +//! drop. //! //! # Why //! -//! For a transport diagnostic of the form `<Ô(t) Ô(0)>`, the result +//! For a transport diagnostic of the form `<Ô(t) Ô(0)>`, the answer //! depends only on the projection of the propagated `Ô(t)` onto the -//! handful of Pauli strings that make up `Ô(0)`. Standard -//! coefficient-magnitude truncation can drop those exact strings when -//! their coefficients drift toward the threshold — typically the tail -//! of a spreading operator at far sites. This module lets the caller -//! mark a small set of strings as *preserved* so they survive any -//! truncation, regardless of their current coefficient. +//! handful of Pauli strings that make up `Ô(0)`. Coefficient-magnitude +//! truncation (or any other truncation strategy) can drop those exact +//! strings when their coefficients drift toward the cutoff — typically +//! the tail of a spreading operator at far sites. The preserve mechanism +//! marks a small set of strings as *kept*, and they survive truncation +//! regardless of what the active strategy would otherwise do. //! -//! The same struct also exposes a `weight_lambda` knob that biases the -//! threshold by `exp(λ · weight(P))`, i.e. truncates higher-weight -//! Pauli strings more aggressively. This is the "virtual DAOE" tactic -//! of [Rakovszky, Pollmann, von Keyserlingk -//! (2020)](https://arxiv.org/abs/2004.05177) lifted into a purely -//! truncation-level knob: the dynamics is not damped, only the -//! truncation criterion is weight-biased. Set `weight_lambda = 0` for -//! a uniform threshold. +//! Crucially this is *not* itself a strategy: it composes with **any** +//! [`Strategy`] (coefficient threshold, max weight, both, anything else) +//! via a post-filter inside [`PauliSum::truncate`](crate::sum::PauliSum::truncate) +//! — snapshot, run the strategy verbatim, re-insert any preserved keys +//! that the strategy dropped. //! //! # Usage //! //! ```ignore //! use ppvm_runtime::prelude::*; -//! use ppvm_runtime::sum::PreserveConfig; +//! use ppvm_runtime::sum::preserve; //! type Cfg = config::indexmap::ByteFxHashF64<1>; -//! type W = ::PauliWordType; //! -//! let preserve = PreserveConfig::::single_z(4, /*base*/ 1e-4, /*lambda*/ 0.0); //! let mut s: PauliSum = PauliSum::builder() //! .n_qubits(4) -//! .preserve(preserve) +//! // pair the keep-set with any strategy you like; here, default. +//! .preserve_strings(preserve::single_z(4)) //! .build(); //! s += ("ZIII", 1.0); -//! s.exchange(0, 1, 0.1); // hypothetical; preserve survives all auto-truncates +//! s.exchange(0, 1, 0.1); // hypothetical: ZIII survives all auto-truncates //! ``` use std::collections::HashSet; use crate::traits::PauliWordTrait; -/// Observable-aware truncation policy applied by [`PauliSum::truncate`] -/// when set via the builder. -/// -/// See the [module docs](self) for the design rationale. Construct via -/// [`PreserveConfig::new`] for arbitrary keep-sets, or via one of the -/// `single_z` / `from_strings` convenience constructors. -#[derive(Debug, Clone)] -pub struct PreserveConfig { - /// Pauli strings that are *never* dropped by truncation, regardless - /// of their current coefficient. - pub keep: HashSet, - /// Base coefficient cutoff. A term `P` with weight `k` is dropped - /// when `|c_P| < base_threshold · exp(weight_lambda · k)`. - pub base_threshold: f64, - /// Weight-biased multiplier (virtual DAOE knob). `0` gives a - /// uniform cutoff; positive values truncate higher-weight strings - /// more aggressively. See module docs. - pub weight_lambda: f64, +/// Build the keep-set of all single-`Z` Pauli strings on `n_qubits` qubits — +/// `Z_0, Z_1, …, Z_{n−1}`. The natural choice when the transport +/// diagnostic is `<Σ_j Z_j(t) Z_i(0)>` (z-magnetization spread). +pub fn single_z(n_qubits: usize) -> HashSet { + (0..n_qubits) + .map(|i| { + let s: String = (0..n_qubits) + .map(|j| if j == i { 'Z' } else { 'I' }) + .collect(); + W::from(s) + }) + .collect() } -impl PreserveConfig { - /// General constructor: pass any iterable of [`PauliWordTrait`] - /// values to keep. - pub fn new(keep: impl IntoIterator, base_threshold: f64, weight_lambda: f64) -> Self { - Self { - keep: keep.into_iter().collect(), - base_threshold, - weight_lambda, - } - } - - /// Preserve every single-`Z` Pauli string on `n_qubits` qubits — - /// `Z_0, Z_1, …, Z_{n−1}`. The natural choice when the transport - /// diagnostic is `<Σ_j Z_j(t) Z_i(0)>` (z-magnetization spread). - pub fn single_z(n_qubits: usize, base_threshold: f64, weight_lambda: f64) -> Self { - let keep: HashSet = (0..n_qubits) - .map(|i| { - let s: String = (0..n_qubits) - .map(|j| if j == i { 'Z' } else { 'I' }) - .collect(); - W::from(s) - }) - .collect(); - Self::new(keep, base_threshold, weight_lambda) - } - - /// Preserve a user-specified list of Pauli strings (each must be a - /// length-`n_qubits` string over `{I, X, Y, Z}`). - pub fn from_strings( - strings: impl IntoIterator, - base_threshold: f64, - weight_lambda: f64, - ) -> Self { - let keep: HashSet = strings.into_iter().map(W::from).collect(); - Self::new(keep, base_threshold, weight_lambda) - } - - /// The effective cutoff for a term of weight `k`: - /// `base_threshold · exp(weight_lambda · k)`. - #[inline] - pub fn threshold_for_weight(&self, weight: usize) -> f64 { - self.base_threshold * (self.weight_lambda * weight as f64).exp() - } +/// Build a keep-set from a user-specified list of Pauli strings (each +/// must be a length-`n_qubits` string over `{I, X, Y, Z}`). +pub fn from_strings(strings: I) -> HashSet +where + I: IntoIterator, +{ + strings.into_iter().map(W::from).collect() } #[cfg(test)] @@ -119,60 +74,48 @@ mod tests { #[test] fn single_z_builds_correct_set() { - let c: PreserveConfig = PreserveConfig::single_z(3, 1e-4, 0.0); - assert_eq!(c.keep.len(), 3); - assert!(c.keep.contains(&W::from("ZII"))); - assert!(c.keep.contains(&W::from("IZI"))); - assert!(c.keep.contains(&W::from("IIZ"))); - assert!(!c.keep.contains(&W::from("ZZI"))); + let s: HashSet = single_z(3); + assert_eq!(s.len(), 3); + assert!(s.contains(&W::from("ZII"))); + assert!(s.contains(&W::from("IZI"))); + assert!(s.contains(&W::from("IIZ"))); + assert!(!s.contains(&W::from("ZZI"))); } #[test] fn from_strings_round_trip() { - let c: PreserveConfig = - PreserveConfig::from_strings(["XYZ".to_string(), "ZZZ".to_string()], 0.1, 0.5); - assert_eq!(c.keep.len(), 2); - assert!(c.keep.contains(&W::from("XYZ"))); - assert!(c.keep.contains(&W::from("ZZZ"))); - } - - #[test] - fn threshold_for_weight_is_weight_biased() { - let c: PreserveConfig = PreserveConfig::new([] as [W; 0], 1e-4, 1.0); - assert!((c.threshold_for_weight(0) - 1e-4).abs() < 1e-12); - assert!((c.threshold_for_weight(1) - 1e-4 * 1.0_f64.exp()).abs() < 1e-12); - assert!((c.threshold_for_weight(3) - 1e-4 * 3.0_f64.exp()).abs() < 1e-12); - } - - #[test] - fn lambda_zero_gives_uniform_threshold() { - let c: PreserveConfig = PreserveConfig::new([] as [W; 0], 0.5, 0.0); - for k in 0..10 { - assert!((c.threshold_for_weight(k) - 0.5).abs() < 1e-12); - } + let s: HashSet = from_strings(["XYZ".to_string(), "ZZZ".to_string()]); + assert_eq!(s.len(), 2); + assert!(s.contains(&W::from("XYZ"))); + assert!(s.contains(&W::from("ZZZ"))); } // =========================================================================== // End-to-end PauliSum integration tests // =========================================================================== // - // These exercise the `PauliSum::truncate` integration: a `PauliSum` built - // with a `PreserveConfig` should never drop strings in the keep-set, but - // should drop other terms below the (weight-biased) cutoff. + // These exercise the `PauliSum::truncate` snapshot-and-restore post-filter: + // whatever the active strategy decides to drop, preserved strings come back. + use crate::config::Config; use crate::config::fxhash::ByteF64; + use crate::strategy::CoefficientThreshold; use crate::sum::PauliSum; use crate::traits::RotationTwo; type Cfg = ByteF64<1>; - type PWord = ::PauliWordType; + type CfgThr = ByteF64<1, CoefficientThreshold>; + type PWord = ::PauliWordType; - /// Preserve-aware truncate keeps the preserved strings even if their - /// coefficient is well below the base cutoff. + /// The active strategy (`CoefficientThreshold`) drops a tiny coefficient, + /// but the preserved string is re-inserted. #[test] - fn truncate_keeps_preserved_strings_below_cutoff() { - let preserve: PreserveConfig = PreserveConfig::single_z(3, /*base*/ 0.5, 0.0); - let mut s: PauliSum = PauliSum::builder().n_qubits(3).preserve(preserve).build(); + fn truncate_restores_preserved_string_dropped_by_strategy() { + let mut s: PauliSum = PauliSum::builder() + .n_qubits(3) + .strategy(CoefficientThreshold(0.5)) + .preserve_strings(single_z::(3)) + .build(); // Single-Z strings get tiny coefficients (below cutoff 0.5) — must survive. s += ("ZII", 1e-6); s += ("IZI", 1e-6); @@ -183,8 +126,7 @@ mod tests { s += ("XXX", 0.7); s.truncate(); - let kept: std::collections::HashSet = - s.data().keys().map(|k| k.to_string()).collect(); + let kept: HashSet = s.data().keys().map(|k| k.to_string()).collect(); assert!(kept.contains("ZII"), "preserved ZII should be kept"); assert!(kept.contains("IZI"), "preserved IZI should be kept"); assert!(kept.contains("IIZ"), "preserved IIZ should be kept"); @@ -195,52 +137,19 @@ mod tests { assert!(kept.contains("XXX"), "above-cutoff XXX should be kept"); } - /// With `weight_lambda > 0`, higher-weight non-preserved strings are - /// dropped at a lower effective coefficient than lower-weight ones. - /// `exp(λ k) · ε` → weight-1 needs `> ε·e^λ`, weight-3 needs `> ε·e^{3λ}`. - #[test] - fn truncate_weight_lambda_is_more_aggressive_on_high_weight() { - let preserve: PreserveConfig = PreserveConfig::new( - [] as [PWord; 0], // empty keep-set; pure weighted threshold - /*base*/ 0.01, - /*lambda*/ 1.0, - ); - let mut s: PauliSum = PauliSum::builder().n_qubits(3).preserve(preserve).build(); - // Weight 1: cutoff is 0.01 · e^1 ≈ 0.0272. Coefficient 0.05 survives. - s += ("XII", 0.05); - // Weight 2: cutoff is 0.01 · e^2 ≈ 0.0739. Coefficient 0.05 dropped. - s += ("XXI", 0.05); - // Weight 3: cutoff is 0.01 · e^3 ≈ 0.2008. Coefficient 0.1 dropped. - s += ("XYZ", 0.1); - - s.truncate(); - let kept: std::collections::HashSet = - s.data().keys().map(|k| k.to_string()).collect(); - assert!( - kept.contains("XII"), - "weight-1 above weighted cutoff should survive" - ); - assert!( - !kept.contains("XXI"), - "weight-2 below weighted cutoff should be dropped" - ); - assert!( - !kept.contains("XYZ"), - "weight-3 below weighted cutoff should be dropped" - ); - } - /// End-to-end conservation: `Σ Z_i` propagated through a sequence of /// `rxx + ryy` exchange-style gates (which preserve total Z) with - /// aggressive preserve-aware truncation keeps every single-Z - /// coefficient at 1.0 exactly. The same setup with a plain - /// `CoefficientThreshold(0.5)` would drop them; preserve-aware - /// truncation does not. + /// aggressive coefficient truncation keeps every single-Z coefficient + /// at 1.0 exactly. The same setup without the preserve set would drop + /// them once their coefficients dipped below the threshold. #[test] fn preserve_single_z_conserves_total_z_under_aggressive_truncation() { let n = 4; - let preserve: PreserveConfig = PreserveConfig::single_z(n, /*base*/ 0.5, 0.0); - let mut s: PauliSum = PauliSum::builder().n_qubits(n).preserve(preserve).build(); + let mut s: PauliSum = PauliSum::builder() + .n_qubits(n) + .strategy(CoefficientThreshold(0.5)) + .preserve_strings(single_z::(n)) + .build(); for j in 0..n { let term: String = (0..n).map(|i| if i == j { 'Z' } else { 'I' }).collect(); s += (term.as_str(), 1.0); @@ -272,20 +181,14 @@ mod tests { } } - /// Without preserve, the same aggressive `CoefficientThreshold(0.5)` - /// also keeps `Σ Z_i` intact in this trivial case (because Σ Z is a - /// fixed point and never falls below 0.5). The interesting failure - /// case happens when single-Z coefficients drift below the cutoff - /// — covered by the Python end-to-end test on a spreading initial - /// observable. + /// No preserve set → behaviour is identical to the bare strategy. #[test] - fn no_preserve_falls_back_to_strategy() { + fn empty_preserve_falls_back_to_strategy_unchanged() { let n = 2; - // No `preserve` → strategy (NoStrategy here) is used and nothing is dropped. let mut s: PauliSum = PauliSum::builder().n_qubits(n).build(); s += ("ZI", 1.0); s += ("XY", 1e-30); - s.truncate(); // NoStrategy: keeps everything + s.truncate(); // default strategy keeps everything assert_eq!(s.data().iter().count(), 2); } } diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index 42fdcc5d3..4132852e5 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -158,8 +158,6 @@ class PauliSum( max_pauli_weight: int | None = None max_loss_weight: int | None = None preserve_strings: Sequence[str] | None = None - preserve_threshold: float | None = None - preserve_weight_lambda: float = 0.0 _interface: PauliSumInterface = field(init=False, repr=False) @@ -224,13 +222,7 @@ def _init_ppvm_interface( "All preserve strings must have length n_qubits " f"({n_qubits}); got {len(s)}: {s!r}" ) - options["preserve"] = preserve_list - options["preserve_threshold"] = ( - self.preserve_threshold - if self.preserve_threshold is not None - else self.min_abs_coeff - ) - options["preserve_weight_lambda"] = self.preserve_weight_lambda + options["preserve_strings"] = preserve_list return interface( n_qubits, @@ -254,8 +246,6 @@ def new( max_pauli_weight: int | None = None, max_loss_weight: int | None = None, preserve_strings: Sequence[str] | None = None, - preserve_threshold: float | None = None, - preserve_weight_lambda: float = 0.0, ) -> Self: """Create a PauliSum from one or more terms with flexible input formats. @@ -278,25 +268,15 @@ def new( up to 10 qubits are lost simultaneously. preserve_strings: Optional list of Pauli strings (each of length ``n_qubits``) that should never be dropped by truncation, - regardless of coefficient magnitude. Useful for transport - diagnostics where the answer depends on the projection onto - a small fixed set of Pauli strings (e.g. ``Σ_j Z_j``). When - set, the underlying ``truncate()`` switches from - ``CoefficientThreshold`` / ``MaxPauliWeight`` to a - preserve-aware policy: listed strings always survive, the - rest are dropped below ``preserve_threshold``. See also + regardless of what the active strategy decides. Useful for + transport diagnostics where the answer depends on the + projection onto a small fixed set of Pauli strings (e.g. + ``Σ_j Z_j``). The mechanism is a post-filter on + ``truncate()`` — the configured strategy + (``min_abs_coeff`` / ``max_pauli_weight``) runs unchanged, + then any preserved string the strategy dropped is + re-inserted with its pre-truncate coefficient. See also :func:`preserve_single_z`. - preserve_threshold: Coefficient cutoff applied to non-preserved - strings. Defaults to ``min_abs_coeff`` if ``preserve_strings`` - is set, else ignored. - preserve_weight_lambda: Weight-biased multiplier for the - non-preserved threshold ("virtual DAOE"): a term of weight - ``k`` is dropped below ``preserve_threshold * exp(λ k)``. - Setting ``λ > 0`` makes high-weight strings be truncated - more aggressively without modifying the dynamics, inspired - by `Rakovszky, Pollmann, von Keyserlingk (2020) - `_. Default ``0`` gives a - uniform threshold. Returns: A new instance of the class this method is called on. @@ -342,8 +322,6 @@ def new( max_pauli_weight=max_pauli_weight, max_loss_weight=max_loss_weight, preserve_strings=preserve_strings, - preserve_threshold=preserve_threshold, - preserve_weight_lambda=preserve_weight_lambda, ) def __str__(self) -> str: @@ -358,8 +336,6 @@ def __copy__(self) -> Self: object.__setattr__(new, "max_pauli_weight", self.max_pauli_weight) object.__setattr__(new, "max_loss_weight", self.max_loss_weight) object.__setattr__(new, "preserve_strings", self.preserve_strings) - object.__setattr__(new, "preserve_threshold", self.preserve_threshold) - object.__setattr__(new, "preserve_weight_lambda", self.preserve_weight_lambda) object.__setattr__(new, "_interface", self._interface.__copy__()) return new diff --git a/ppvm-python/test/test_preserve.py b/ppvm-python/test/test_preserve.py index 90e146bc8..98351f63e 100644 --- a/ppvm-python/test/test_preserve.py +++ b/ppvm-python/test/test_preserve.py @@ -31,12 +31,8 @@ def test_preserve_strings_round_trip(): 3, "Z1", preserve_strings=preserve_single_z(3), - preserve_threshold=1e-4, - preserve_weight_lambda=0.0, ) assert list(ps.preserve_strings) == ["ZII", "IZI", "IIZ"] - assert ps.preserve_threshold == 1e-4 - assert ps.preserve_weight_lambda == 0.0 def test_preserve_strings_length_validated(): @@ -45,48 +41,67 @@ def test_preserve_strings_length_validated(): 3, "Z1", preserve_strings=["ZI"], # wrong length - preserve_threshold=0.1, ) # ============================================================================= -# Behavior: a preserved string with tiny coefficient survives truncation. +# Behavior: a preserved string with tiny coefficient survives truncation, +# regardless of which truncation strategy is active. The preserve mechanism +# is a post-filter that re-inserts dropped preserved keys after the strategy +# runs — it composes with any strategy. # ============================================================================= -def test_preserved_string_survives_below_threshold(): - """A single-Z string with coefficient well below the cutoff must - survive truncation. Without preserve, it would be dropped.""" - # Start with a tiny-coefficient Z0 plus a normal-coefficient XII; the - # preserve-aware truncate should keep both — Z0 because it's preserved, - # XII because its magnitude (0.5) is above the 1e-3 threshold. +def test_preserved_string_survives_coefficient_truncation(): + """`min_abs_coeff` would drop a tiny-coefficient single-Z, but the + preserve mechanism puts it back.""" ps = PauliSum.new( 3, - [("Z0", 1e-8), ("X0", 0.5)], + [("Z0", 1e-8), ("X0", 0.5), ("X1", 1e-8)], + min_abs_coeff=1e-3, preserve_strings=preserve_single_z(3), - preserve_threshold=1e-3, ) - # The XII below-threshold-non-preserved version: also include something - # that should be dropped to make sure the policy does drop non-preserved - # below-threshold things. - ps2 = PauliSum.new( - 3, - [("Z0", 1e-8), ("X0", 1e-6), ("X1", 0.5)], - preserve_strings=preserve_single_z(3), - preserve_threshold=1e-3, + # Trigger auto-truncate via a no-op gate. + ps.rx(0, 0.0) + kept = {t for t, _ in ps.terms} + assert "ZII" in kept, "preserved tiny Z0 must survive" + assert "XII" in kept, "above-threshold XII must survive" + assert "IXI" not in kept, "below-threshold non-preserved IXI must be dropped" + + +def test_preserved_string_survives_weight_truncation(): + """`max_pauli_weight` would drop a high-weight string; if that string + happens to be in the preserve set, it's restored.""" + # Build a 4-qubit sum where one term has weight 3 (X0X1X2 → "XXXI") + # and we cap max_pauli_weight at 2. Without preserve, the weight-3 + # term is dropped. With preserve including it, it survives. + ps = PauliSum.new( + 4, + [("Z0", 1.0), ("X0X1X2", 0.7)], + max_pauli_weight=2, + preserve_strings=["XXXI"], + ) + ps.rx(0, 0.0) # no-op triggers truncate + kept = {t for t, _ in ps.terms} + assert "ZIII" in kept, "weight-1 Z0 must survive" + assert "XXXI" in kept, "weight-3 XXXI is in preserve set and must survive" + + +def test_preserved_string_survives_combined_truncation(): + """The strategy combines coefficient *and* weight cuts; preserve still + works orthogonally on top.""" + ps = PauliSum.new( + 4, + [("Z0", 1e-8), ("X0X1X2", 1e-8), ("Y0", 0.5)], + min_abs_coeff=1e-3, + max_pauli_weight=2, + preserve_strings=["ZIII", "XXXI"], # one tiny-coef, one high-weight ) - # Manually trigger the truncation via a no-op-on-this-state gate that - # auto-truncates: apply rx(addr0, 0.0) — does nothing arithmetically - # but triggers the auto-truncate. ps.rx(0, 0.0) - ps2.rx(0, 0.0) - kept_ps = {t for t, _ in ps.terms} - kept_ps2 = {t for t, _ in ps2.terms} - assert "ZII" in kept_ps, "preserved tiny Z0 must survive (ps)" - assert "XII" in kept_ps, "above-threshold XII must survive (ps)" - assert "ZII" in kept_ps2, "preserved tiny Z0 must survive (ps2)" - assert "IXI" in kept_ps2, "above-threshold X1 must survive (ps2)" - assert "XII" not in kept_ps2, "below-threshold non-preserved X0 must be dropped" + kept = {t for t, _ in ps.terms} + assert "ZIII" in kept, "preserved tiny Z0 must survive coefficient cut" + assert "XXXI" in kept, "preserved weight-3 XXXI must survive weight cut" + assert "YIII" in kept, "above-threshold YIII must survive" # ============================================================================= @@ -96,17 +111,10 @@ def test_preserved_string_survives_below_threshold(): def test_total_z_conservation_with_preserve_vs_without(): - """Reproduces a tiny-scale version of the user's main.py setup: - propagate a localized Z_i under XY exchange + Z-dephasing, with - aggressive truncation, and check that single-Z preserve substantially - reduces the drift in `result.sum(axis=1)` versus the same run with - plain `CoefficientThreshold` truncation. - - The drift is not zero in either case (small θ ⇒ direct loss - dominates and preserve closes most of the gap; but some indirect loss - via dropped off-diagonal terms always remains). The test asserts - only that preserve reduces drift by an order of magnitude or more. - """ + """Propagate a localized Z_i under XY exchange + Z-dephasing with + aggressive truncation. Single-Z preserve substantially reduces the + drift in `result.sum(axis=1)` versus the same run with plain + `min_abs_coeff` truncation.""" L = 8 i = L // 2 threshold = 0.02 @@ -115,8 +123,6 @@ def test_total_z_conservation_with_preserve_vs_without(): steps = 6 noise = (1 - math.exp(-gamma * dt)) / 2 - # Small angle so far-site Z_j coefficients drift toward the cutoff - # — the regime where preserve actually helps. edges = [(a, a + 1, 0.08) for a in range(L - 1)] z_observables = [PauliSum.new(L, f"Z{j}") for j in range(L)] @@ -126,8 +132,6 @@ def evolve(ps): sums.append(sum(ps.overlap(zz) for zz in z_observables)) for q in range(L): ps.pauli_error(q, [0.0, 0.0, noise]) - # XY exchange on each edge: rxx then ryy (both commute and - # together preserve total Z magnetization). for a, b, th in reversed(edges): ps.rxx(a, b, th) ps.ryy(a, b, th) @@ -139,14 +143,13 @@ def evolve(ps): ps_plain = PauliSum.new(L, f"Z{i}", min_abs_coeff=threshold, max_pauli_weight=L) drift_plain = evolve(ps_plain) - # Preserve-aware truncation with single-Z exempt. + # Same strategy + preserve-set on top. ps_pres = PauliSum.new( L, f"Z{i}", min_abs_coeff=threshold, max_pauli_weight=L, preserve_strings=preserve_single_z(L), - preserve_threshold=threshold, ) drift_pres = evolve(ps_pres) @@ -162,46 +165,6 @@ def evolve(ps): ) -# ============================================================================= -# Virtual DAOE: weight_lambda > 0 makes high-weight terms get truncated more -# aggressively, even when the base threshold would have kept them. -# ============================================================================= - - -def test_weight_lambda_drops_high_weight_more_aggressively(): - """A weight-3 term at coefficient 0.05 should survive a uniform 0.01 - threshold, but should be dropped by a `λ = 1.0` weight-biased - threshold (whose effective cutoff at weight 3 is 0.01·e^3 ≈ 0.2).""" - # Build a PauliSum with several terms of varying weight. - ps = PauliSum.new( - 4, - [("X0", 0.05), ("X0X1", 0.05), ("X0X1X2", 0.05)], - preserve_strings=[], # no preserve; use only weight-biased cutoff - preserve_threshold=0.01, - preserve_weight_lambda=1.0, - ) - # We can't pass preserve_strings=[] currently — the dataclass treats - # falsy as "no preserve". So instead pass a dummy preserve set with - # something we don't have, plus set the threshold. Re-do via a - # weight-only style: - ps = PauliSum.new( - 4, - [("X0", 0.05), ("X0X1", 0.05), ("X0X1X2", 0.05)], - preserve_strings=["IIII"], # never-matching keep-set; pure weighted cutoff - preserve_threshold=0.01, - preserve_weight_lambda=1.0, - ) - # Trigger truncate (no-op gate). - ps.rx(3, 0.0) - kept = {t for t, _ in ps.terms} - # Weight 1: cutoff = 0.01·e ≈ 0.0272. 0.05 > cutoff → survives. - assert "XIII" in kept, "weight-1 above weighted cutoff should survive" - # Weight 2: cutoff = 0.01·e^2 ≈ 0.0739. 0.05 < cutoff → dropped. - assert "XXII" not in kept, "weight-2 below weighted cutoff should be dropped" - # Weight 3: cutoff = 0.01·e^3 ≈ 0.2008. 0.05 < cutoff → dropped. - assert "XXXI" not in kept, "weight-3 below weighted cutoff should be dropped" - - # ============================================================================= # Default behavior: when preserve_strings is None, the existing strategy # (CoefficientThreshold + MaxPauliWeight) is used unchanged. From 558267883d761438ef038d27130c09f605054ba0 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Mon, 1 Jun 2026 08:49:03 +0100 Subject: [PATCH 4/5] =?UTF-8?q?chore(preserve):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20unused=20helpers,=20shorten=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete `single_z` / `from_strings` from `sum::preserve` (only used in tests; the one external caller in `interface.rs` is now a one-liner) - move the three integration tests to `tests/preserve.rs` and drop the now-empty `sum::preserve` module - drop the `preserve_single_z` Python helper (only used by tests; inlined as `_single_z` in `test_preserve.py`) - shorten the `preserve_strings` docstrings on the Rust field and the Python `new()` arg; remove the `:func:preserve_single_z` cross-reference Co-Authored-By: Claude Opus 4.7 --- crates/ppvm-python-native/src/interface.rs | 9 +- crates/ppvm-runtime/src/sum/data.rs | 9 +- crates/ppvm-runtime/src/sum/mod.rs | 1 - crates/ppvm-runtime/src/sum/preserve.rs | 194 --------------------- crates/ppvm-runtime/tests/preserve.rs | 114 ++++++++++++ ppvm-python/src/ppvm/__init__.py | 1 - ppvm-python/src/ppvm/paulisum.py | 33 +--- ppvm-python/test/test_preserve.py | 18 +- 8 files changed, 128 insertions(+), 251 deletions(-) delete mode 100644 crates/ppvm-runtime/src/sum/preserve.rs create mode 100644 crates/ppvm-runtime/tests/preserve.rs diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index ea76a3452..71efcbfe5 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use paste::paste; use ppvm_runtime::prelude::*; use ppvm_runtime::strategy::{ CoefficientThreshold, CombinedStrategy, MaxLossWeight, MaxPauliWeight, }; -use ppvm_runtime::sum::preserve; use pyo3::prelude::*; macro_rules! create_interface_loss_methods { @@ -73,10 +74,8 @@ macro_rules! create_interface { ) -> Self { let _ = max_loss_weight; // unused in non-loss variants let strategy = create_strategy!($loss, min_abs_coeff, max_pauli_weight, max_loss_weight); - // Pauli strings that `truncate()` must never drop. Composes - // with the active `strategy` as a snapshot-and-restore - // post-filter; see `PauliSum::truncate`. - let preserve_set = preserve::from_strings(preserve_strings.into_iter()); + let preserve_set: HashSet<_> = + preserve_strings.into_iter().map(Into::into).collect(); let mut ps = PauliSum::<$type>::builder() .n_qubits(n_qubits) .strategy(strategy) diff --git a/crates/ppvm-runtime/src/sum/data.rs b/crates/ppvm-runtime/src/sum/data.rs index d32da9d3e..9568b9273 100644 --- a/crates/ppvm-runtime/src/sum/data.rs +++ b/crates/ppvm-runtime/src/sum/data.rs @@ -44,13 +44,8 @@ pub struct PauliSum { n_qubits: usize, capacity: usize, strategy: T::Strategy, - /// Pauli strings that [`PauliSum::truncate`] must never drop, - /// regardless of what the active [`Strategy`] would decide. Used - /// for observable-aware truncation (e.g. preserving the support of - /// a transport observable while letting the strategy aggressively - /// trim everything else). Composes with any strategy via a - /// snapshot-and-restore post-filter; see [`PauliSum::truncate`]. - /// Empty by default. See helpers in [`crate::sum::preserve`]. + /// Keep-set: strings [`PauliSum::truncate`] must always re-insert + /// after the strategy runs. Empty by default. preserve_strings: HashSet, } diff --git a/crates/ppvm-runtime/src/sum/mod.rs b/crates/ppvm-runtime/src/sum/mod.rs index fef74a6db..280dd5a60 100644 --- a/crates/ppvm-runtime/src/sum/mod.rs +++ b/crates/ppvm-runtime/src/sum/mod.rs @@ -6,7 +6,6 @@ mod data; mod display; mod noise; mod ops; -pub mod preserve; mod proj; mod rot1; mod rot2; diff --git a/crates/ppvm-runtime/src/sum/preserve.rs b/crates/ppvm-runtime/src/sum/preserve.rs deleted file mode 100644 index abb2b551c..000000000 --- a/crates/ppvm-runtime/src/sum/preserve.rs +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Observable-aware truncation: a set of Pauli strings that the active -//! truncation [`Strategy`](crate::traits::Strategy) is *not allowed* to -//! drop. -//! -//! # Why -//! -//! For a transport diagnostic of the form `<Ô(t) Ô(0)>`, the answer -//! depends only on the projection of the propagated `Ô(t)` onto the -//! handful of Pauli strings that make up `Ô(0)`. Coefficient-magnitude -//! truncation (or any other truncation strategy) can drop those exact -//! strings when their coefficients drift toward the cutoff — typically -//! the tail of a spreading operator at far sites. The preserve mechanism -//! marks a small set of strings as *kept*, and they survive truncation -//! regardless of what the active strategy would otherwise do. -//! -//! Crucially this is *not* itself a strategy: it composes with **any** -//! [`Strategy`] (coefficient threshold, max weight, both, anything else) -//! via a post-filter inside [`PauliSum::truncate`](crate::sum::PauliSum::truncate) -//! — snapshot, run the strategy verbatim, re-insert any preserved keys -//! that the strategy dropped. -//! -//! # Usage -//! -//! ```ignore -//! use ppvm_runtime::prelude::*; -//! use ppvm_runtime::sum::preserve; -//! type Cfg = config::indexmap::ByteFxHashF64<1>; -//! -//! let mut s: PauliSum = PauliSum::builder() -//! .n_qubits(4) -//! // pair the keep-set with any strategy you like; here, default. -//! .preserve_strings(preserve::single_z(4)) -//! .build(); -//! s += ("ZIII", 1.0); -//! s.exchange(0, 1, 0.1); // hypothetical: ZIII survives all auto-truncates -//! ``` - -use std::collections::HashSet; - -use crate::traits::PauliWordTrait; - -/// Build the keep-set of all single-`Z` Pauli strings on `n_qubits` qubits — -/// `Z_0, Z_1, …, Z_{n−1}`. The natural choice when the transport -/// diagnostic is `<Σ_j Z_j(t) Z_i(0)>` (z-magnetization spread). -pub fn single_z(n_qubits: usize) -> HashSet { - (0..n_qubits) - .map(|i| { - let s: String = (0..n_qubits) - .map(|j| if j == i { 'Z' } else { 'I' }) - .collect(); - W::from(s) - }) - .collect() -} - -/// Build a keep-set from a user-specified list of Pauli strings (each -/// must be a length-`n_qubits` string over `{I, X, Y, Z}`). -pub fn from_strings(strings: I) -> HashSet -where - I: IntoIterator, -{ - strings.into_iter().map(W::from).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::word::PauliWord; - - type W = PauliWord<[u8; 1]>; - - #[test] - fn single_z_builds_correct_set() { - let s: HashSet = single_z(3); - assert_eq!(s.len(), 3); - assert!(s.contains(&W::from("ZII"))); - assert!(s.contains(&W::from("IZI"))); - assert!(s.contains(&W::from("IIZ"))); - assert!(!s.contains(&W::from("ZZI"))); - } - - #[test] - fn from_strings_round_trip() { - let s: HashSet = from_strings(["XYZ".to_string(), "ZZZ".to_string()]); - assert_eq!(s.len(), 2); - assert!(s.contains(&W::from("XYZ"))); - assert!(s.contains(&W::from("ZZZ"))); - } - - // =========================================================================== - // End-to-end PauliSum integration tests - // =========================================================================== - // - // These exercise the `PauliSum::truncate` snapshot-and-restore post-filter: - // whatever the active strategy decides to drop, preserved strings come back. - - use crate::config::Config; - use crate::config::fxhash::ByteF64; - use crate::strategy::CoefficientThreshold; - use crate::sum::PauliSum; - use crate::traits::RotationTwo; - - type Cfg = ByteF64<1>; - type CfgThr = ByteF64<1, CoefficientThreshold>; - type PWord = ::PauliWordType; - - /// The active strategy (`CoefficientThreshold`) drops a tiny coefficient, - /// but the preserved string is re-inserted. - #[test] - fn truncate_restores_preserved_string_dropped_by_strategy() { - let mut s: PauliSum = PauliSum::builder() - .n_qubits(3) - .strategy(CoefficientThreshold(0.5)) - .preserve_strings(single_z::(3)) - .build(); - // Single-Z strings get tiny coefficients (below cutoff 0.5) — must survive. - s += ("ZII", 1e-6); - s += ("IZI", 1e-6); - s += ("IIZ", 1e-6); - // A non-preserved term well below cutoff — must be dropped. - s += ("XYZ", 1e-6); - // A non-preserved term above cutoff — must survive. - s += ("XXX", 0.7); - - s.truncate(); - let kept: HashSet = s.data().keys().map(|k| k.to_string()).collect(); - assert!(kept.contains("ZII"), "preserved ZII should be kept"); - assert!(kept.contains("IZI"), "preserved IZI should be kept"); - assert!(kept.contains("IIZ"), "preserved IIZ should be kept"); - assert!( - !kept.contains("XYZ"), - "below-cutoff non-preserved XYZ should be dropped" - ); - assert!(kept.contains("XXX"), "above-cutoff XXX should be kept"); - } - - /// End-to-end conservation: `Σ Z_i` propagated through a sequence of - /// `rxx + ryy` exchange-style gates (which preserve total Z) with - /// aggressive coefficient truncation keeps every single-Z coefficient - /// at 1.0 exactly. The same setup without the preserve set would drop - /// them once their coefficients dipped below the threshold. - #[test] - fn preserve_single_z_conserves_total_z_under_aggressive_truncation() { - let n = 4; - let mut s: PauliSum = PauliSum::builder() - .n_qubits(n) - .strategy(CoefficientThreshold(0.5)) - .preserve_strings(single_z::(n)) - .build(); - for j in 0..n { - let term: String = (0..n).map(|i| if i == j { 'Z' } else { 'I' }).collect(); - s += (term.as_str(), 1.0); - } - - // Apply a few rxx+ryy pairs (= XY exchange on each edge). This commutes - // with Σ Z_k, so the coefficients on Z_j should remain at 1.0. - for (a, b) in [(0, 1), (1, 2), (2, 3)] { - s.rxx(a, b, 0.37); - s.ryy(a, b, 0.37); - s.truncate(); - } - - for j in 0..n { - let term: String = (0..n).map(|i| if i == j { 'Z' } else { 'I' }).collect(); - let word: PWord = term.clone().into(); - let coeff = s.data().iter().find(|(k, _)| **k == word).map(|(_, v)| *v); - assert!( - coeff.is_some(), - "single-Z string {} must be preserved", - term - ); - assert!( - (coeff.unwrap() - 1.0).abs() < 1e-10, - "coefficient on {} should remain 1.0 (got {})", - term, - coeff.unwrap() - ); - } - } - - /// No preserve set → behaviour is identical to the bare strategy. - #[test] - fn empty_preserve_falls_back_to_strategy_unchanged() { - let n = 2; - let mut s: PauliSum = PauliSum::builder().n_qubits(n).build(); - s += ("ZI", 1.0); - s += ("XY", 1e-30); - s.truncate(); // default strategy keeps everything - assert_eq!(s.data().iter().count(), 2); - } -} diff --git a/crates/ppvm-runtime/tests/preserve.rs b/crates/ppvm-runtime/tests/preserve.rs new file mode 100644 index 000000000..4010369e2 --- /dev/null +++ b/crates/ppvm-runtime/tests/preserve.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for the `preserve_strings` snapshot-and-restore +//! post-filter in [`PauliSum::truncate`]: whatever the active strategy +//! decides to drop, preserved strings come back. + +use std::collections::HashSet; + +use ppvm_runtime::config::Config; +use ppvm_runtime::config::fxhash::ByteF64; +use ppvm_runtime::prelude::*; +use ppvm_runtime::strategy::CoefficientThreshold; +use ppvm_runtime::sum::PauliSum; + +type Cfg = ByteF64<1>; +type CfgThr = ByteF64<1, CoefficientThreshold>; +type PWord = ::PauliWordType; + +fn single_z(n_qubits: usize) -> HashSet { + (0..n_qubits) + .map(|i| { + let s: String = (0..n_qubits) + .map(|j| if j == i { 'Z' } else { 'I' }) + .collect(); + PWord::from(s) + }) + .collect() +} + +/// The active strategy (`CoefficientThreshold`) drops a tiny coefficient, +/// but the preserved string is re-inserted. +#[test] +fn truncate_restores_preserved_string_dropped_by_strategy() { + let mut s: PauliSum = PauliSum::builder() + .n_qubits(3) + .strategy(CoefficientThreshold(0.5)) + .preserve_strings(single_z(3)) + .build(); + // Single-Z strings get tiny coefficients (below cutoff 0.5) — must survive. + s += ("ZII", 1e-6); + s += ("IZI", 1e-6); + s += ("IIZ", 1e-6); + // A non-preserved term well below cutoff — must be dropped. + s += ("XYZ", 1e-6); + // A non-preserved term above cutoff — must survive. + s += ("XXX", 0.7); + + s.truncate(); + let kept: HashSet = s.data().keys().map(|k| k.to_string()).collect(); + assert!(kept.contains("ZII"), "preserved ZII should be kept"); + assert!(kept.contains("IZI"), "preserved IZI should be kept"); + assert!(kept.contains("IIZ"), "preserved IIZ should be kept"); + assert!( + !kept.contains("XYZ"), + "below-cutoff non-preserved XYZ should be dropped" + ); + assert!(kept.contains("XXX"), "above-cutoff XXX should be kept"); +} + +/// End-to-end conservation: `Σ Z_i` propagated through a sequence of +/// `rxx + ryy` exchange-style gates (which preserve total Z) with +/// aggressive coefficient truncation keeps every single-Z coefficient +/// at 1.0 exactly. The same setup without the preserve set would drop +/// them once their coefficients dipped below the threshold. +#[test] +fn preserve_single_z_conserves_total_z_under_aggressive_truncation() { + let n = 4; + let mut s: PauliSum = PauliSum::builder() + .n_qubits(n) + .strategy(CoefficientThreshold(0.5)) + .preserve_strings(single_z(n)) + .build(); + for j in 0..n { + let term: String = (0..n).map(|i| if i == j { 'Z' } else { 'I' }).collect(); + s += (term.as_str(), 1.0); + } + + // Apply a few rxx+ryy pairs (= XY exchange on each edge). This commutes + // with Σ Z_k, so the coefficients on Z_j should remain at 1.0. + for (a, b) in [(0, 1), (1, 2), (2, 3)] { + s.rxx(a, b, 0.37); + s.ryy(a, b, 0.37); + s.truncate(); + } + + for j in 0..n { + let term: String = (0..n).map(|i| if i == j { 'Z' } else { 'I' }).collect(); + let word: PWord = term.clone().into(); + let coeff = s.data().iter().find(|(k, _)| **k == word).map(|(_, v)| *v); + assert!( + coeff.is_some(), + "single-Z string {} must be preserved", + term + ); + assert!( + (coeff.unwrap() - 1.0).abs() < 1e-10, + "coefficient on {} should remain 1.0 (got {})", + term, + coeff.unwrap() + ); + } +} + +/// No preserve set → behaviour is identical to the bare strategy. +#[test] +fn empty_preserve_falls_back_to_strategy_unchanged() { + let n = 2; + let mut s: PauliSum = PauliSum::builder().n_qubits(n).build(); + s += ("ZI", 1.0); + s += ("XY", 1e-30); + s.truncate(); // default strategy keeps everything + assert_eq!(s.data().iter().count(), 2); +} diff --git a/ppvm-python/src/ppvm/__init__.py b/ppvm-python/src/ppvm/__init__.py index 9d76ba432..f28072849 100644 --- a/ppvm-python/src/ppvm/__init__.py +++ b/ppvm-python/src/ppvm/__init__.py @@ -8,7 +8,6 @@ from .generalized_tableau import sample_stim as sample_stim from .paulisum import LossyPauliSum as LossyPauliSum from .paulisum import PauliSum as PauliSum -from .paulisum import preserve_single_z as preserve_single_z from .squin_interpreter.device import ( GeneralizedTableauSimulator as GeneralizedTableauSimulator, ) diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index 4132852e5..784501a7d 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -15,26 +15,6 @@ _COMPACT_TOKEN_RE = re.compile(r"([IXYZ])(\d+)") -def preserve_single_z(n_qubits: int) -> list[str]: - """Return the list of all single-`Z` Pauli strings on `n_qubits` qubits. - - Suitable as the ``preserve_strings`` argument when computing a - `<Σ_j Z_j(t) Z_i(0)>`-style transport diagnostic: every Pauli string - that contributes to the projection onto total magnetization gets - exempted from truncation, so the conserved-charge component of the - propagated observable is preserved exactly regardless of how - aggressively the rest of the operator is truncated. - - Args: - n_qubits: Number of qubits. - - Returns: - ``["ZII...I", "IZI...I", ..., "II...IZ"]`` — `n_qubits` strings - in site order. - """ - return ["".join("Z" if i == j else "I" for i in range(n_qubits)) for j in range(n_qubits)] - - def _parse_term(term: "str | tuple[str, float]", n_qubits: int) -> "tuple[str, float]": if isinstance(term, tuple): s, coeff = term @@ -266,17 +246,8 @@ def new( Note, that this should usually be chosen to be quite low, since e.g. 10 would correspond to keeping terms that contribute if up to 10 qubits are lost simultaneously. - preserve_strings: Optional list of Pauli strings (each of length - ``n_qubits``) that should never be dropped by truncation, - regardless of what the active strategy decides. Useful for - transport diagnostics where the answer depends on the - projection onto a small fixed set of Pauli strings (e.g. - ``Σ_j Z_j``). The mechanism is a post-filter on - ``truncate()`` — the configured strategy - (``min_abs_coeff`` / ``max_pauli_weight``) runs unchanged, - then any preserved string the strategy dropped is - re-inserted with its pre-truncate coefficient. See also - :func:`preserve_single_z`. + preserve_strings: Pauli strings (length ``n_qubits`` each) that + truncation must never drop. Empty by default. Returns: A new instance of the class this method is called on. diff --git a/ppvm-python/test/test_preserve.py b/ppvm-python/test/test_preserve.py index 98351f63e..37dd748ff 100644 --- a/ppvm-python/test/test_preserve.py +++ b/ppvm-python/test/test_preserve.py @@ -7,17 +7,11 @@ import pytest -from ppvm import PauliSum, preserve_single_z - -# ============================================================================= -# Helper: `preserve_single_z` returns the right strings. -# ============================================================================= +from ppvm import PauliSum -def test_preserve_single_z_helper(): - assert preserve_single_z(1) == ["Z"] - assert preserve_single_z(2) == ["ZI", "IZ"] - assert preserve_single_z(4) == ["ZIII", "IZII", "IIZI", "IIIZ"] +def _single_z(n_qubits: int) -> list[str]: + return ["".join("Z" if i == j else "I" for i in range(n_qubits)) for j in range(n_qubits)] # ============================================================================= @@ -30,7 +24,7 @@ def test_preserve_strings_round_trip(): ps = PauliSum.new( 3, "Z1", - preserve_strings=preserve_single_z(3), + preserve_strings=_single_z(3), ) assert list(ps.preserve_strings) == ["ZII", "IZI", "IIZ"] @@ -59,7 +53,7 @@ def test_preserved_string_survives_coefficient_truncation(): 3, [("Z0", 1e-8), ("X0", 0.5), ("X1", 1e-8)], min_abs_coeff=1e-3, - preserve_strings=preserve_single_z(3), + preserve_strings=_single_z(3), ) # Trigger auto-truncate via a no-op gate. ps.rx(0, 0.0) @@ -149,7 +143,7 @@ def evolve(ps): f"Z{i}", min_abs_coeff=threshold, max_pauli_weight=L, - preserve_strings=preserve_single_z(L), + preserve_strings=_single_z(L), ) drift_pres = evolve(ps_pres) From 0410d3083ffc2170f227c53bfcec3e42a7d0b2db Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Tue, 2 Jun 2026 09:22:43 +0200 Subject: [PATCH 5/5] Update crates/ppvm-runtime/src/traits/map.rs --- crates/ppvm-runtime/src/traits/map.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/ppvm-runtime/src/traits/map.rs b/crates/ppvm-runtime/src/traits/map.rs index 8d96bbe30..ea426a4ca 100644 --- a/crates/ppvm-runtime/src/traits/map.rs +++ b/crates/ppvm-runtime/src/traits/map.rs @@ -123,13 +123,6 @@ pub trait ACMapScale< /// Drop entries that don't satisfy a predicate — used by truncation /// strategies. -/// -/// The predicate is `FnMut` and *not* required to be `Sync + Send`. All -/// existing backing maps (`HashMap`, `IndexMap`, `DashMap`) implement -/// `retain` sequentially anyway — the prior `Fn + Sync + Send` bound -/// was overly restrictive and prevented closures from capturing -/// non-Sync data (e.g. a `HashSet`, since `PauliWordTrait` -/// itself does not require `Send + Sync`). pub trait ACMapRetain< S: PauliStorage, V: Coefficient,