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.
Expectation-preserving (mean-field) truncation
Every strategy above discards the terms it selects. MeanFieldTruncation(k, ψ) instead folds the truncated weight back onto lower-order terms, so that the expectation value in a computational-basis reference state $\psi$ is preserved exactly at the truncation instant.
It works from the mean-field (fluctuation) decomposition of each single-qubit factor around $\psi$:
\[P_j = m_j\, I + \delta P_j, \qquad m_j = \langle\psi|P_j|\psi\rangle .\]
Expanding a Pauli string over this split and keeping only the terms with at most $k$ fluctuation factors $\delta P_j$ maps every string of weight $> k$ to a sum of strings of weight $\le k$. The map is:
- exact when $k \ge \mathrm{weight}(P)$ (nothing to fold);
- expectation-preserving for every $k$: $\langle\psi|O|\psi\rangle$ is unchanged;
- equal, on the $k$-by-weight diagonal, to the state-adapted covariance projection $\pi_k$ of arXiv:2609.12840.
On a computational-basis reference only pure-Z qubits carry a nonzero mean ($m_j = \pm 1$); X/Y factors have $m_j = 0$ and are pure fluctuation. Two consequences worth knowing:
- The order-0 case
MeanFieldTruncation(0, ψ)collapses each selected term to $c\,\langle\psi|P|\psi\rangle\, I$ — its mean-field contribution to the identity. - A term whose off-diagonal support already exceeds the budget ($\mathrm{x\_weight}(P) > k$) folds to nothing — its lowest fluctuation order is already $> k$. This is a real, occasionally surprising, zero.
strat = MeanFieldTruncation(3, ψ) # ψ::Ket — the reference state
truncate!(O, strat) # ⟨ψ|O|ψ⟩ is preserved; every surviving term has weight ≤ 3Because the fold modifies survivors, MeanFieldTruncation is not "pure-drop": it takes the measured before/after correction path rather than the fused delta (see below). It works on both storage engines. On a SparsePauliVector it runs as a separate boundary pass — sweep the buffer, stage each order-$k$ replacement into the flat append region, drop the folded terms, then sort-merge the appends back in (no Dict). The append/workspace buffers are reused across window boundaries, so in an evolution loop the pass amortizes to essentially zero allocation after its first growth; the growth itself is bounded by the pre-merge fold expansion, which can transiently exceed the operator size. The one-term kernel is exposed directly as mean_field_factorize / mean_field_factorize!.
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.
With MeanFieldTruncation (using the same $\psi$ for the strategy and the accumulator), accumulated_energy stays $\approx 0$ by construction — the fold preserves $\langle\psi|O|\psi\rangle$ — while accumulated_variance still records the variance the truncation removed. Since the fold is not pure-drop, it is measured wholesale before and after (the fused single-pass delta path is reserved for the drop strategies), so this works without any change to the accumulators.
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).
MeanFieldTruncation is not on the fused path: it folds truncated weight back onto lower-order terms, which cannot be expressed as a per-term keep/drop predicate. It still runs on a SparsePauliVector — as the non-compiled boundary pass (stage the order-$k$ replacements into the append region, drop the folded terms, sort-merge back). That pass reuses the flat buffers, so it amortizes to near-zero allocation across a loop, but it carries a larger transient buffer than the drop strategies and its correction, like every non-compiled strategy's, is measured before/after rather than accumulated inside the merge.
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).