fix(tableau): correlated-loss convention, duplicate batch targets, CZ-block overlap - #205
Open
Roger-luo wants to merge 1 commit into
Open
fix(tableau): correlated-loss convention, duplicate batch targets, CZ-block overlap#205Roger-luo wants to merge 1 commit into
Roger-luo wants to merge 1 commit into
Conversation
…-block overlap
Three defects in the crates the `ppvm` wheel actually ships, found by auditing the
Lean formalization against the Rust and adjudicated with independent
re-derivation. Each is user-visible today through the Python API.
**1. Correlated loss disagreed between backends by a factor of two.** `p[1]` is
the probability that a *named* one of the pair is lost, so P(exactly one lost) is
`2·p[1]` and the survivor scales by `1 − 2·p[1] − p[0]`. That is what the paper
specifies, and what `ppvm-pauli-sum` has always computed — its own test comment
said `(1 - 2*p[1] - p[0])` when the channel landed. `ppvm-tableau`'s trajectory
and `ppvm-tableau-sum`'s mixture instead read the trait's ambiguous "losing
either one qubit" as the *total*, giving `p[1]`. So a user got one answer from
`LossyPauliSum` and half of it from `GeneralizedTableau`:
p LossyPauliSum GeneralizedTableau 2*p[1]
[0.0, 0.3, 0.0] 0.600000 0.298450 0.60 before
[0.2, 0.4, 0.0] 0.800000 0.399300 0.80 before
All three backends now agree with `2·p[1]` across the admissible region,
including the saturating boundary `p[0] + 2·p[1] == 1`.
The wording is the actual root cause, so it is now stated normatively once, on
`ppvm-traits`' `CorrelatedLossChannel`, and every other site cites it — the two
tableau backends, `ppvm-pauli-sum`, `mixins.py`, `paulisum.py` (whose "losing a
single qubit" was the ambiguity that let the split through), and the usage skill,
which mislabelled the triple as a Pauli-error vector `[p_x, p_y, p_z]` rather
than `[p_LL, p_LQ, p_LN]`.
Adds `debug_assert`s for the admissible region `p[0], p[1] >= 0`,
`p[0] + 2·p[1] <= 1`, `p[2] ∈ [0, 1]` (with 1e-9 slack so a saturated triple
like `[1/3, 1/3, _]` is not rejected by rounding). Tests, benches and Python
tests that passed out-of-domain triples are corrected to admissible ones without
changing a single assertion — e.g. `[0.0, 1.0, 0.0]`, which describes a map that
is not completely positive, becomes `[0.0, 0.5, 0.0]`, the same "exactly one lost
every shot" witness inside the domain.
**2. Duplicate qubit indices silently collapsed in batched Cliffords.**
`build_masks` ORs one bit per target, so a repeated index applied the gate once
instead of `k` times. `X 0 0` is legal Stim meaning apply-per-target, so
`run_string("X 0 0\nM 0")` returned `Some(true)` where the truth is
`Some(false)`. Detected now via a popcount against the index count, falling back
to the per-index loop, which conjugates by `G^k` correctly for every family —
XOR-cancelling the mask would be wrong for `s`/`sqrt_x`/`sqrt_y`, where
`S² = Z ≠ I`. The fallback bodies are `#[cold] #[inline(never)]` so the fused
sweeps keep their register budget: the ten bit-plane batch rows measure
0.990–1.008× against `origin/main`.
**3. The fused CZ block corrupted state when pairs overlapped.** `cz_block` and
`cz_block_pairs` assumed disjoint support, which the Lean proves is necessary but
nothing enforced. On `X₀X₁X₂`, `cz_block(0, 1, 2)` returned `+Y₀Y₁Y₂` where the
per-pair loop gives `−Y₀X₁Y₂` — wrong in both bit planes and in the sign. Since
`cz_block(0, 1, n)` is adjacent-pair brickwork, the natural call was the broken
one. Now falls back per pair when `offset < count`; disjoint calls stay bit-for-bit
on the fused kernel.
`cargo test --workspace` green (65 targets), `cargo fmt` clean, and
`pytest ppvm-python/test/` 217 passed against a rebuilt wheel. Benchmarked
against a pristine `origin/main` checkout across 35 rows including the untouched
two-qubit and scalar gates: 0.985–1.021×, all inside the noise floor.
Known follow-up, deliberately not in this PR: `ppvm-pauli-sum`'s impl is
coefficient-generic, and `Coefficient` carries no ordering, so it documents the
admissible region but does not assert it — meaning it still produces negative
coefficients where the tableau backends now raise. The guards are also
`debug_assert`s, so release wheels gain no protection; surfacing this as a
`ValueError` at the binding layer is a separate change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This pull request fixes three user-visible correctness defects in the legacy crates that back the shipped ppvm Python wheel: it standardizes the correlated-loss p[1] convention across backends, ensures batched Clifford gates respect duplicate targets, and makes fused CZ-block helpers correct (via fallback) when CZ pair supports overlap.
Changes:
- Define and propagate a single normative correlated-loss convention (
p[1]is per-named-qubit loss), align tableau trajectory + mixture weighting to2*p[1], and add admissible-regiondebug_asserts plus cross-backend regression tests. - Detect repeated indices in batched single-qubit Clifford layers and fall back to per-index loops to apply
G^kcorrectly. - Prevent incorrect fused CZ-block behavior on overlapping pairs by adding preconditions + routing overlapping cases to a per-pair fallback, with new regression tests.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| skills/ppvm-usage/SKILL.md | Updates usage guidance to label correlated-loss parameters as [p_LL, p_LQ, p_LN]. |
| ppvm-python/test/generalized_tableau/test_loss.py | Fixes tests to use admissible correlated-loss triples and adds a cross-backend agreement test vs LossyPauliSum. |
| ppvm-python/src/ppvm/paulisum.py | Clarifies the correlated-loss docstring, especially the normative 2*p[1] interpretation. |
| ppvm-python/src/ppvm/mixins.py | Aligns Python API documentation with the normative p[1] convention and documents the admissible region. |
| crates/ppvm-traits/src/traits/noise.rs | Establishes the normative correlated-loss p[1] definition in one place for all backends/bindings to reference. |
| crates/ppvm-tableau/src/noise.rs | Updates trajectory sampling logic to weight the exactly-one-loss event as 2*p[1], adds admissible-region checking, and strengthens tests. |
| crates/ppvm-tableau/src/gates/clifford.rs | Adds repeated-target detection in mask building and an outlined per-index fallback path for batched Clifford gates, plus regression tests. |
| crates/ppvm-tableau/src/data.rs | Enforces/records CZ-block disjointness preconditions for the fused kernel and falls back to per-pair CZ when overlaps are possible. |
| crates/ppvm-tableau/benches/micro.rs | Adjusts correlated-loss benchmark parameters to stay inside the admissible region under the clarified convention. |
| crates/ppvm-tableau-sum/tests/sampler_vs_pure.rs | Updates mixture-vs-trajectory tests to use the normative p[1] convention and admissible triples. |
| crates/ppvm-tableau-sum/src/noise.rs | Fixes mixture branch weights for correlated loss to use p[1] per single-loss branch and survivor weight 1 - p[0] - 2*p[1], with new tests. |
| crates/ppvm-pauli-sum/src/sum/noise.rs | Updates documentation to explicitly reference the trait’s normative correlated-loss convention and document the admissible region behavior. |
Suppressed comments (1)
crates/ppvm-tableau/src/gates/clifford.rs:1714
- This comment repeats the same incorrect statement that the listed gates are “identity” under repeated application;
S²,(√X)², and(√Y)²are non-identity Paulis. Adjust wording so the rationale for falling back on repeated targets remains technically correct.
// *distinct* site. `Gᵏ` is the identity only for the involutory gates
// (`S² = Z`, `(√X)² = X`, `(√Y)² = Y`), so neither one bit nor an
// XOR-cancelled bit is right for every family.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+371
to
+373
| /// `Gᵏ`. `Gᵏ` is the identity only for the involutory gates (`S² = Z`, | ||
| /// `(√X)² = X`, `(√Y)² = Y`), so neither one bit nor an XOR-cancelled bit is | ||
| /// right for every family; only the per-index loop is (G-061). |
|
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Three defects in the crates the
ppvmwheel actually ships. All three are user-visible today through the Python API. Found by auditing the Lean formalization inlean/against the Rust, then adjudicating each with an independent re-derivation before any code changed.Scoped to the legacy crates deliberately, so the diff stays reviewable. The
ppvm-*-2crates have the same three fixes oncodex/traits-2-impl, butppvm-python-nativebinds legacy only (cargo tree -p ppvm-python-nativecontains zero-2crates), so nothing there reaches a user.1. Correlated loss disagreed between backends by a factor of two
p[1]is the probability that a named one of the pair is lost, so P(exactly one lost) is2·p[1]and the survivor scales by1 − 2·p[1] − p[0]. That is what the paper draft specifies (§"Correlated loss": "the probability that both qubits remain … is $1-p_{LL}-2p_{LQ}$"), and whatppvm-pauli-sumhas always computed — its own test comment said(1 - 2*p[1] - p[0])when the channel first landed in #38.ppvm-tableau's trajectory andppvm-tableau-sum's mixture instead read the trait's ambiguous "the probability of losing either one qubit" as the total, givingp[1]. So the same channel gave different answers depending on the backend:pLossyPauliSumGeneralizedTableau2·p[1][0.0, 0.3, 0.0][0.1, 0.2, 0.0][0.2, 0.4, 0.0]After, all three backends agree with
2·p[1]across the admissible region, including the saturating boundaryp[0] + 2·p[1] == 1:p2·p[1][0.0, 0.3, 0.0][0.2, 0.4, 0.0][0.0, 0.5, 0.0]The wording is the actual root cause, so it is now stated normatively in exactly one place —
ppvm-traits'CorrelatedLossChannel— and every other site cites it: both tableau backends,ppvm-pauli-sum,mixins.py,paulisum.py(whose "losing a single qubit" was the ambiguity that let the split through), and the usage skill, which mislabelled the triple as a Pauli-error vector[p_x, p_y, p_z]rather than[p_LL, p_LQ, p_LN].Worth flagging for reviewers: this exact fix was proposed in review on #38 and not applied. A reviewer asked for
p[1]to be documented as "per outcome … the total probability of a single-qubit loss event is2 * p[1]" plus the constraint2*p[1] + p[0] <= 1. Neither landed, the ambiguous wording shipped, and #34 three weeks later implemented the other reading against it.Also adds
debug_asserts for the admissible regionp[0], p[1] >= 0,p[0] + 2·p[1] <= 1,p[2] ∈ [0, 1], with 1e-9 slack so a saturated triple like[1/3, 1/3, _]isn't rejected by rounding. Tests, benches and Python tests that passed out-of-domain triples are corrected to admissible ones without changing a single assertion — e.g.[0.0, 1.0, 0.0], which describes a map that is not completely positive, becomes[0.0, 0.5, 0.0]: the same "exactly one lost every shot" witness, inside the domain.2. Duplicate qubit indices silently collapsed in batched Cliffords
build_masksORs one bit per target, so a repeated index applied the gate once instead ofktimes.X 0 0is legal Stim meaning apply-per-target, so:Now detected with a popcount against the index count, falling back to the per-index loop, which conjugates by
G^kcorrectly for every gate family. XOR-cancelling the mask would be wrong fors/sqrt_x/sqrt_y, whereS² = Z ≠ I.The fallback bodies are
#[cold] #[inline(never)]so the fused sweeps keep their register budget — the ten bit-plane batch rows measure 0.990–1.008× againstorigin/main.3. The fused CZ block corrupted state when pairs overlapped
cz_block/cz_block_pairsassumed disjoint support — which the Lean proves is necessary, but nothing enforced. OnX₀X₁X₂,cz_block(0, 1, 2)returned+Y₀Y₁Y₂where the per-pair loop gives−Y₀X₁Y₂: wrong in both bit planes and in the sign.cz_block(0, 1, n)is adjacent-pair brickwork, so the natural call was the broken one.Now falls back per pair when
offset < count. Disjoint calls stay bit-for-bit on the fused kernel.Verification
cargo test --workspace— 65 targets, zero failurespytest ppvm-python/test/— 217 passed against a rebuilt wheel (staleness of the installed_corewas checked first, so this isn't a false green)cargo fmt --all --checkclean; thecargo clippypre-commit hook passes. The four workspace-wide--all-targetsclippy diagnostics are pre-existing — verified byte-identical against a pristineorigin/maincheckout, none anchored in a line this PR wroteorigin/maincheckout, 35 rows, 0.985–1.021×, all inside the noise floor. Deliberately includes the five bit-plane gates and the untouchedcz/cnot/scalar rows rather than only phase-only gatescargo benchbuilds again (it was panicking on an out-of-domain triple)Known follow-ups, deliberately not in this PR
ppvm-pauli-sum's impl is coefficient-generic andCoefficientcarries no ordering, so it documents the admissible region but cannot assert it — it still produces negative coefficients where the tableau backends now raise.debug_asserts, so release wheels gain no protection, and amaturin developinstall raisesPanicExceptionrather than aValueErrorfrom the binding layer.GeneralizedTableauSum(sum_cutoff=0.0)panics on sampling because branch weights0.5+0.2+0.2+0.1sum to0.9999999999999999, tripping a>= 1 - sum_cutoffassert attableau-sum/src/data.rs:136. Convention-independent — the old weights round identically — and reachable from Python.🤖 Generated with Claude Code