Truncation
Under Heisenberg evolution the number of Pauli terms grows exponentially in the worst case — every rotation can split every anticommuting term in two. All practical Pauli propagation therefore truncates: it discards terms judged unimportant, trading a controlled error for a bounded operator size. This page explains the weight measures the strategies are built on, the exact semantics of each strategy, how truncation error can be tracked, and how to extend the system.
The README shows the quick-reference list of strategies; here we document what each one actually does.
Weight measures
Three notions of "size" of a Pauli string are used, all computed from the symplectic bitstrings in O(1):
weight—count_ones(z | x): the number of non-identity sites (the operator's support). The standard locality measure: under evolution generated by local Hamiltonians, high-weight terms are typically generated late and contribute little to local observables.x_weight—count_ones(x): the number of X/Y sites, i.e. the off-diagonal support. A term withx_weight0 is diagonal in the computational basis. This measure is natural when the observable of interest is diagonal (e.g. ⟨Z…Z⟩ against a product state): only terms that eventually return tox == 0can contribute, and terms with many off-diagonal factors are far from doing so.majorana_weight— the number of Majorana operators representing the string under the Jordan–Wigner encoding. For fermionic problems this is the physical locality measure: a Pauli that looks high-weight because of a Jordan–Wigner string can be a low-order fermionic operator, and vice versa. Computed by a branchless bitwise algorithm (suffix parity via parallel prefix XOR — see the docstring).
Deterministic strategies
Each strategy is a small struct; truncate!(O, strategy) applies it in place to either storage engine (see Data Structures & Performance).
| Strategy | Drops a term when |
|---|---|
NoTruncation() | never |
CoeffTruncation(ε) | $|c| \le \varepsilon$ |
WeightTruncation(w) | $\mathrm{weight}(P) > w$ |
XWeightTruncation(w) | $\mathrm{x\_weight}(P) > w$ |
MajoranaWeightTruncation(w) | $\mathrm{majorana\_weight}(P) > w$ |
WeightDampedTruncation(α, ε) | $|c|\, e^{-\alpha\, \mathrm{weight}(P)} \le \varepsilon$ |
XWeightDampedTruncation(α, ε) | $|c|\, e^{-\alpha\, \mathrm{x\_weight}(P)} \le \varepsilon$ |
The damped strategies interpolate between the two hard cutoffs: at $\alpha = 0$ they reduce to CoeffTruncation(ε), and as $\alpha \to \infty$ they approach a hard weight cutoff. In between, they implement a coefficient threshold that grows exponentially with weight — a high-weight term must have an exponentially larger coefficient to survive. This is often a better error/size trade-off than either hard cutoff alone, since it neither keeps negligible high-weight terms nor discards a high-weight term that happens to carry large amplitude.
CompositeTruncation(s1, s2, ...) applies strategies in sequence. The strategies are stored as a typed tuple so each inner application inlines (no dynamic dispatch).
AdaptiveTruncation(max_terms, min_thresh) targets a size rather than a threshold: if the sum exceeds max_terms terms, the coefficient threshold is raised to exactly the value that keeps the max_terms largest ones; otherwise it clips at min_thresh.
Stochastic strategies
Deterministic clipping is biased: it always shrinks coefficients toward zero, and the bias compounds over thousands of truncation steps. The stochastic strategies pay variance to remove that bias.
StochasticCoeffTruncation(ε)— unbiased stochastic rounding ("Russian roulette"). Terms with $|c| \ge \varepsilon$ are untouched. A term with $|c| < \varepsilon$ is promoted to magnitude $\varepsilon$ (phase preserved) with probability $|c|/\varepsilon$, and deleted otherwise. The expectation of every coefficient is exact: $\mathbb{E}[\tilde c] = c$.StochasticSamplingTruncation(k)— importance-samples $k$ terms with probability proportional to $|c|^2$ (without replacement, via the exponential-keys method), then rescales the survivors by a common factor so the L2 norm of the sum is preserved.
Both accept an rng for reproducibility.
Tracking truncation error: correction accumulators
truncate! optionally takes a CorrectionAccumulator that measures an observable before and after each truncation and accumulates the difference:
corr = EnergyCorrection(ψ) # ψ::Ket — the reference state
truncate!(O, strat, corr) # or pass correction=corr to evolve
corr.accumulated_energy # Σ (⟨ψ|O|ψ⟩_after − ⟨ψ|O|ψ⟩_before)NoCorrection()— tracks nothing, zero overhead (the default).EnergyCorrection(ψ)— accumulates the change in $\langle\psi|O|\psi\rangle$.EnergyVarianceCorrection(ψ)— additionally accumulates the change in $\mathrm{Var}(O, \psi)$.
Subtracting corr.accumulated_energy from a final expectation value removes the error injected at the recorded truncation events. Note what it does not capture: the downstream effect of evolving without the discarded terms (the dropped terms would have kept splitting and interfering). It is a first-order running correction, not an exact error bar — in practice it substantially tightens energy estimates in truncated propagation.
Direct clip functions
Each deterministic strategy has a plain-function twin that skips the strategy object: coeff_clip!, weight_clip!, x_weight_clip!, majorana_weight_clip!, weight_damped_clip!, x_weight_damped_clip!, stochastic_clip!. truncate! with the corresponding strategy calls exactly these, so the two spellings never disagree; the strategy form exists so truncation can be passed as a value into evolve! and composed.
Truncation during flat-storage evolution
On a SparsePauliVector, evolve! distinguishes two truncation roles (see Data Structures & Performance for the window machinery):
truncation— the strict strategy, applied at every merge boundary. Deterministic strategies (the table above, and composites of them) are compiled into a fused per-term predicate evaluated inside the merge kernel; stochastic and adaptive strategies run as a separate pass at the boundary. Either way the semantics atwindow = 1match thePauliSumpath exactly.local_truncation— an optional loose filter applied to each sine branch at append time, between merges. It must be deterministic (it runs per term, before deduplication), and weight-based cutoffs are exact there while coefficient cutoffs see pre-merge (unsummed) coefficients. Use it to bound intra-window growth; leave the tight threshold totruncation.
One composition limit applies to the fused path: a CompositeTruncation may contain at most one WeightDampedTruncation and one XWeightDampedTruncation (two damped filters of the same kind cannot be fused into a single predicate).
Extending the system
New strategies subtype TruncationStrategy and implement _apply!:
struct EvenWeightTruncation <: PauliOperators.TruncationStrategy end
function PauliOperators._apply!(O::PauliSum, ::EvenWeightTruncation)
return filter!(p -> iseven(weight(p.first)), O)
endThat is enough for truncate!(O, EvenWeightTruncation()) and for the truncation keyword of the PauliSum evolution path. (To use a custom strategy with SparsePauliVector, also implement _apply!(::SparsePauliVector, ::YourStrategy); it will run at merge boundaries.)
New accumulators subtype CorrectionAccumulator and implement _measure(O, corr) (snapshot whatever quantities you need, returned as a named tuple) and _accumulate!(corr, before, after) (fold the difference into the accumulator's state).