ppvm / Pauli propagation & tableau simulation
On this page
  1. § 1 Workspace at a glance
  2. § 2 Pick crates by use case
  3. § 3 Setup
  4. § 4 ppvm-pauli-sum
  5. § 5 ppvm-tableau
  6. § 6 ppvm-stim
  7. § 7 ppvm-sym
  8. § 8 stim-parser
  9. § 9 The trait hierarchy
  10. § 10 Truncation strategies
  11. § 11 Next steps
Rust · For library developers

rsQuick Start

For developers who want to embed ppvm in another tool, extend its trait hierarchy, or run heavy benchmark sweeps from native Rust without paying for the Python bridge.

§ 1Workspace at a glance

ppvm is a Cargo workspace of focused crates. A foundation trio — ppvm-traits, ppvm-pauli-word, and ppvm-pauli-sum — provides Pauli propagation; everything else builds on it. Depend on what your workload actually needs — there is no umbrella crate.

ppvm-traits foundation
The trait system: the Config trait that bundles storage / coefficient type / hasher / truncation strategy, the single-qubit Pauli alphabet, the gate & noise trait hierarchy (Clifford, TGate, rotations, depolarizing, loss, …), and the shared ACMap / Trace map impls. Required by every other ppvm crate.
ppvm-pauli-word foundation
Packed Pauli strings: PauliWord, PhasedPauliWord, LossyPauliWord, and PauliPattern. Built on ppvm-traits.
ppvm-pauli-sum backend
The Pauli-propagation engine: the PauliSum<Config> (and LossyPauliSum) type, the truncation strategy types, and the concrete Config bundles (fxhash, indexmap, dashmap, gxhash). The usual entry point for native Pauli propagation.
ppvm-tableau backend
Generalized stabilizer-tableau simulator with non-Clifford support via sparse coefficient tracking. Provides Tableau, GeneralizedTableau, and the SparseVector trait (indexable by usize, u128, or bnum types for > 64 qubits). Optional rayon feature for parallel sampling.
ppvm-stim interop
Parse and execute Stim programs against ppvm-tableau. Re-exports the parser, provides parse_extended, execute, and a sample helper for multi-shot loops. Depends on the Pauli crates, ppvm-tableau, and stim-parser.
ppvm-sym backend
Symbolic (parametric) Pauli propagation. Pauli sums carry coefficients of type Term — polynomials in sines and cosines of symbolic parameters — useful for analytic studies of variational circuits and Trotter schedules.
stim-parser parser
Standalone parser for the Stim circuit format. Use directly if you want to consume Stim programs without pulling in a simulator; ppvm-stim already depends on it.

§ 2Pick crates by use case

  • Pauli propagation only (Heisenberg-picture observables, noisy circuits at scale): ppvm-pauli-sum.
  • Stabilizer / generalized-tableau simulation (Schrödinger picture, mid-circuit measurement, loss): ppvm-pauli-sum + ppvm-tableau.
  • Run Stim programs against the tableau: ppvm-pauli-sum + ppvm-tableau + ppvm-stim.
  • Symbolic / parametric circuits: ppvm-pauli-sum + ppvm-sym.
  • Just parse Stim, no simulator: stim-parser.

§ 3Setup

Requires Rust ≥ 1.83 (edition 2024). Add the crates relevant to your workload — the git dependency form is identical for each:

[dependencies]
ppvm-pauli-sum = { git = "https://github.com/QuEraComputing/ppvm" }
ppvm-tableau   = { git = "https://github.com/QuEraComputing/ppvm" }
ppvm-stim      = { git = "https://github.com/QuEraComputing/ppvm" }
ppvm-sym       = { git = "https://github.com/QuEraComputing/ppvm" }

On x86_64, set RUSTFLAGS="-C target-feature=+aes,+sse2" — gxhash, used by the default Config bundles, needs the AES instruction set. CI sets this automatically. On targets without AES, build with --no-default-features --features=indexmap,ahash to drop gxhash.

§ 4ppvm-pauli-sum — Pauli propagation

PauliSum<T: Config> is generic over a configuration bundle (storage, coefficient type, hasher, truncation strategy). Pick a pre-built config from ppvm_pauli_sum::config:

  • indexmap::ByteFxHashF64<N> — deterministic ordering, f64 coefficients. Good default.
  • dashmap::ByteFxHash<N> — concurrent map for use with rayon.
  • fxhash::Byte<N> — plain HashMap; fastest single-threaded.

N is the number of bytes per Pauli word: N = ceil(n_qubits / 8). Twelve qubits → N = 2.

use ppvm_pauli_sum::prelude::*;

fn main() {
    let mut state: PauliSum<config::indexmap::ByteFxHashF64<1>> =
        PauliSum::builder().n_qubits(2).build();
    state += ("ZZ", 1.0);

    // Circuit is H(0); CNOT(0, 1) — apply in reverse for Heisenberg propagation.
    state.cnot(0, 1);
    state.h(0);

    // <0…0| state |0…0> via a pattern match.
    let zero_state: PauliPattern = "Z?*".into();
    println!("{}", state.trace(&zero_state));
}

The same circuit and the same gate vocabulary the Python bindings sit on top of — every Python call goes through this layer.

§ 5ppvm-tableau — generalized stabilizer tableau

For full-state simulation in the Schrödinger picture, use GeneralizedTableau<T, I, C>. The I parameter is the bitstring index type for the sparse coefficient vector — pick by qubit count:

  • usize — up to ~64 qubits (platform dependent).
  • u128 — up to 128 qubits.
  • bnum::types::U256 / U512 / U1024 — for larger systems.

Using usize past 64 qubits silently overflows — pick the next-larger type if in doubt.

use ppvm_pauli_sum::prelude::*;
use ppvm_tableau::prelude::*;

fn main() {
    // GeneralizedTableau::new takes (n_qubits, coefficient_threshold).
    let mut tab: GeneralizedTableau<config::indexmap::ByteFxHashF64<1>, usize, _>
        = GeneralizedTableau::new(2, 1e-10);

    tab.h(0);
    tab.cnot(0, 1);

    let r0 = tab.measure(0);
    let r1 = tab.measure(1);
    assert_eq!(r0, r1); // GHZ correlation
}

Gates run in forward order here — the tableau is in the Schrödinger picture, not the Heisenberg picture that ppvm-pauli-sum uses. The same trait methods (h, cnot, rx, t, …) apply to both backends; only the picture changes.

Non-Clifford gates (t, u3, rotations) and reset live on the tableau. For mid-circuit measurement, the outcome is a tri-state value (Some(true), Some(false), None for loss) — loss is first-class.

§ 6ppvm-stim — running Stim programs

ppvm-stim parses extended Stim and executes against a GeneralizedTableau. The two-stage pipeline — parse once, reuse the program across shots — matters when sampling at scale.

use ppvm_stim::{parse_extended, sample};
use ppvm_tableau::prelude::*;
use ppvm_pauli_sum::prelude::*;

fn run_shots(src: &str, n_qubits: usize) -> Result<Vec<Vec<Option<bool>>>, ppvm_stim::Error> {
    let prog = parse_extended(src)?;

    // Multi-shot: pass a factory closure — sample reuses the parsed program.
    let shots = sample(&prog, 10_000, || {
        GeneralizedTableau::<config::indexmap::ByteFxHashF64<1>, usize, _>::new(n_qubits, 1e-10)
    })?;
    Ok(shots)
}

For single-shot demos there are also run_string / run_file, but they re-parse on every call — never use them inside a sampling loop.

§ 7ppvm-sym — symbolic propagation

Substitute f64 coefficients for Term — the ppvm-sym polynomial-in-sin/cos representation — to propagate Paulis through parametric circuits without committing to angle values:

use ppvm_pauli_sum::prelude::*;
use ppvm_sym::Term;

fn main() {
    let mut sum = PauliSum::<config::fxhash::Byte<2, Term>>::builder()
        .n_qubits(2)
        .build();
    sum += ("ZZ", Term::from(1.0));

    sum.rz(0, Term::var(0));
    sum.ry(0, Term::var(1));
    sum.cnot(0, 1);
    sum.rx(1, Term::var(1));

    let pat: PauliPattern = "Z?*".into();
    println!("Trace expression: {}", sum.trace(&pat));

    // Evaluate at concrete angles.
    let value = sum.trace(&pat).eval(&[0.3, 0.5]).unwrap();
    println!("Value at (0.3, 0.5): {}", value);
}

The same PauliSum, the same gate trait set — only the coefficient type changes. Term::var(i) introduces a free parameter; gate methods accept anything implementing Into<T::Coeff>. Call .eval(&[...]) on a Term to substitute numeric values at the end.

§ 8stim-parser — standalone Stim parsing

Use stim-parser when you want to consume the Stim circuit format without a simulator — e.g., to translate Stim into your own IR or to lint circuits in CI. ppvm-stim already depends on this crate and re-exports its prelude.

use stim_parser::prelude::*;

let prog = parse_extended(stim_src)?;
for inst in prog.instructions() {
    // walk gates, measurements, annotations…
}

§ 9The trait hierarchy

Gate behaviour is exposed through small, composable traits defined once in ppvm_traits::traits and implemented by every backend. Generic code that takes a T: Clifford + RotationOne bound works against PauliSum and GeneralizedTableau alike:

  • Clifford / CliffordExtensions — Pauli, Hadamard, S, CNOT, CZ, CY, √X, √Y (and their adjoints).
  • TGate, RotationOne, RotationTwo, U3Gate — non-Clifford rotations that branch the Pauli sum.
  • Measure / LossyMeasure — Z-basis measurement, with and without loss-aware outcomes.
  • Depolarizing, PauliError, LossChannel, CorrelatedLossChannel, AmplitudeDamping — noise channels.
  • Reset — ground-state reset (tableau).

Adding a new gate is a matter of implementing the relevant trait for whichever simulator types you care about — see the Developer Guide for the end-to-end recipe.

§ 10Custom truncation strategies

PauliSum takes a truncation policy as part of its Config. Pre-built strategies in ppvm_pauli_sum::strategy cover the common cases — CoefficientThreshold(eps), MaxPauliWeight(w), MaxLossWeight(w), CombinedStrategy(a, b). Pass one through the builder:

use ppvm_pauli_sum::{prelude::*, strategy::CoefficientThreshold};

type State = PauliSum<config::indexmap::ByteFxHashF64<2, CoefficientThreshold>>;

let mut state: State = PauliSum::builder()
    .n_qubits(12)
    .strategy(CoefficientThreshold(1e-6))
    .capacity(400)            // capacity tuning has a large perf impact
    .build();

To roll your own, implement the Strategy trait — which provides capacity (an initial-size hint for the map) and a truncate method that drops entries from the map according to your policy:

use ppvm_traits::traits::strategy::Strategy;

#[derive(Debug, Clone, Copy, Default)]
struct DropTinyHighWeight;

impl Strategy for DropTinyHighWeight {
    fn capacity(&self, n_qubits: usize) -> usize {
        16 * n_qubits
    }

    fn truncate<S, V, H, M, W>(&self, map: &mut M)
    where
        // bounds elided — see ppvm_traits::traits::strategy
    {
        map.retain(|word, coeff| {
            coeff.abs() > 1e-8 || word.weight() <= 8
        });
    }
}

Once the strategy is part of the Config, truncation runs automatically inside the gate methods. You can also call state.truncate() manually at sync points if you want explicit control.

§ 11Next steps

  • Browse the full Rust API reference, grouped by crate and kind.
  • Read examples/symbolic.rs, examples/trotter.rs, and examples/msd.rs in the repository for end-to-end pipelines.
  • If your user-facing surface is Python, you don't have to drop down to Rust — the Python Quick Start covers the common cases.
  • For architecture details (Config-trait generics, dual-map optimisation, where to put new gates), see the Developer Guide.