Developer Guide
§ 1Orient yourself
ppvm is a Cargo workspace with a Python wrapper layered on top. The Rust crates are the source of truth; the Python package is a thin PyO3-based binding.
ppvm/
├── crates/
│ ├── ppvm-traits # Trait system, Config bundle, Pauli alphabet, map impls
│ ├── ppvm-pauli-word # Packed Pauli strings: PauliWord, phased, lossy, pattern
│ ├── ppvm-pauli-sum # PauliSum engine, truncation strategy, concrete configs
│ ├── ppvm-tableau # Stabilizer + generalized-tableau simulator
│ ├── ppvm-sym # Symbolic (parametric) Pauli propagation
│ ├── ppvm-stim # Stim program execution against the tableau
│ ├── stim-parser # Standalone parser for the Stim circuit format
│ └── ppvm-python-native # PyO3 bindings, compiled into `ppvm` as `ppvm._core`
├── ppvm-python/ # Python package `ppvm` (maturin: wrapper + `ppvm._core`)
├── docs/ # This documentation site (Astro)
├── examples/ # Rust examples (symbolic.rs, trotter.rs)
└── AGENTS.md # Pointer to this guide
Dependency graph. ppvm-traits is the foundation; ppvm-pauli-word builds on it, andppvm-pauli-sum builds on both. ppvm-tableau,ppvm-sym, and ppvm-stim depend on the Pauli crates. ppvm-stim additionally depends onppvm-tableau and stim-parser.ppvm-python-native depends on ppvm-pauli-sumand ppvm-tableau.
§ 2Build & test
Rust
# All Rust tests
cargo test --workspace
# A single crate
cargo test -p ppvm-tableau
# A single test by name
cargo test -p ppvm-pauli-sum -- test_ghz
# Benchmarks
cargo bench -p ppvm-tableau --bench micro
cargo bench --bench micro -- "gates/single-qubit/h"Rust edition 2024. On x86 the default hasher (gxhash) needs AES/SSE2 target features; this repo sets them for x86_64 in.cargo/config.toml, and CI does the same. On non-x86 hosts, build with--no-default-features --features=indexmap,ahash or similar.
WebAssembly (wasm32)
The whole workspace except ppvm-python-native (a CPython extension, never a wasm target) cross-compiles to browser wasm with no extra flags:
rustup target add wasm32-unknown-unknown
# The simulators, Pauli engine, Stim parser, and top-level `ppvm` crate.
cargo build --target wasm32-unknown-unknown --workspace --exclude ppvm-python-nativeThe build is wasm-clean automatically. Native-only acceleration dependencies — gxhash (AES intrinsics), dashmap →rayon (OS threads), and ahash — live in[target.'cfg(not(target_arch = "wasm32"))'.dependencies]tables, so on wasm they are pruned and the matching features go inert (the code that names those crates is gated with the samenot(target_arch = "wasm32")). The fx64hash configs use native-word [usize; N] storage (u64 on 64-bit, u32 on wasm) since bitvec only implementsBitStore for u64 on 64-bit pointer widths.ppvm-tableau-sum's structural fingerprint falls back fromgxhash to fxhash on wasm.
rand's entropy (rand::make_rng()) has no default source on wasm32-unknown-unknown, so the getrandomwasm_js backend (Web Crypto API) is selected via a--cfg getrandom_backend="wasm_js" rustflag in.cargo/config.toml plus the wasm_js feature inppvm-tableau's wasm-only dependency table — so the JS runtime supplies randomness. There are no OS threads on wasm: the rayonfeature is unavailable, and the Stim parser runs its recursive grammar inline instead of on a dedicated stack thread. The wasm32 buildCI job compiles the workspace for this target on every PR.
Python
# Requires uv (https://docs.astral.sh/uv/)
# The native module compiles automatically via maturin on first run.
uv run --project ppvm-python --group dev pytest ppvm-python/test/
# A single file
uv run --project ppvm-python --group dev pytest ppvm-python/test/test_basics.py
# A single test by name
uv run --project ppvm-python --group dev pytest ppvm-python/test/ -k test_ghzThe compiled ppvm._core is part of the ppvmwheel, so after changing Rust force a rebuild with uv sync --project ppvm-python --reinstall-package ppvm (or maturin develop -m crates/ppvm-python-native/Cargo.toml). The Python project is configured to use uv-managed Python installations, so a freshuv sync avoids linking PyO3 builds against a system Python.
This docs site
The Astro site you're reading lives under docs/. Every build step (rustdoc-JSON extraction, griffe-based Python API extraction, notebook execution, Astro build) is wired intodocs/package.json, so a fresh checkout has one command to remember:
cd docs
npm install # one-time
npm run dev # extract everything, then `astro dev` (port 4321)
npm run build # extract everything, then `astro build` → dist/npm run dev / npm run build chain the three extraction steps in order so the rendered site picks up every public API change automatically. When you're iterating on a single layer, run that step on its own and refresh the already-running astro dev:
| Command | Rebuilds | When to use |
|---|---|---|
npm run extract:rust | src/data/rust-api.json | You changed a public Rust item — trait, struct, doc comment — and want it surfaced on /api/. Needs cargo +nightly. |
npm run extract:python | src/data/python-api.json | You changed a public Python item under ppvm-python/src/ppvm/ and want it surfaced on /api/. Uses griffe via uv. |
npm run extract:notebooks | src/generated/notebooks/* | You edited or added a Jupytext file under docs/notebooks/. Re-executes the notebooks against the current ppvm-python build and embeds outputs. |
npm run extract | All of the above, in order. | Touched several layers at once. |
npm run astro:dev / npm run astro:build | Just Astro. | You're only editing .astro / .css files and trust the existing extractor outputs — fastest loop. |
docs/src/data/ and docs/src/generated/ are both .gitignore'd; the only sources of truth for those files are the extractor scripts, which CI re-runs on every build. Adding a notebook is a single drop-in:docs/notebooks/my_notebook.py (Jupytext-percent format) → npm run extract:notebooks → the Examples landing page picks it up fromsrc/generated/notebooks/index.json.
2.1 Notebook execution & caching
The script behind npm run extract:notebooks lives atdocs/scripts/build-notebooks.py. Per-notebook pipeline:
- Read the Jupytext
percent-format.pyfile and convert to an in-memoryipynb. - Prepend a hidden setup cell that switches matplotlib to the IPython
inlinebackend — without this,plt.show()renders to a buffer that never reaches the cell output and plots are silently dropped. - Execute every code cell via
nbclient. Text output, tracebacks, and matplotlib figures are captured inline; figures are embedded as base64 PNGs. - Drop the hidden setup cell, render to an HTML fragment via
nbconvert'sbasictemplate (no JupyterLab chrome — the site stylesheet themes the.jp-*classes), and sanitise throughbleachagainst an allow-list that permitsdata:image/pngURLs but strips scripts and iframes. - Write
docs/src/generated/notebooks/<slug>.html+<slug>.json(title, ordered headings, language, source path). The Astro routes atdocs/src/pages/examples/index.astroand[slug].astroconsumeindex.json+ the per-slug fragments at build time.
Content-addressed cache. Executing every notebook from scratch on every PR is expensive — the long-running examples can dominate CI. To avoid that, every successful run also writes its output to docs/.notebook-cache/<hash>.{html,json}, keyed by
sha256(CACHE_SCHEMA_VERSION + docs/scripts/build-notebooks.py + notebook source + Cargo.lock + Cargo.toml + crates/*/Cargo.toml + ppvm-python/uv.lock)Hashing the extractor itself means that a change to the rendering / sanitiser / matplotlib-setup logic invalidates every cached entry automatically — without that, a tweak to the bleach allow-list would silently keep serving the previous HTML for every unchanged notebook source. TheCACHE_SCHEMA_VERSION constant at the top of the script is an explicit global invalidation knob for changes the hash can't see (e.g. a new field in the sidecar JSON that downstream Astro pages start depending on).
On the next run the script restores from the cache when the hash matches and only re-executes notebooks whose fingerprint changed. CI persists the directory via actions/cachekeyed on the same set of files (see the "Restore executed-notebook cache" step in.github/workflows/docs.yml), so a docs-only PR that touches only .astro or.css hits the cache for every notebook and the build takes seconds.
What the fingerprint deliberately does notinclude: Rust .rs sources and Python package sources. Hashing every workspace file would force a re-execution on any cosmetic edit, which is exactly the cost we want to avoid. The tradeoff is that a numerical change inside a Rust crate without a dependency or Cargo.toml bump won't invalidate cached notebook outputs on a docs-only PR — rely on the standard test suites (cargo test --workspace,pytest) to catch those. (A scheduled full-rebuild workflow as a second safety net would be a reasonable future addition, but none exists today; bumpCACHE_SCHEMA_VERSION manually if you ever need to force a global re-execution.)
Override knobs (mostly for debugging):
PPVM_NOTEBOOK_CACHE=0— force re-execution of every notebook regardless of cache state (use when investigating suspected numerical drift).PPVM_NOTEBOOK_CACHE_DIR=<path>— point the cache at a non-default directory (CI uses this implicitly via the defaultdocs/.notebook-cache; tweak only if you need to share a cache across worktrees).
Where to look when you need to change this.Adding a new notebook: drop a Jupytext file underdocs/notebooks/ — no extractor change needed. Changing how notebooks render (sanitiser allow-list, matplotlib DPI, output format): docs/scripts/build-notebooks.py— every edit to this file already invalidates the cache via the fingerprint, so no version bump is needed for routine pipeline tweaks. Changing the fingerprint inputs (e.g. another lockfile becomes relevant): edit_shared_fingerprint_files() in that same scriptand the hashFiles(...) argument on the cache step in .github/workflows/docs.yml — those two lists must stay in sync (note thatdocs/scripts/build-notebooks.py itself appears in both), otherwise the GH Actions cache key drifts from the script's per-notebook key and you get either stale outputs or perpetual misses. To force a global invalidation independent of file content (e.g. cached-artefact schema change), bumpCACHE_SCHEMA_VERSION in the script; bump thenotebooks-v1- prefix in the workflow when the GH Actions cache itself needs a clean slate. Changing the Examples landing or per-notebook page chrome: the two.astro files underdocs/src/pages/examples/.
Local prerequisites the scripts assume: node ≥ 20(Astro 5), uv, Rust nightly (rustup toolchain install nightly). The full layout and rationale live indocs/README.md.
Continuous integration
CI lives in .github/workflows/ci.yml and is staged so the cheap, platform-independent checks gate the expensive cross-OS ones:
pre-commit(Linux) runs the fullprekhook suite — rustfmt, clippy,cargo check --workspace --all-targets, ruff, ty, hawkeye, and the file hygiene hooks. Every other jobneeds:it, so a lint or type failure stops the run before any test minutes are spent.rust-testsandpython-tests(Linux) runcargo test --workspaceand thepytestsuites. The pure-Rust crates are platform-agnostic, so Linux is the only OS that runs the full test suites.extension-cross-platform(macOS + Windows) is the only cross-OS job. It builds the PyO3 extension via maturin and runs the extension'spytestsuite. Itneeds: [rust-tests, python-tests], so the macOS/Windows runners only start once Linux is fully green.
Unused dependencies. A cargo-macheteprek hook flags unused crate dependencies across the whole workspace (run from the repo root, cargo-macheterecurses into every member). It is part of the hook suite, so it runs both locally on commit and in CI via the pre-commit job — there is no separate machete CI job. The binary is provisioned by mise(cargo:cargo-machete in mise.toml), so the mise-action step that sets up the other hooks installs it too. Silence a false positive per-crate with[package.metadata.cargo-machete] ignored = […].
Why cross-OS is extension-only. The compiled PyO3 module is the only artifact whose build is OS-sensitive — macOS needs-undefined dynamic_lookup (added byppvm-python-native/build.rs; maturin sets it too), Windows links python3.lib, and Linux needs neither. Building that extension with maturin also compiles ppvm-python-native and its entire dependency tree on the target OS, so a cross-platform compile regression in any crate still surfaces here — without separately runningcargo build for the whole workspace three times.
No global RUSTFLAGS. gxhash's+aes,+sse2 target features are set arch-scoped in.cargo/config.toml(cfg(target_arch = "x86_64")), not as a workflow-wideRUSTFLAGS — those x86 features are invalid on the aarch64macos-latest runner and would fail to compile there. Linux and Windows (x86_64) still pick them up from the config.
§ 3Architecture
ppvm implements two complementary quantum simulation backends. They share a common gate / noise trait hierarchy from ppvm-traits.
3.1 Pauli propagation (ppvm-pauli-sum)
Tracks Pauli operator evolution through circuits in theHeisenberg picture (circuits run backwards). The central type is PauliSum<T: Config>, a dictionary of Pauli strings to coefficients.
Key design patterns — respect these when editing:
- Config-based generics. The
Configtrait bundles Storage, Coefficient, Strategy, Map, and BuildHasher choices at compile time. Implementations live inconfig/(fxhash,indexmap,dashmap,gxhash). Do not introduce runtime dispatch where aConfigbound would do. - Dual-map optimisation.
PauliSummaintains two internal maps (main + auxiliary) and swaps between them during gate propagation to avoid repeated allocations. Any new gate that writes to a fresh map must respect this swap. - Strategy pattern. Truncation policies (
CoefficientThreshold,MaxPauliWeight,MaxLossWeight,CombinedStrategy) decide when small terms are dropped. Call.truncate()to apply. - Backward propagation. Circuits run backwards. To simulate
H(0); CNOT(0,1)in the Heisenberg picture, callstate.cnot(0,1); state.h(0): the CNOT precedes the Hadamard in code.
3.2 Generalized stabilizer tableau (ppvm-tableau)
Full state simulation using stabilizer formalism, extended to handle non-Clifford gates (T, rotations) via stabilizer rank decomposition with sparse coefficient tracking.
Tableau<T: Config>— 2n-row stabilizer / destabilizer tableau (rows0..n= destabilizers,n..2n= stabilizers).GeneralizedTableau<T: Config, IndexType>— extendsTableauwith a sparse coefficient vector for non-Clifford state tracking.IndexTypecan beusize,u128, orbnum::types::U256for large qubit counts.SparseVector<T, I>— stores coefficients indexed by bitstrings. Indices can be large integers (U256, U512, U1024) for simulations beyond 64 qubits.- Stim compatibility. Rust-side Stim support lives in
ppvm_stim(parse_extended,run_string,run_file). Python-side Stim parsing usesStimProgram.parse/StimProgram.from_file. Execute parsed programs withtab.run(prog)or sample many shots withppvm.sample_stim/GeneralizedTableau.sample.
3.3 Trait hierarchy (ppvm-traits/src/traits/)
Gate behaviour is defined via traits reused across both backends:
Clifford/CliffordExtensions— single- and two-qubit Clifford gates.TGate,RotationOne,RotationTwo,U3Gate— non-Clifford gates (branching).Measure/LossyMeasure— Z-basis measurement.Depolarizing,PauliError,LossChannel,CorrelatedLossChannel— noise channels.
§ 4Conventions
4.1 Commit messages
Use Conventional Commits:<type>(<scope>): <description>.
feat(tableau): add correlated loss channel
fix(pauli-sum): handle zero-norm in truncation
test(stim-parser): add fast fuzz/proptest suite
chore: restore lockfile consistency4.2 Code style
- Run
cargo fmt --allbefore committing Rust. - Run
cargo clippy --workspace --all-targets; fix or justify all warnings. - Run
cargo macheteto catch unused dependencies; it's also aprekhook, run on commit and in CI. - Python is formatted with
ruff formatand linted withruff check. - Public Rust items should have doc comments;
cargo doc --no-depsmust build cleanly because the API site is built from rustdoc JSON. - Python docstrings use Google style (griffe parses with
-d google) and are rendered as Markdown viamarked. Use backtick spans for cross-references — not Sphinx/RST syntax:- ✅
`fork`or`GeneralizedTableau.sample` - ❌
:meth:`fork`,:func:`ppvm.sample_stim`— these are never parsed and appear as literal text.
- ✅
4.3 Tests
Add tests in the same crate as the code they cover. Prefer property tests (proptest) for parser and arithmetic changes;stim-parser already has a proptest suite worth modelling new tests on.
§ 5Python bindings
Single mixed wheel. ppvm-python is one maturin package: it bundles the pure-Python wrapper undersrc/ppvm/ together with the PyO3 crate (ppvm-python-native, Rust → cdylib via PyO3 0.29), which maturin compiles and drops in as the privateppvm._core submodule. Users only ever import ppvm.
- Python ≥ 3.10 required (
.python-versionpins 3.12 for dev). The wheel is built against PyO3'sabi3-py310stable ABI, so onecp310-abi3wheel per platform loads on 3.10+. uvmanages the venv and deps and triggers the maturin build onuv sync.ppvm-python/pyproject.tomlsetsbuild-backend = "maturin"with[tool.maturin]manifest-path→ the crate,python-source = "src", andmodule-name = "ppvm._core".- Plain
cargo buildalso links the cdylib (abuild.rsinppvm-python-nativeadds the macOS-undefined dynamic_lookupflag), so the Rust-only workflows work without maturin. - The native module exports 16
PauliSumvariants × 2 (with/without loss) + 32GeneralizedTableauvariants (1–32 qubits) via thecreate_interface!/create_interface_range!macros.
When adding a new method to a Python-facing type, edit the macro invocation in ppvm-python-native so every config variant picks it up; do not hand-write methods for one variant.
§ 6Extending ppvm
Adding a new gate
- Decide which trait it belongs to (
Clifford,RotationOne, etc.) inppvm-traits/src/traits/. - Implement it for
PauliSum<T: Config>inppvm-pauli-sum/src/sum/. - Implement it for
Tableau/GeneralizedTableauinppvm-tableau/src/gates/. - Expose it in
ppvm-python-nativethrough the relevantcreate_interface!macro, and wrap it inppvm-python/src/ppvm/…. - Add tests on both sides and a benchmark if it is on a hot path.
Adding a new noise channel
Follow the pattern ofLossChannel /CorrelatedLossChannel. Implement the trait in ppvm-traits/src/traits/noise.rs, then mirror in ppvm-tableau if it is meaningful in the tableau picture.
Adding a new Config
Create a module under ppvm-pauli-sum/src/config/, implement the Config trait (defined inppvm-traits), and re-export it fromconfig/mod.rs. If it should be exposed to Python, add a variant to the create_interface! macro call.
§ 7Where to look for X
- Pauli arithmetic,
PauliSum crates/ppvm-pauli-sum/src/sum/; word / phase / loss / pattern types incrates/ppvm-pauli-word/src/- Gate & noise traits
crates/ppvm-traits/src/traits/- Truncation strategies (
CoefficientThreshold,MaxPauliWeight, …) crates/ppvm-pauli-sum/src/strategy.rs,crates/ppvm-traits/src/traits/strategy.rsConfigtrait & implementations- trait in
crates/ppvm-traits/src/config.rs; concrete bundles incrates/ppvm-pauli-sum/src/config/ - Stabilizer tableau core (
Tableau,GeneralizedTableau) crates/ppvm-tableau/src/data.rs,tableau_like.rs- Tableau gates
crates/ppvm-tableau/src/gates/- Stim parsing
crates/stim-parser/(parser only) andcrates/ppvm-stim/(execution)- PyO3 bindings & macros
crates/ppvm-python-native/src/- Python wrapper & mixins
ppvm-python/src/ppvm/- Python tests
ppvm-python/test/
Found something out of date? Send a PR — this guide is the canonical source for both human and agent contributors.