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=...)anddepolarize2(addr0, addr1, p=...). - Pauli error:
pauli_error(addr, [p_x, p_y, p_z]). - Loss:
loss_channel(addr, p)andcorrelated_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)