|
| 1 | +// SPDX-FileCopyrightText: 2026 The PPVM Authors |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +//! Regression guard for `CoefficientThreshold` truncation semantics under |
| 5 | +//! *deferred* truncation. |
| 6 | +//! |
| 7 | +//! `PauliSum::map_insert` must never drop a gate-produced term on its own; |
| 8 | +//! terms are only ever removed by an explicit `truncate()`. A since-reverted |
| 9 | +//! optimization (the `Strategy::discard` insertion-pruning) skipped inserting |
| 10 | +//! a produced term whose coefficient was individually below the threshold and |
| 11 | +//! whose key was not yet present, on the assumption that the *next* call is |
| 12 | +//! always `truncate()`. That assumption breaks when a caller applies several |
| 13 | +//! gates between truncations: two sub-threshold contributions to the same key |
| 14 | +//! can sum to an above-threshold coefficient (`|a + b| ≥ τ` while |
| 15 | +//! `|a|, |b| < τ`), so dropping either at production loses a genuinely |
| 16 | +//! surviving term — and biases the terms it would have fed. |
| 17 | +//! |
| 18 | +//! This test pins the correct behavior: with truncation deferred across |
| 19 | +//! gates, the `CoefficientThreshold` result must equal a non-truncating |
| 20 | +//! reference filtered once at the end (standard "insert everything, truncate |
| 21 | +//! last" semantics). |
| 22 | +
|
| 23 | +use ppvm_runtime::prelude::*; |
| 24 | +use ppvm_runtime::strategy::CoefficientThreshold; |
| 25 | + |
| 26 | +macro_rules! sorted_terms { |
| 27 | + ($state:expr) => {{ |
| 28 | + let mut v: Vec<(String, f64)> = $state.iter().map(|(k, c)| (k.to_string(), *c)).collect(); |
| 29 | + v.sort_by(|a, b| a.0.cmp(&b.0)); |
| 30 | + v |
| 31 | + }}; |
| 32 | +} |
| 33 | + |
| 34 | +#[test] |
| 35 | +fn deferred_truncation_keeps_terms_whose_merged_coefficient_survives() { |
| 36 | + // Two rx(θ) on a single Z with no truncate in between. The first rx |
| 37 | + // produces Y at ~sin(θ) ≈ 0.030 (< τ); the second adds another ~0.030, so |
| 38 | + // the accumulated Y ≈ sin(2θ) ≈ 0.060 is *above* τ and must survive the |
| 39 | + // final truncate. Insertion-pruning would drop the first (absent, |
| 40 | + // sub-threshold) Y and never accumulate it — losing the term entirely. |
| 41 | + const TAU: f64 = 0.05; |
| 42 | + const THETA: f64 = 0.03; |
| 43 | + |
| 44 | + // CoefficientThreshold, truncation deferred until the very end. |
| 45 | + let mut approx: PauliSum<config::indexmap::ByteFxHashF64<2, CoefficientThreshold>> = |
| 46 | + PauliSum::builder() |
| 47 | + .n_qubits(1) |
| 48 | + .strategy(CoefficientThreshold(TAU)) |
| 49 | + .build(); |
| 50 | + approx += ("Z", 1.0); |
| 51 | + approx.rx(0, THETA); |
| 52 | + approx.rx(0, THETA); |
| 53 | + approx.truncate(); |
| 54 | + |
| 55 | + // Exact reference: NoStrategy never truncates; filter once at τ at the end. |
| 56 | + let mut exact: PauliSum<config::indexmap::ByteFxHashF64<2>> = |
| 57 | + PauliSum::builder().n_qubits(1).build(); |
| 58 | + exact += ("Z", 1.0); |
| 59 | + exact.rx(0, THETA); |
| 60 | + exact.rx(0, THETA); |
| 61 | + let reference: Vec<(String, f64)> = sorted_terms!(exact) |
| 62 | + .into_iter() |
| 63 | + .filter(|(_, c)| c.abs() >= TAU) |
| 64 | + .collect(); |
| 65 | + |
| 66 | + // Sanity: the merged Y term really is above threshold (so this guards a |
| 67 | + // dropped surviving term, not just a coefficient nudge). |
| 68 | + assert!( |
| 69 | + reference.iter().any(|(k, c)| k == "Y" && c.abs() >= TAU), |
| 70 | + "test setup broken: reference should contain an above-threshold Y term, got {reference:?}" |
| 71 | + ); |
| 72 | + |
| 73 | + assert_eq!( |
| 74 | + sorted_terms!(approx), |
| 75 | + reference, |
| 76 | + "CoefficientThreshold with deferred truncation dropped or biased a term \ |
| 77 | + that standard insert-then-truncate keeps" |
| 78 | + ); |
| 79 | +} |
0 commit comments