You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
fromppvm.generalized_tableauimportMeasurementResultasMtab=GeneralizedTableau(n_qubits=2)
tab.h(0)
tab.cnot(0, 1)
tab.probability([0, 1], [M.ZERO, M.ZERO]) # 0.5, state unchangedtab.probability([1], [M.ONE]) # 0.5, marginal on qubit 1tab.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.
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>
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.
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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #239.
Summary
Adds post-selected (forced-outcome) Z measurement to
GeneralizedTableau, in Rust and Python:project(q, value)valueon qubitqvalue, renormalized, outcome appended to the measurement recordproject_many(targets, values)probability(targets, values)project_manyon a fork); impossible outcomes return0.0Unlike
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.In Rust, outcomes are
booland each method returnsResult<f64, ProjectError>.Scope and errors
GeneralizedTableauSumis not touched.LOSTvalue or a lost target raisesNotImplementedError(ProjectError::QubitLost).project/project_manyraiseValueError(ProjectError::ZeroProbability) and leave the state unchanged;probabilityreturns0.0.IndexError(ProjectError::QubitOutOfRange); mismatched lengths raiseValueError(ProjectError::LengthMismatch). Every target is validated before anything is projected.Zon 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 belowPROJECT_ZERO_TOL = 1e-12count 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.projectcollapses the state with the existingproject_case_a/project_case_bhelpers (the HashMap-based path thatGeneralizedTableauSumalso uses).measuredoes not use them: since #154,measure_with_scratchinlines its own sort-merge projection, which is much faster. This means: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.projectis slower thanmeasure: it builds a HashMap per case-a call, copies the coefficients in case b, and doesn't reuse aMeasureScratchacrossproject_many.Proposed follow-up: split
measure_with_scratchinto "compute probability" and "apply a given outcome" steps, then havemeasuresample the outcome andprojectforce it, so both share the optimized path. That touches the hotmeasurepath tuned in #154, so it needs thestim-circuitsbenchmark 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 andProjectError→ Python exception mapping.ppvm-python/src/ppvm/generalized_tableau.py,_core.pyi: Python API and stubs.skills/ppvm-usage/SKILL.md.Testing
New tests in
crates/ppvm-tableau/tests/project.rs(16) andppvm-python/test/generalized_tableau/test_project.py(21, counting parametrized cases):measure: on a 3-qubit circuit with T, CNOT, and rotation gates, the state afterprojectmatches the state after a seededmeasurethat returned the same outcome, on all 64 Pauli expectations.projectprobabilities and the results ofproject_many/probabilitymatch 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.compute_decomposition. Inverting the outcome passed toproject_case_aorproject_case_b, or disabling the case-a zero check, makes the relevant tests fail.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-filespasses (fmt, clippy, cargo-machete, ruff, ty, hawkeye). The docs page was rendered withastro dev. The fullnpm run buildwasn't run, becauseextract:rustneeds a nightly toolchain via rustup.Note: these local runs used Rust 1.96 (Homebrew);
rust-toolchain.tomlpins 1.98, which CI will use.🤖 Generated with Claude Code