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 bloqade-ppvmwheel, so after changing Rust force a rebuild with uv sync --project ppvm-python --reinstall-package bloqade-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. aarch64 Linux gets +aes,+neon the same way (its default target features lack AES; Apple targets already enable it), which the linux-aarch64 release wheel needs.
§ 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/
§ 8Releasing
Releases are automated with release-plzand driven by the Conventional Commit messages on main(§ 4.1). All published crates and thebloqade-ppvm Python package share one version ([workspace.package] in the root Cargo.toml; maturin reads it through ppvm-python-native) and release together under a single v0.x.y tag, one rootCHANGELOG.md and one GitHub release. Configuration lives inrelease-plz.toml; the workflow is.github/workflows/release-plz.yml.
8.1 The normal flow
- Merge PRs to
mainwith conventional-commit titles (PRs are squash-merged, so the title is the commit message). - The
release-plz-prjob keeps a release PR open that bumps the shared version and updatesCHANGELOG.md. Edit it before merging if the version or notes look wrong. - Merging it triggers
release-plz-release: it publishes the crates to crates.io in dependency order, then creates the tag and GitHub release. Because it released something, the same run builds abi3 wheels (linux x86_64/aarch64, macOS x86_64/arm64, windows x86_64) and an sdist, runs the Python test suite against each installed wheel, and publishes to PyPI.
The release PR is opened with the default GITHUB_TOKEN, so CI does not run on it. It only changes versions and the changelog.
8.2 Versions and breaking changes
Pre-1.0, feat:/fix: bump the patch version and a breaking change (feat!:, or a BREAKING CHANGE:footer) bumps the minor version. semver_check runscargo-semver-checks and reports Rust API breaks in the release PR. It cannot see the Python API, so mark Python-facing breaking changes with ! yourself and review the release PR with that in mind.
Python-only changes don't open a release PR.release-plz only tracks the published crates, so commits confined toppvm-python/ or ppvm-python-native are invisible to it. To release them, open a PR that bumpsversion in [workspace.package], updates the internal version = "…" requirements on path dependencies to match (for a minor bump they would otherwise reject the new crates), runs cargo update --workspace to refreshCargo.lock, and adds the notes to CHANGELOG.mdby hand. Once it is merged,release-plz-release sees an unpublished version and runs the full release as above.
8.3 Dry runs and recovery
Running the Release-plz workflow manually (Actions → Run workflow) builds and tests the wheels without publishing anything. Checking publish_pypi on main also publishes the current version to PyPI — use this if the Python half of a release failed after the crates were already published. To preview the next version bump and changelog locally, runmise exec -- release-plz update; it rewrites files in place, so inspect with git diff and then discard.
Published versions can't be deleted or replaced, only yanked (cargo yank --version X.Y.Z <crate>, or "yank" in the PyPI project settings). Fix a bad release with a new patch release.
8.4 Auth
crates.io and PyPI both use OIDC trusted publishing, so no tokens are stored. Each crate's trusted publisher (crates.io → crate settings → Trusted Publishing) and the PyPI publisher point at owner QuEraComputing, repo ppvm, workflowrelease-plz.yml; PyPI additionally uses thepypi environment.
8.5 Adding a published crate
- Inherit
version,edition,licenseandrepositoryfrom the workspace, add adescription, and symlinkLICENSEandNOTICEinto the crate directory (ln -s ../../LICENSE). Internal-only crates getpublish = falseinstead. - In
release-plz.toml, give it a[[package]]entry withversion_group = "workspace"and add it toppvm-cli'schangelog_include. Neither omission fails loudly: the crate would version on its own, or its commits would be missing from the release notes. - Trusted publishing only works for crates that already exist, so a crate owner publishes the first version by hand (
cargo publish -p <crate>) and then configures its trusted publisher.
8.6 First release (one-time)
Until the crates exist on crates.io, release-plz-releasefails on every push to main. Bootstrap 0.1.0 by hand:
- From an up-to-date
main, a crates.io account that will own the crates runscargo loginandcargo publish --workspace, then adds a GitHub team as co-owner of each crate (cargo owner --add github:QuEraComputing:<team> <crate>). - Configure the trusted publisher of each of the 12 crates (§ 8.4).
- Tag the published commit so the next changelog starts from it:
git tag -a v0.1.0 -m v0.1.0 && git push origin v0.1.0, and optionallygh release create v0.1.0 --generate-notes. - Run the workflow manually on
mainwithpublish_pypichecked to publishbloqade-ppvm0.1.0. - Close the stale "release v0.1.0" PR if release-plz opened one before the crates were published.
Found something out of date? Send a PR — this guide is the canonical source for both human and agent contributors.