Skip to content

feat(tableau): post-selected projection (project, project_many, probability) - #240

Open
jon-wurtz wants to merge 6 commits into
mainfrom
feat/tableau-project
Open

jon-wurtz wants to merge 6 commits into
mainfrom
feat/tableau-project

Conversation

@jon-wurtz

Copy link
Copy Markdown
Contributor

Closes #239.

Summary

Adds post-selected (forced-outcome) Z measurement to GeneralizedTableau, in Rust and Python:

Method Returns State
project(q, value) probability of value on qubit q projected onto value, renormalized, outcome appended to the measurement record
project_many(targets, values) joint probability projected in order; atomic: rolled back if any projection fails
probability(targets, values) joint probability unchanged (runs project_many on a fork); impossible outcomes return 0.0

Unlike measure, no RNG draw is made, so you choose the outcome. This is non-physical, but by the chain rule it gives bitstring and marginal probabilities, P(z) = |⟨z|ψ⟩|², without sampling.

from ppvm.generalized_tableau import MeasurementResult as M

tab = GeneralizedTableau(n_qubits=2)
tab.h(0)
tab.cnot(0, 1)
tab.probability([0, 1], [M.ZERO, M.ZERO])  # 0.5, state unchanged
tab.probability([1], [M.ONE])              # 0.5, marginal on qubit 1
tab.project(0, M.ONE)                      # 0.5; qubit 0 is now |1>

In Rust, outcomes are bool and each method returns Result<f64, ProjectError>.

Scope and errors

  • Noiseless / pure-trajectory only. The methods act on the current trajectory's state; noise and loss channels applied earlier have already been sampled. GeneralizedTableauSum is not touched.
  • Loss is not implemented: a LOST value or a lost target raises NotImplementedError (ProjectError::QubitLost).
  • Zero probability: project / project_many raise ValueError (ProjectError::ZeroProbability) and leave the state unchanged; probability returns 0.0.
  • Bad arguments: an out-of-range or negative target raises IndexError (ProjectError::QubitOutOfRange); mismatched lengths raise ValueError (ProjectError::LengthMismatch). Every target is validated before anything is projected.
  • Precision: when Z on the target is a stabilizer of the frame (case b), the probability is computed exactly from the coefficient weights. Otherwise (case a) it comes from (1 ± ⟨Z⟩)/2, and values below PROJECT_ZERO_TOL = 1e-12 count as zero, since normalizing a noise-sized projection would produce garbage amplitudes.

Warning

The projection uses a different code path from measure, and that should be fixed in a follow-up PR.

project collapses the state with the existing project_case_a / project_case_b helpers (the HashMap-based path that GeneralizedTableauSum also uses). measure does not use them: since #154, measure_with_scratch inlines its own sort-merge projection, which is much faster. This means:

  • There are now two separately maintained projection implementations in ppvm-tableau. They agree to ~1e-9 (tested against all 64 Pauli expectations; see below), but differ in summation order and output coefficient order, and a future fix to one won't reach the other.
  • project is slower than measure: it builds a HashMap per case-a call, copies the coefficients in case b, and doesn't reuse a MeasureScratch across project_many.

Proposed follow-up: split measure_with_scratch into "compute probability" and "apply a given outcome" steps, then have measure sample the outcome and project force it, so both share the optimized path. That touches the hot measure path tuned in #154, so it needs the stim-circuits benchmark and the RNG-draw-order guarantees rechecked, which is why it isn't in this PR.

Changes

  • crates/ppvm-tableau/src/measure.rs: project, project_many, probability, ProjectError, PROJECT_ZERO_TOL (re-exported from the prelude).
  • crates/ppvm-python-native/src/interface_tableau.rs: PyO3 bindings and ProjectError → Python exception mapping.
  • ppvm-python/src/ppvm/generalized_tableau.py, _core.pyi: Python API and stubs.
  • Docs: new "Post-selection and bitstring probabilities" section in the Python tableau quickstart; method-table rows and a usage note in skills/ppvm-usage/SKILL.md.

Testing

New tests in crates/ppvm-tableau/tests/project.rs (16) and ppvm-python/test/generalized_tableau/test_project.py (21, counting parametrized cases):

  • Agreement with measure: on a 3-qubit circuit with T, CNOT, and rotation gates, the state after project matches the state after a seeded measure that returned the same outcome, on all 64 Pauli expectations.
  • Chain rule: for every bitstring, the product of project probabilities and the results of project_many / probability match an independent reference, P(b) = 2⁻ⁿ Σ_T (−1)^{b·T} ⟨Z_T⟩; the totals sum to 1, and the result doesn't depend on qubit order.
  • Both projection paths covered: the tests check which case each projection takes via compute_decomposition. Inverting the outcome passed to project_case_a or project_case_b, or disabling the case-a zero check, makes the relevant tests fail.
  • Edge cases: zero probability in both cases (state, coefficients, expectations, and record unchanged), loss, out-of-range and negative indices, atomic rollback, order-independent errors, and a sub-tolerance probability (rx(1e-6), P ≈ 2.5e-13) computed exactly.

Local runs: cargo test --workspace (1157 passed) and the full Python suite (242 passed). prek run --all-files passes (fmt, clippy, cargo-machete, ruff, ty, hawkeye). The docs page was rendered with astro dev. The full npm run build wasn't run, because extract:rust needs a nightly toolchain via rustup.

Note: these local runs used Rust 1.96 (Homebrew); rust-toolchain.toml pins 1.98, which CI will use.

🤖 Generated with Claude Code

jon-wurtz and others added 4 commits October 6, 2026 12:59
Add GeneralizedTableau::project(addr0, outcome) -> Result<f64, ProjectError>,
a forced-outcome Z measurement that projects the state onto the requested
outcome, appends it to the measurement record, and returns its probability.
Built on the existing compute_overlap_case_{a,b} and project_case_{a,b}
helpers. Lost qubits and zero-probability outcomes return an error and leave
the state unchanged.

Expose it in Python as GeneralizedTableau.project(addr0, value) -> float,
raising NotImplementedError for loss and ValueError for zero probability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split the zero-probability project test into case-b and case-a variants and
assert which measurement path each projection takes via
compute_decomposition. The case-a test uses H then RY(-pi/2), which returns
to |0> while the stabilizer frame still holds X, so P(1) = 0 on the case-a
path. Both tests check that the tableau, coefficients, all Pauli
expectations, and the measurement record are unchanged after the error.
Mirror both scenarios in the Python tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add GeneralizedTableau::project_many(targets, outcomes), which post-selects
each target in order and returns the joint probability. It is atomic: on any
error the state and measurement record are restored. Mismatched lengths
return the new ProjectError::LengthMismatch.

Add GeneralizedTableau::probability(targets, outcomes), which forks the state
and calls project_many on the fork, leaving the state unchanged. Impossible
outcomes return 0.0 instead of an error.

Expose both in Python with the signature (targets, values) and share the
argument validation through _projection_args.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…obability exactly

Address review findings on project / project_many / probability:

- Validate every target (index range, then loss) before any projection,
  clone, or fork. An out-of-range index no longer panics mid-batch and
  leaves project_many half-applied, and a lost target now errors
  regardless of its position in the list. Adds
  ProjectError::QubitOutOfRange, raised as IndexError in Python; the
  Python wrapper also rejects negative indices with IndexError.
- In case b (Z is a stabilizer), compute the probability as the kept
  share of the coefficient norm instead of (1 +/- <Z>)/2, so small
  probabilities are accurate and only exact zeros are refused. Case a
  keeps PROJECT_ZERO_TOL, now documented.
- probability() forks once and projects without a rollback backup.
- Restore the phase debug_assert from measure in the case-b path.
- Drop the duplicate Python length check; the native LengthMismatch
  surfaces as ValueError.

Document the new API in skills/ppvm-usage/SKILL.md and the Python
tableau quickstart, including error behavior, precision limits, and the
pure-trajectory scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 6, 2026 18:30

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋 Thanks for opening your first pull request against PPVM!

A quick note on contribution terms: by submitting this PR you
agree that your contribution is licensed under the
Apache License 2.0
and that you accept the
PPVM Contributor License Agreement.
Please skim those before a maintainer reviews — opening this PR
counts as your acceptance.

A few things that will speed up review:

  • Read CONTRIBUTING.md
    for the workflow, build commands, and style notes.
  • Run prek run --all-files locally; CI runs the same checks.
  • Use Conventional Commits
    for commit messages.

We'll get to your PR as soon as we can. Thanks for contributing!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Case-a probabilities are incorrect after coefficient pruning, and oversized Python indices violate the documented exception contract.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds post-selected Z measurement and probability calculation to GeneralizedTableau across Rust and Python.

Changes:

  • Adds project, project_many, and probability APIs with error handling.
  • Exposes Python bindings, typing, and documentation.
  • Adds comprehensive Rust and Python tests.
File Description
crates/​ppvm-tableau/​src/​measure.rs Implements projection and probability logic.
crates/​ppvm-tableau/​src/​lib.rs Re-exports projection errors and tolerance.
crates/​ppvm-tableau/​tests/​project.rs Tests Rust projection behavior.
crates/​ppvm-python-native/​src/​interface_tableau.rs Adds PyO3 bindings and error mapping.
ppvm-python/​src/​ppvm/​generalized_tableau.py Adds public Python APIs and validation.
ppvm-python/​src/​ppvm/​_core.pyi Adds native method type stubs.
ppvm-python/​test/​generalized_tableau/​test_project.py Tests Python projection behavior.
docs/​src/​pages/​quickstart/​python/​tableau.astro Documents post-selection workflows.
skills/​ppvm-usage/​SKILL.md Adds projection usage guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread crates/ppvm-tableau/src/measure.rs
Comment thread ppvm-python/src/ppvm/generalized_tableau.py Outdated
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://QuEraComputing.github.io/ppvm/pr-preview/pr-240/

Built to branch gh-pages at 2026-10-06 19:06 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

jon-wurtz and others added 2 commits October 6, 2026 14:55
Gate branching prunes coefficients below the threshold without
renormalizing, so the coefficient norm can drop below 1. The case-a path
of project computed (1 +/- z)/2 from the raw overlap z = N<Z>, giving wrong
conditional probabilities (a deterministic outcome could come back below
1). Divide the overlap by the coefficient norm first. Case b already
computed kept/total and was unaffected.

Add a 3-qubit regression test with aggressive pruning (min_abs_coeff =
0.25, norm^2 ~ 0.96) where qubit 0 is |0> on the case-a path; it reported
P(0) = 0.980 before this fix.

Reported by Copilot review on #240.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A target larger than usize::MAX passed the Python non-negativity check
and failed during PyO3 conversion with OverflowError, contradicting the
documented IndexError. Validate 0 <= target < n_qubits in the Python
wrapper before calling the native layer, covering negative, oversized,
and arbitrarily large integers. The native range check remains for Rust
callers.

Reported by Copilot review on #240.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 6, 2026 19:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Case-b projection can leave pruned states unnormalized, and mismatched Python inputs can raise the wrong exception type.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Validate input lengths before converting outcomes

ppvm-python/​src/​ppvm/​generalized_tableau.py:77

Length validation happens only after every value has been converted. Consequently, mismatched inputs such as project_many([], [MeasurementResult.LOST]) raise NotImplementedError before the native LengthMismatch check, although the public contract says all length mismatches raise ValueError. Materialize both iterables and check their lengths before converting outcomes.

std::mem::replace(&mut self.coefficients, C::new())
.into_iter()
.collect();
self.project_case_b(&entries, outcome, phase_decomp, destab_anticomm_bits);

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ Feature ] Post-selected measurement

2 participants