Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Validating a result

One expectation value at one cutoff is not a result. Truncation has no variational bound, so nothing in the number itself says how far it is from the truth. Three checks turn it into one: sweep the cutoff, read the retained norm, and score against an exact reference wherever that is affordable.

Sweep the cutoff

Run the same propagation at a few decreasing truncation.coeff thresholds and watch the answer, the term count and the retained norm together.

import numpy as np
from paulistrings import Circuit, PauliSum, truncation

n, steps = 12, 6

step = Circuit(n)
for q in range(n - 1):
    step.pauli_rotation("ZZ", [q, q + 1], 1.0)
for q in range(n):
    step.rx(1.1, q)
for q in range(n):
    step.rz(0.6, q)

circuit = Circuit(n)
for _ in range(steps):
    circuit = circuit + step

observable = PauliSum.from_strings({"Z" + "I" * (n - 1): 1.0}, num_qubits=n)
norm_in = float(np.sum(np.abs(observable.coefficients_array()) ** 2))

for eps in [1e-1, 1e-2, 1e-3, 1e-4, 1e-5]:
    evolved = observable.propagate(circuit, truncation.coeff(eps), direction="heisenberg")
    retained = float(np.sum(np.abs(evolved.coefficients_array()) ** 2)) / norm_in
    print(f"{eps:7.0e}  terms={len(evolved):6d}  <Z0>={evolved.expectation('z+').real:+.9f}  retained={retained:.6f}")
  1e-01  terms=     0  <Z0>=+0.000000000  retained=0.000000
  1e-02  terms=   708  <Z0>=+0.301649751  retained=0.856704
  1e-03  terms=  3466  <Z0>=+0.323737940  retained=0.998545
  1e-04  terms=  4392  <Z0>=+0.324826803  retained=0.999998
  1e-05  terms=  4478  <Z0>=+0.324877392  retained=1.000000

Three things to read off, in order. The 1e-1 row truncated the sum to zero terms and reports 0.0; a zero-term sum is not a converged answer however stable it looks, and the run has to be thrown away rather than quoted. The value then moves by 2.2·10⁻² from 1e-2 to 1e-3, by 1.1·10⁻³ to 1e-4, and by 5.1·10⁻⁵ to 1e-5 — a falling sequence of differences, which is what a converging sweep looks like. The retained norm rounds to 1.000000 at 1e-5, but it is not exactly 1 — that row is one term short of the 4 479 an untruncated run returns (see below) — so read it as “the sweep has bottomed out”, not as “this row is exact”.

The differences are the uncertainty estimate, not the cutoff. Quote the tightest row’s value with the last difference as its error bar, and never quote a value from a cutoff you did not sweep past.

The sweep must be run on the circuit exactly as it will be used — same gate granularity, same noise, same direction — because a gate is a truncation point and changing any of them changes the schedule the sweep is measuring.

Read the retained norm

The retained Hilbert–Schmidt norm is N = Σ_P |c_P|², which coefficients_array() gives directly:

def retained_norm(pauli_sum):
    return float(np.sum(np.abs(pauli_sum.coefficients_array()) ** 2))

N is conserved under exact unitary evolution, so the fraction of the operator a truncated run deleted is 1 − N_out / N_in, measured against the same quantity on the input sum. For a single Pauli seed N_in is 1 and the deleted fraction is just 1 − N_out, which is the form the showcases and benchmarks quote.

Two caveats. A noise channel rescales coefficients by design, so under a noisy circuit N falls whether or not anything was truncated and the diagnostic no longer isolates truncation — see A noise channel is a truncation point too. And a large retained norm does not by itself mean a correct expectation value: the deleted terms carry signs and may be exactly the ones the state projects onto, which is why the norm is read alongside the sweep rather than instead of it.

Apply the plateau criterion

Where no exact reference exists, the sweep is the only reference, and the criterion for accepting one is stricter than “two successive values agree”.

A self-converged value may be quoted only when both of the following hold:

  • the two most recent successive differences are small against the accuracy you need, and
  • either the term count has stopped growing — the sum has saturated, and the plateau is the exact answer — or both of those differences are strictly nonzero.

A flat value on a still-growing sum is rejected: at a small rotation angle, loosening the cutoff by a decade can admit thousands of terms none of which contributes to the state being measured, so the value does not move while the sum keeps growing, and an exactly-zero difference there means “no relevant term has arrived yet”. A sum truncated to zero terms is rejected outright, however flat it looks.

That distinction is not a nicety. The obvious test — tighten the cutoff until two successive values agree to tol — was run against an exact reference at a small kick angle in Benchmark B and declared convergence with an estimated uncertainty of exactly zero while the value was still 5.6·10⁻⁷ from the truth, because at that angle the only terms contributing to ⟨0|O|0⟩ are those rotated all the way to pure Z, and a decade of extra terms contained none. Replacing it with the criterion above is worth a measured 190× in the accuracy of the reported uncertainty, and the benchmark suite imports that criterion as one function object rather than re-implementing it per page.

Truncation has no variational bound, so read a sweep as a trend across the whole grid, never as a point-to-point improvement: a partial sum can sit on either side of the truth and the error need not fall monotonically — see No variational bound. The criterion does not survive every regime either: Benchmark C measures a depth at which a budget-stopped sweep reports a falsely confident uncertainty, and the honest verdict there is “no usable estimate”, not a weaker one.

Score against an exact reference where you can

The sweep estimates its own uncertainty; an exact reference measures the error. Reach for one whenever the problem admits it:

  • Drop the policy entirely. A small system or a shallow circuit may fit untruncated, in which case policy=None is the exact answer and the sweep can be scored against it (Untruncated runs). The sweep above bottoms out at 4 478 terms, one short of the 4 479 that observable.propagate(circuit, None, direction="heisenberg") returns.
  • Restrict to the causal cone. A local observable only spreads as far as its light cone, so the exact problem is often far smaller than the full register.
  • Use a dense simulator. A state-vector reference (qiskit Aer, or a hand-rolled Kronecker construction) is exact to ~26–30 qubits and does not care about depth — see Against other tools.
  • Use stim at a Clifford point. Where the circuit is Clifford, a tableau simulator gives the exact ±1 integer at any qubit count — Benchmark A exists to be scored that way.

An exact reference at a reachable corner of the parameter space also calibrates the sweep at the corners you cannot reach.

A time series is a grid of truncated results and carries the same obligation at every point — Incremental propagation. The term counts and memory behind a sweep are on Stats, memory and logging, and the sweep as a cross-engine convergence panel is what every page under Benchmarks ends with.