ppvm / Pauli propagation & tableau simulation
Sections
  1. § 1 Install
  2. § 2 Stim circuits & sampling
  3. § 3 Generalized Tableau
  4. § 4 Pauli Propagation
  5. § 5 Loss channel details
  6. § 6 Next steps

Generalized Stabilizer Tableau

Overview

GeneralizedTableau simulates quantum circuits in the Schrödinger picture using a generalized stabilizer decomposition.

Clifford operations are tracked efficiently in the stabilizer tableau. Non-Clifford gates (T gates, arbitrary rotations) expand the state into a superposition of stabilizer states, tracked via a sparse coefficient vector. The cost scales exponentially only in the number of non-Clifford gates, making this backend well-suited for circuits with few T gates and many Clifford operations.

Drive it from Python

When you'd rather build the circuit gate-by-gate in Python — or when you need non-Clifford gates the Stim grammar can't express — drive a GeneralizedTableau directly:

docs/examples/tableau_ghz.py tested
from ppvm import GeneralizedTableau

tab = GeneralizedTableau(n_qubits=2, seed=0)
tab.h(0)
tab.cnot(0, 1)

r0 = tab.measure(0)
r1 = tab.measure(1)

print(f"qubit 0: {r0}, qubit 1: {r1}")  # → qubit 0: 1, qubit 1: 1
print("correlated:", r0 == r1)  # → correlated: True

The tableau exposes single- and two-qubit Clifford gates, T and rotation gates for non-Clifford steps, projective Z-basis measurements, and depolarizing / loss / Pauli-error noise channels. See the Python API reference for the full list.

Forking for multiple shots

Measurement mutates the tableau, so running multiple shots requires creating independent copies. Use fork() to clone the quantum state with a fresh RNG:

tab = GeneralizedTableau(n_qubits=2, seed=42)
tab.h(0)
tab.cnot(0, 1)

for shot in range(100):
    t = tab.fork(seed=shot)
    print(t.measure(0), t.measure(1))

To preserve the RNG state exactly (e.g. for checkpointing), use copy.copy() instead.

Stim circuit support

Circuits in Stim format are parsed and prepared once into a StimProgram, then executed:

from ppvm import GeneralizedTableau, StimProgram, sample_stim

prog = StimProgram.parse("""
H 0
CX 0 1
M 0 1
""")

# Single shot:
tab = GeneralizedTableau(n_qubits=2)
results = tab.run(prog)
print(f"Bell state measurement: {results}")

# Many shots — fresh tableau per shot:
shots = sample_stim(prog, n_qubits=2, num_shots=10_000, seed=0)

# .stim file:
prog = StimProgram.from_file("path/to/circuit.stim")
shots = GeneralizedTableau.sample(prog, n_qubits=85, num_shots=100, seed=0)

For the sample-many-shots shortcut you saw in § 2, the sample_stim helper takes care of forking and bookkeeping for you.

Noise and loss

GeneralizedTableau supports the same noise channels as PauliSum:

  • Depolarizing: depolarize1(addr, p=...) and depolarize2(addr0, addr1, p=...).
  • Pauli error: pauli_error(addr, [p_x, p_y, p_z]).
  • Loss: loss_channel(addr, p) and correlated_loss_channel(addr0, addr1, p).

When a qubit is lost, a subsequent measurement returns MeasurementResult.LOST instead of ZERO or ONE. You can check and reset loss state:

tab = GeneralizedTableau(n_qubits=1, seed=0)
tab.loss_channel(0, 1.0)  # deterministic loss

print(tab.is_lost(0))    # True
print(tab.measure(0))    # MeasurementResult.LOST

tab.reset_loss_channel(0)
print(tab.is_lost(0))    # False

The mathematical model behind the loss and reset channels is documented in § 5 Loss channel details.

Coefficient pruning

Each non-Clifford gate doubles the number of terms in the internal coefficient vector. The min_abs_coeff parameter controls pruning of small coefficients:

tab = GeneralizedTableau(n_qubits=4, min_abs_coeff=1e-8)

Mixed states and sampling

A single GeneralizedTableau follows one stochastic trajectory: each noise channel and measurement samples an outcome and collapses the state, so drawing many shots means re-running the whole circuit once per shot.

GeneralizedTableauSum instead represents the full mixed state — a classical, probability-weighted mixture of pure stabilizer states, one branch per error outcome. Noise and loss channels branch the sum rather than sampling from it, so the circuit is built once and any number of shots are then drawn from the resulting distribution by a Sampler.

from ppvm.generalized_tableau_sum import GeneralizedTableauSum

tab = GeneralizedTableauSum(n_qubits=2, seed=0)
tab.h(0)
tab.cnot(0, 1)
tab.depolarize1(0, p=0.05)            # branches the sum into a mixture

sampler = tab.sampler()            # snapshot the current state
shots = sampler.sample_shots(10_000)   # list[list[MeasurementResult]]

The same Clifford, rotation, noise, and loss methods as GeneralizedTableau are available. sampler() snapshots the current state into an independent sampler with its own RNG, so gates applied to the tableau afterwards don't affect it. sample() and sample_shots(n) return MeasurementResult outcomes, while raw_sample() and raw_shots(n) return raw integer codes (0/1/2 for |0>/|1>/lost), which is slightly faster.

Two thresholds bound the size of the mixture: min_abs_coeff prunes small coefficients within each branch (as above), while sum_cutoff drops whole branches whose probability weight falls below it.

tab = GeneralizedTableauSum(n_qubits=4, min_abs_coeff=1e-10, sum_cutoff=1e-8)
On this page
  1. Overview
  2. Drive it from Python
  3. Forking for multi-shot
  4. Stim circuit support
  5. Noise and loss
  6. Coefficient pruning
  7. Mixed states & sampling