Skip to content

traits-2: rebuild the trait tower, engine, tableau and symbolic ring as ppvm-*-2 - #204

Open
Roger-luo wants to merge 92 commits into
mainfrom
codex/traits-2-impl
Open

traits-2: rebuild the trait tower, engine, tableau and symbolic ring as ppvm-*-2#204
Roger-luo wants to merge 92 commits into
mainfrom
codex/traits-2-impl

Conversation

@Roger-luo

@Roger-luo Roger-luo commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Rebuilds the trait system, propagation engine, stabilizer tableau and symbolic
coefficient ring as a parallel ppvm-*-2 family, then routes every downstream
consumer to it behind a feature flag. Old crates untouched, still the default —
no rename, no deletion.

The stabilizer tableau was then rebuilt a second time, after the first
audit: four square quadrants in one contiguous qubit-contiguous (column-major)
allocation, with an inverse tableau read out of the same arena — Stim's
Tableau / inv_state layout. That is a deliberate trade, and the numbers below
show both sides of it. A Clifford gate touches two columns instead of all 2n
rows (4.9× faster at n=128); a deterministic measurement is one sign read
(surface_d30, 1889 qubits, is 15.9× faster than legacy); and a row fold on
a dense frame now pays a transpose the row-major layout got for free (the
85-qubit MSD sweep is 2.7× slower batched, 7.8× slower measured one qubit
at a time). See "Tableau layout: what it bought, what it cost".

flowchart LR
  T["ppvm-traits-2<br/>traits · leaf types<br/>blanket Clifford"]
  W["ppvm-pauli-word-2<br/>packed PauliWord"]
  L["ppvm-lossy-pauli-word-2<br/>+ loss plane"]
  P["ppvm-phased-pauli-word-2<br/>Phased·W · fused Clifford"]
  S["ppvm-pauli-sum-2<br/>Sum · policies · L4<br/>ColumnStore · IndexMapStore"]
  B["ppvm-tableau-2<br/>Tableau · Generalized<br/>· mixture"]
  Y["ppvm-sym-2<br/>exact / symbolic ring"]
  D["downstream<br/>stim · vihaco · cli · tui<br/>legacy default | traits-2 opt-in"]
  T --> W
  W --> L
  W --> P
  T --> S
  W --> S
  L --> S
  T --> B
  W --> B
  S --> B
  T --> Y
  S --> Y
  S --> D
  B --> D
Loading

ppvm_traits::Config bundled six unrelated axes into one trait, so varying any
one of them meant rewriting an impl Config wholesale. It decomposes into:

flowchart LR
  CFG["Config<br/>one impl per<br/>combination"]
  CFG --> A["Coefficient<br/>ring + magnitude only<br/>(admits exact rings)"]
  CFG --> B["Angle · Halvable<br/>ImaginaryUnit · Conjugate<br/>opt-in capabilities"]
  CFG --> C["Word · PauliBits<br/>Indexable<br/>read / bits / key identity"]
  CFG --> D["Support · Accumulate · Scale<br/>Pair · Multiply<br/>graded layers on the container"]
  CFG --> E["Policy<br/>capacity + truncation"]
  CFG --> F["key_hash<br/>pass-through hasher<br/>(map picks none)"]
Loading

Results — headline workloads

The circuits we actually care about. Measured for this PR, not pulled from the
audit: the propagation rows at 01392466, every tableau row re-measured at
43d6a816 after the column-major rebuild.

PAULI PROPAGATION — Trotter propagation, 10 steps, truncated at 1e-6
same gate list / order / truncation on both engines; supports diffed term-for-term

TFIM      H = J.SUM(Zi Zi+1) + h.SUM(Xi),  observable SUM(Zi)
          RX layer then RZZ layer per step

      n        terms      ppvm-1      ppvm-2   new/old
      8        4,701      3.4 ms      3.0 ms    0.882x  █████████████████████░░░░░
     16       19,529     12.2 ms     10.5 ms    0.861x  ████████████████████░░░░░░
     24       34,353     23.3 ms     21.2 ms    0.910x  ██████████████████████░░░░
     32       49,177     39.6 ms     34.7 ms    0.876x  █████████████████████░░░░░
     40       64,001     60.7 ms     54.3 ms    0.895x  █████████████████████░░░░░
     48       78,825     84.9 ms     74.1 ms    0.873x  █████████████████████░░░░░

HEISENBERG   H = J.SUM(XX+YY+ZZ) + h.SUM(Zi),  autocorrelator
             the branch-heavy model: support grows ~40x faster in n

      n        terms      ppvm-1      ppvm-2   new/old
      6        1,022      2.8 ms      2.8 ms    1.000x  ████████████████████████░░
      8       16,324     46.2 ms     39.3 ms    0.851x  ████████████████████░░░░░░
     10      225,353    465.1 ms    375.4 ms    0.807x  ███████████████████░░░░░░░
     12    1,174,849      2.52 s      1.95 s    0.774x  ██████████████████░░░░░░░░
     14    2,915,879      5.54 s      4.32 s    0.780x  ██████████████████░░░░░░░░

Both propagation rows come from mise run xbench --libs ppvm-1,ppvm-2, which
refuses to report a timing until it has diffed the two engines' propagated
supports term for term — the observable agrees to 10 digits at every width.
Heisenberg is the interesting one: its support grows far faster in n, and the
-2 advantage grows with it, from parity at n=6 to 1.28× faster at n=14
(2.9 M terms).

TABLEAU — Clifford + measurement size sweep, n = 32..128, 3 launches
what the column-major layout is for: one qubit, all generators

                            ppvm-tableau         -2   new/old          range
  Clifford circuit, n=32       2.40 us    2.04 us    0.847x  0.836-0.850  ████████████████████░░░░
  Clifford circuit, n=64       8.60 us    3.40 us    0.421x  0.395-0.426  ██████████░░░░░░░░░░░░░░
  Clifford circuit, n=96      17.12 us    4.91 us    0.286x  0.280-0.287  ███████░░░░░░░░░░░░░░░░░
  Clifford circuit, n=128     29.73 us    6.06 us    0.202x  0.198-0.205  █████░░░░░░░░░░░░░░░░░░░

  measure sweep, n=32          2.52 us    3.19 us    1.251x  1.240-1.265  << SLOWER
  measure sweep, n=64          9.36 us    6.40 us    0.690x  0.684-0.709  █████████████████░░░░░░░
  measure sweep, n=96         19.85 us   10.34 us    0.526x  0.494-0.531  █████████████░░░░░░░░░░░
  measure sweep, n=128        34.52 us   15.11 us    0.438x  0.434-0.441  ███████████░░░░░░░░░░░░░

TABLEAU — 1889-qubit surface-code memory, via ppvm-stim, 3 launches
27870 measurements per shot, 97% of them deterministic

                                legacy   traits-2   new/old          range
  surface_d30, per shot       623.5 ms    39.2 ms    0.063x  0.063-0.063  █░░░░░░░░░░░░░░░░░░░░░░░

TABLEAU — magic state distillation, 85 qubits (5 blocks x 17), 3 launches
the same layout's worst case: one generator, all qubits, on a dense frame

                            ppvm-tableau         -2   new/old          range
  MSD circuit, naive           98.3 us   208.3 us    2.122x  2.11-2.13    << SLOWER
  MSD circuit, fused T         60.3 us    82.4 us    1.365x  1.35-1.38    << SLOWER
  measure_all on MSD state     25.2 us    67.7 us    2.682x  2.67-2.68    << SLOWER
  measure loop on MSD state    25.1 us   197.1 us    7.826x  7.68-7.84    << SLOWER
  noisy shots                 497.5 us   893.1 us    1.795x  1.77-1.80    << SLOWER
  16 T-gates on 85q            49.5 ms    49.8 ms    1.006x  1.000-1.012  ███████████████████████░

TABLEAU — whole STIM programs, via ppvm-stim, 3 launches per backend
cross-build (see below): each row is two separately compiled binaries

                                legacy   traits-2   new/old          range
  cultivation_d5              498.9 us   433.1 us    0.880x  0.858-0.909  █████████████████████░░░
  repetition_code_d3_r3        0.63 us    1.47 us    2.340x  2.30-2.34    << SLOWER
  ghz (4q)                     0.29 us    0.81 us    2.790x  2.76-2.80    << SLOWER
  bell_pair (2q)               0.18 us    0.55 us    2.955x  2.92-3.14    << SLOWER
  feedback_cx                  0.17 us    0.48 us    2.847x  2.83-2.91    << SLOWER

Read honestly, that is one story told four times: the layout is optimal for
"one qubit, all generators" and pessimal for "one generator, all qubits" on a
frame dense enough that gathering the generators costs more than re-orienting
the whole tableau. Which side a workload lands on is decided by its measurement
mix, not its size — surface_d30 is 1889 qubits and 15.9× faster because 97% of
its measurements are deterministic; MSD is 85 qubits and 2.7× slower because its
frame is dense and every measurement projects. How the sweep is called is worth
a factor of three on top of that: batched under one orientation change it is
2.7×, one qubit at a time it is 7.8×, and the two rows are the same measurements
on the same state. The four small STIM programs are
slower for a third reason again — a fixed ~0.4 µs on the first random
measurement — which is 3× on a 2-qubit circuit and invisible past n ≈ 32.

Two provenance caveats, unchanged from before: the MSD circuit is fixed at 85
qubits, so the size sweep is a random Clifford + measurement circuit rather than
MSD at several distillation sizes; and everything routed through ppvm-stim
(surface_d30, cultivation_d5, the four small programs) compiles exactly one
backend per binary, so those rows are cross-build comparisons carrying layout
bias, unlike the paired rows above them.

Tableau layout: what it bought, what it cost

The first -2 tableau kept the old crate's row-major frame, which is why the
original audit showed it at parity on gates and slightly ahead on measurement.
It was then rebuilt on Stim's layout, in four steps, each with its own commit
and its own measurement.

Contiguous column-major storage. Four square quadrants (destabilizer/
stabilizer × X/Z) in one 32-byte-aligned allocation, indexed qubit-contiguously.
A Clifford gate is then a sweep over two majors instead of over all 2n
generators: 218 ns → 23 ns per CNOT at n=128. Because a rectangular bit-matrix
transpose is not an in-place permutation and a square one is, the quadrants are
square — the same reason Stim splits them.

Row-oriented reads gather or transpose, whichever is cheaper. A
generator-contiguous pass either materializes the k generators it needs out of
the column-major arena, or takes a TransposedTableau guard (Stim's
TableauTransposedRaii). blocks::prefer_gather picks. That took surface_d30
from 29 s/shot — the low point of the rewrite — to 224 ms.

The inverse tableau. Forward columns are inverse rows, so no second bit
matrix is needed: x-bit j of U†X_qU is the Z bit of stabilizer j at qubit
q. Only the 2n signs are tracked separately, maintained by prepending each
gate's G† table. That makes the determinism check one sign bit and
compute_decomposition's ℤ/4 fold one sign bit plus a popcount, and it
survives a projection (the elimination is a sequence of Cliffords appended to
the frame, so it conjugates each inverse row by V†). surface_d30 590 → 37 ms.

The transpose is sized to the frame. transpose_block used to run six
shift-mask rounds over all 64 rows however few were populated, so a 2-qubit frame
paid a 64-qubit orientation change. Truncating to e.next_power_of_two() rows is
exact, not a heuristic — a round j ≥ span is the identity on a corner-populated
block. First random measurement on a GHZ chain at n = 2/4/8/16: 1.33/1.34/1.38/
1.39 µs → 541/604/630/781 ns.

One orientation change per sweep, not per qubit. A dense frame that
re-orients on every measurement pays the transpose n times over. The library's
measure_all already held a single guard across the batch, but the conformance
driver's did not — its loop re-drew the legacy compatibility RNG stream per
measurement, which the batched entry points cannot express, so it stayed a
per-qubit loop and measure_all/new was silently the same benchmark as
measure_loop/new. Hoisting just the guard (GeneralizedTableau::with_row_major,
now public for exactly this kind of caller) keeps the stream and drops the MSD
sweep from 201.6 µs to 67.7 µs — level with measure_all_with_scratch. The fused
MSD circuit, which ends in measure_all, went 3.41× → 1.37×.

What it cost, and what is open. A row fold on a dense frame is the one
access pattern this layout cannot serve cheaply, and the 85-qubit MSD state is
exactly that: 0.80 µs per measurement batched, 2.4 µs unbatched, against the
row-major engine's 0.31 µs either way. Attributed with samply,
transpose_quadrants is 69% of the unbatched sweep. Two things are known about
what is left and neither is in this PR:

  • A caller that measures one qubit at a time on a dense frame still pays a
    transpose pair per measurement (7.8× on the MSD loop). Holding the frame
    row-major lazily — until a column-major access actually demands it — would
    amortize that without the caller having to ask.
  • The batched 2.7× wants a column-major prefix-parity scan for the inverse
    sign update, so a dense projection never re-orients at all.
  • On small frames what is left is the allocator, not the transpose: 47% of a
    2-qubit case-a measurement is inside libsystem_malloc, from ~10 Vecs per
    projection that project_inverse already knows how to carve out of a reusable
    arena.

Full derivation, cost models and open questions in
docs/design/tableau-data-structure.md.

Results — full sweep, by engine

Grouped by engine, since the five engines are affected very differently.

828 old/new benchmark pairs, re-audit at e3a37026
(clifford/cy re-measured at 979efe99, after the fix)

  ████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓░░░░░
   590 improved (<0.97x)     170 parity     66 above gate / 0 actionable

                                               new/old   faster <<<                 1.03 gate

PAULI PROPAGATION   ppvm-pauli-sum-2
  noise batch, Z errors                         0.755x   ███████████████████████░░░░░░░░┃░░░░
  clifford batch, CNOT                          0.853x   ██████████████████████████░░░░░┃░░░░
  re-key CNOT sweep                             0.871x   ██████████████████████████░░░░░┃░░░░
  noisy TFIM Trotter, n=12   [0.86-0.90]        0.880x   ██████████████████████████░░░░░┃░░░░
  native RZZ sweep                              0.960x   █████████████████████████████░░┃░░░░
  Trotter, decomposed RZZ   [0.949-0.971]       0.963x   █████████████████████████████░░┃░░░░
  qubit sweep, n=12   [0.997-1.039]             1.025x   ███████████████████████████████┃░░░░
  clifford/cy single gate   [was 1.111x]        0.895x   ████████████████████████████░░░┃░░░░

    ...same engine, storage swapped
  IndexMapStore build                           0.570x   █████████████████░░░░░░░░░░░░░░┃░░░░
  lossy interleaved GHZ n=12   [0.84 / 0.99]    0.840x   █████████████████████████░░░░░░┃░░░░
  ColumnStore rzz / hash   [0.93-0.95]          0.950x   ████████████████████████████░░░┃░░░░
  ColumnStore rx / hash   [0.94-0.97]           0.970x   █████████████████████████████░░┃░░░░
  ColumnStore Trotter / hash   [0.98-0.99]      0.990x   ██████████████████████████████░┃░░░░

WORD LAYER   PauliWord / Lossy / Phased
  pattern contains, lossy                       0.011x   █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░┃░░░░
  pattern contains, ordinary                    0.015x   █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░┃░░░░
  pattern parse, indexed                        0.478x   ██████████████░░░░░░░░░░░░░░░░░┃░░░░
  ordinary sqrt-X   [0.850-0.893]               0.870x   ██████████████████████████░░░░░┃░░░░
  phased CZ                                     0.886x   ███████████████████████████░░░░┃░░░░
  phased CNOT                                   0.980x   █████████████████████████████░░┃░░░░
  phased ZCX                                    0.989x   ██████████████████████████████░┃░░░░
  ordinary CY   [0.998-1.021]                   1.004x   ██████████████████████████████░┃░░░░

BARE TABLEAU   Tableau
  clifford bare/cz_many   [0.971-1.001]         0.991x   ██████████████████████████████░┃░░░░
  clifford bare/cnot_many   [0.971-1.020]       1.002x   ██████████████████████████████░┃░░░░

GENERALIZED TABLEAU   GeneralizedTableau
  branch coalesce, 65,536   [0.648-0.676]       0.670x   ████████████████████░░░░░░░░░░░┃░░░░
  clifford gen./cnot_many   [0.941-1.046]       0.994x   ██████████████████████████████░┃░░░░

TABLEAU MIXTURE   GeneralizedTableauMixture
  noisy build   [0.75-0.76]                     0.755x   ███████████████████████░░░░░░░░┃░░░░
  serial sampling, 128 shots   [0.94-0.95]      0.945x   ████████████████████████████░░░┃░░░░
  parallel 8-branch/16-shot   [0.974-1.045]     1.000x   ██████████████████████████████░┃░░░░

SYMBOLIC RING   ppvm-sym-2
  propagation/clifford   [OPEN, 1.05-1.22]      1.100x   ███████████████████████████████╋█░░░

where a process range is given the bar is drawn at its SLOWEST end

This sweep predates the column-major tableau rebuild. Its five tableau rows
describe the row-major -2 frame, not the shipped one; for the tableau engine
the headline table above supersedes them, and it is not uniformly favourable.
The word, Pauli-propagation, mixture and symbolic rows are unaffected — the
rebuild touched ppvm-tableau-2 only.

With that caveat, every end-to-end workload here is at parity or better in
every engine — Trotter, qubit sweeps, branch coalescing, mixture sampling, lossy
GHZ. One row remains genuinely unexplained, a single-gate nanobenchmark family in
the symbolic ring. The Pauli-propagation clifford/cy row that was open at 1.111× is now
fixed at 0.895× (see the fold below), taking its two rows out of the
above-gate bucket. The remaining 64 are adjudicated non-actionable — identical/no-op paths, diagnostic controls,
or layout-sensitive nanobenchmarks — and the largest of those sit in
generalized-tableau observation helpers, where up to 3.7× is +3–6 ns on a path
that is implementation-identical between old and new. Detail in the last two
folds.

Design principles

Compile-time generic throughout. No trait objects, no runtime dispatch for
storage, hashing or policy. Producers are monomorphized type parameters, never
dyn, so the abstraction compiles to nothing on the hot loop.

Decompose by responsibility, not convenience. Config is removed, not
replaced. Each split off Coefficient is justified by a ring that satisfies the
base trait but cannot satisfy the capability — 0.5·(1+i) leaves ℤ[i], which
is why half and Mul<f64> are gone and Halvable exists.

Follow the algebra, don't invent structure. A Pauli mod phase is a vector in
GF(2)^2n; multiplication is XOR up to a ℤ₄ cocycle; conjugation factors into a
symplectic bit map plus a phase. The traits factor the same way, and the symplectic
sign logic is written exactly once:

flowchart TD
  SC["SymplecticColumns<br/>bit-plane algebra · role-independent"]
  PT["PhaseTrack<br/>Z4 for a word · Z2 + g-rule for a tableau"]
  MK["BlanketClifford<br/>opt-in marker"]
  BL["blanket impl Clifford + CliffordExtensions<br/>single audited copy"]
  SC --> BL
  PT --> BL
  MK --> BL
  BL --> W1["PauliWord"]
  BL --> W2["LossyPauliWord<br/>loss-guarded primitives"]
  BL --> W3["Tableau"]
  PH["Phased·W<br/>opts OUT — hand-written fused impl<br/>reads each bit once"]
  SF["StabilizerFrame<br/>role-exclusive · a word never implements it"]
  SF --> W3
Loading

The map is a graded algebra over C[K]. Capabilities layered by algebraic
strength, each admitted by a distinct property and a distinct consumer:
Support (finite partial function) → Accumulate (module core) → Scale
(module action) → Pair (trace pairing) → Multiply (twisted ring product).
Truncation sits deliberately outside: dropping terms still in the support breaks
module exactness, so it lives on Policy via Retain. The key bound is
Indexable, not Word.

A trait must earn its place. Admitted only when generic code consumes it and
there are multiple implementation families, or when it is an established boundary
implemented by different backends. Rejects Config, PauliSumAlgorithm,
TableauMixtureAlgorithm, TableauStorage, and one-alphabet word subtraits.

Word is read-only. Extent, per-site read, weight, iteration. Gate kernels
work at sub-site granularity and are algebra-specific, so mutation lives on each
algebra's own traits — which is what lets a future normal-ordered fermionic
product implement Word honestly.

The finalized digest is first-class. key_hash() is avalanche-quality and
consumed directly as the map hash. The map has no hasher to choose; the key owns
its algorithm, cache and finalization fold, none of which appear in the contract.

No signature leaks array-of-structs. No &mut (K, C), no &mut [C]; mutation
is whole-map or batch. That one rule is what keeps a structure-of-arrays backend
expressible — and ColumnStore is now built on it with zero trait changes.

Randomness is injected, never owned. An owned generator makes a mixture-branch
clone share a stream — a correctness bug that presents as a statistical one — and
stops a value that claims to be pure state from being reproducible from its own
contents. Ownership belongs at the frontend boundary.

Lean is the specification. The machine-checked identities in lean/PPVM/**
are the oracles: phase cocycle, twisted-product associativity, symplectic
isometries, the frame coordinate expansion (Yoder-2012 Lemma 5), the XOR-relabel
bijection, ℓ¹/ℓ² truncation bounds, the Pauli-channel eigenvalue. Where Lean
adjudicates old wrong, the port follows Lean and records it — Projection
computed c²/2 instead of c/2 and left X/Y untouched where completeness
forces dephasing.

Behaviour preservation is otherwise the prime directive. Where old and the
design disagree on anything observable, old wins and the deviation is documented at
the call site. Hence: gates do not truncate, a zero coefficient survives every
operation, and three distinct tableau cutoff spellings are reproduced verbatim
rather than unified.

Additive, with the gate outside the agent. Old and new compile side by side;
differential tests live in a test-only crate depending on both, so shipped -2
crates never depend on old. cargo build, cargo test and lake build are
authoritative pass/fail.

Verification protocol
  • Lean-oracle property tests in each -2 crate; differential tests against the old
    crates in ppvm-conformance-2 (~34k lines).
  • Ratios are new / old. Improvement below 0.97, parity 0.97–1.03. A regression is
    robust only when the median and the slowest process exceed 1.03 — one
    launch never establishes a regression.
  • Harness: mise run perf-report (benchmarks/perf_regression_report.py) drives
    the matrix, pairs medians, and refuses to classify a one-launch row as robust.
  • Rows surviving the gate then go through the two -Cllvm-args layout controls in
    benchmarks/README.md, because same-source padding-only rebuilds move several of
    these nanobenchmarks by more than the gate width.
  • Downstream: Stim and Vihaco pass all four native feature matrices plus a wasm
    library check. The Python integration is deliberately untouched by this PR
    ppvm-python-native and ppvm-python are byte-for-byte identical to their
    pre-PR state, the extension still compiles the old crates, and there is no
    backend flag. The traits-2 Python cutover is parked on
    codex/traits-2-python-cutover for a separate PR.

Detail in docs/performance-report.md; per-component gap ledgers in docs/log.md.

Regression benchmark inventory — 17 Criterion targets

All in crates/ppvm-conformance-2/benches; each runs the identical workload
against old and new in one process and reports the ratio. {side} = the paired
old/new arms.

Word layer

target groups
pauli_word_bench pauli_word/{product, cnot, key_hash, weight}
lossy_pauli_word_bench lossy_pauli_word/{product, cnot, key_hash, weight, loss_weight}
phased_pauli_word_bench phased_pauli_word/{product, cnot}
word_surface_bench word_surface/{ordinary, lossy, phased, pattern, construct, clone_copy} at 8/64/256 qubits

word_surface covers construction; x_bit/z_bit/get/iter_traverse/weight/
width; set_x_bit/set_z_bit and one-/two-site mutation with clone-then-bits
variants; lossy is_lost/set_lost/clear_loss/set_present/loss_weight/
lossy_present/h_lost_noop; phased add_phase/phase/is_positive/
inner_word/into_parts/from_word_and_phase; cold-cache paths
(new_only_cold_all_components, new_only_cold_first_compute,
new_only_set_x_bit_cold_cache); pattern parse/contains/enumerate_all;
equality, display, hash.

Sum layer

target groups
pauli_sum_bench pauli_sum/{build_batch, clifford_h, clifford_x, clifford_cnot, rotation_rx, pauli_error, scale, overlap}
pauli_sum_surface_bench complete gate/noise surface — see below
pauli_sum_integration pauli_sum/{rekey_cnot, rx, rzz, pauli_error, truncate, truncate_active} on a growing support
pauli_sum_multiply pauli_sum/{multiply_word, multiply_sum} (L4 twisted convolution)
pauli_sum_workloads pauli_sum/{workload_qubit_sweep, workload_random_circuit, workload_trotter_ablation, workload_truncate}
pauli_sum_loss pauli_sum/loss_interleaved_n12 (neutral-atom GHZ)
pauli_sum_column_store column_store/{build, clifford_h, clifford_x, clifford_cnot, rx, rx_growth, scale, reduce, overlap, pauli_error, truncate_threshold, truncate_weight, trotter_tfim_n12}
pauli_sum_indexmap pauli_sum_indexmap/{build, gates, ordered_terms} (Python insertion-order ABI)

pauli_sum_surface_bench:
clifford/{x,y,z,h,s,s_dag,cnot,cz,cy,sqrt_x,sqrt_x_dag,sqrt_y,sqrt_y_dag,cx_alias,zcx_alias,zcy_alias,zcz_alias}
and the clifford_batch/* twins ·
rotation_one/{rx,ry,rz,rot_xy_r} + rotation_one_batch/* ·
rotation_two/rotate_2_generic_xz ·
noise/{pauli_error,two_qubit_pauli_error,depolarize1,depolarize2,amplitude_damping}

  • noise_batch/* ·
    pauli_sum_surface/{construct, add, algebra, inspect, truncate, format, new_only} ·
    algebra/scale · projection.

Tableau layer

target groups
tableau_bench tableau-micro, tableau-attrib/measure-sweep, tableau-integration/{branch-coalesce, fused-tgate, measure-all-msd, msd-85q, noisy-shots, rot2-brickwork, scaling}
tableau_surface_bench tableau-surface/{clifford, construction, measurement, noise, observation, projection, rotation, sparse-amplitudes, display}, each split bare/* vs generalized/*
tableau_mixture_bench mixture/{lifecycle, gate, rotation, measure, reset, noise, scaling, sampler, integration}

tableau_surface_bench covers measure-deterministic/measure-random/
measure_many/measure_all with and without reusable scratch;
compute_decomposition, compute_overlap_case_a, compute_overlap_case_b,
z_expectation, expectation, trace-pattern; cz_block/cz_block_pairs/
cz_block_pairs_cross_word; stabilizer/destabilizer traversal; the amplitude store
(add_or_insert/{hit,miss}, get/{hit,miss}, mul_element_by/{hit,miss},
iteration, clone, equality); measurement record append/overwrite/current;
odd_phase_destabilizer_mask; reset_loss_channel; rotations
one/{rx,ry,rz,rotate_1_x,u3,r_xy} + *_many and two/rotate_2_xz.

tableau_mixture_bench covers lifecycle/{new, new_with_seed, clone, len, is_empty}; scaling/{branches_h, branches_loss, branches_sampler_construction};
sampler/{construction, single_sample, serial_shot_scaling, parallel_branch_shot_scaling, adaptive_sample_shots_128, serial_parallel_crossover}; integration/noisy_build; and
new_only_no_old_twin/* for surface with no old counterpart.

Symbolic layer

target groups
sym_bench sym/{micro_term_add, micro_term_mul, micro_mul_term, micro_prod_mul, micro_eval, exact_multiply, expectation_eval, expectation_propagate, random_circuit, random_circuit_clifford, trace_parametric, truncation_sweep, truncation_sweep_eps}
sym_surface sym/{surface, new_only, api_blocker}

sym_surface covers term construction and arithmetic (add, mul_*, pow,
prod*, promote_*, fold_*_constant, sin_pow, cos_pow, norm_sq),
set_max_sin/set_min_eps, sum accumulation, trace, display, hash, equality,
and old_only/{p0,p1} marking surface the port deliberately does not reproduce.

Plus harness_smoke as the no-op harness sanity target.

Measured results in full

Adjudication of the 75 rows above 1.03 in the 36850446 screening

class rows
fixed (now below 0.97) 14
parity (0.97–1.03) 11
actionable 0
non-actionable (duplicate path, identical/no-op, or layout control) 50

Plus 5 adjacent grouped rows: 3 fixed, 1 parity, 1 non-actionable, 0 robust.

Where the above-gate non-actionable rows sit, by engine

engine rows worst why non-actionable
generalized tableau observation/generalized/{bernoulli, flip_with_prob, overwrite_last_measurement_record} 3.71× identical / no-op path; +2.8–5.9 ns
bare tableau tableau-micro/{scratch_new_x85, msd_measure_single} 3.65× diagnostic amortized controls
generalized tableau noise/generalized/reset_loss_channel 1.22× representation/layout nanobench; +1.2 ns, stride control 1.048× crossing parity
symbolic ring sym/surface/construct/{term_variable, term_constant} 3.01× identical scalar path
word layer clone_copy/256/lossy/clone_{warm,cold} 2.07× required three-cache atomic clone
word layer ordinary/mutate/256/set_{x,z}_bit 1.66× required immediate cache-valid mutation
word layer {ordinary,lossy,phased}/read/256/get, observation/equality 1.29× identical packed read / compare
Pauli propagation truncate/max_{weight,loss_weight}_disabled, workload_truncate/*/max_sentinel 1.62× identical disabled path, ~1–2.6 ns
Pauli propagation inspect/{get, contains_key, contains_key_value} 1.38× identical primitive
symbolic ring sym/surface/propagation/clifford/* 1.14× process-layout crossing (see Still open)

The two tableau/symbolic outliers are the ones worth understanding before trusting
the summary bar: a 3.7× on an operation that is byte-identical between backends is
a placement artifact on a few-nanosecond body, not tableau work getting slower.

Before → after

benchmark at 36850446 final process range
pattern contains, ordinary 2.278× 0.015× 0.014–0.016×
pattern contains, lossy-present 1.916× 0.011× 0.011–0.011×
branch coalesce, 65,536 1.863× 0.670× 0.648–0.676×
ordinary CY 1.399× 1.004× 0.998–1.021×
ordinary √X 1.294× 0.870× 0.850–0.893×
pattern parse, indexed 1.151× 0.478× 0.465–0.492×
PauliSum CNOT batch 1.044× 0.853× 0.845–0.919×
PauliSum Z-noise batch 1.037× 0.755× 0.745–0.800×
decomposed-RZZ Trotter 1.094× 0.963× 0.949–0.971×
n=12 sweep 1.070× 1.025× 0.997–1.039×
native RZZ 0.960× median
direct CNOT 0.871× median
phased CNOT / CX / ZCX — / — / 1.032× 0.980× / 0.985× / 0.989× medians
mixture parallel 8-branch/16-shot 1.017× 1.000× 0.974–1.045×

Backends

ColumnStore (SoA) vs the hash backend, four fresh processes: steady rx
0.94–0.97×, sustained rx growth 0.93–0.96×, from_terms 0.89–0.92×, active
truncate 0.94–0.95×, realistic rx 0.95–0.98×, native rzz 0.93–0.95×, noisy
TFIM Trotter 0.98–0.99×. New hash vs old on the same Trotter: 0.86–0.90×.

Mixture: noisy build 0.75–0.76×, serial 128-shot sampling 0.94–0.95×.
IndexMapStore: build 0.57×, ordered term export 0.73–0.75×, CNOT 0.81×, rotation
0.89–0.94×. Lossy interleaved n=12 GHZ: 0.99× and 0.84× in two processes.

Still open

  • Pauli propagationpauli_sum_surface/clifford/{cy,zcy_alias}: CLOSED,
    1.107× → 0.895× / 0.889×. The earlier pass had the mechanism right and
    verified the wrong thing — it checked that cy_toggles had no call sites, not
    that the per-term re-key closure had been inlined. It had not; nm still
    showed the symbol with one bl per term. A 32-byte PauliWord exceeds the
    AArch64 register-return limit, so out-of-line meant two plane copies to the
    stack, byte read-modify-writes instead of register toggles, a reload
    overlapping those stores (a forwarding stall on the digest's dependency chain)
    and an indirect sret — ~2 ns/term, which is also why "less memory work, still
    slower" was true. Fix: cy becomes #[inline(never)], so one copy of the
    re-key loop exists, the closure has one call site, and LLVM folds it in. Gate
    body byte-identical.
  • Symbolic ringsym/surface/propagation/*, a 9–10 row family at 1.05–1.22× where the old
    side drifts 10%+ run to run. The fixture is two terms in a map sized from the
    symbolic capacity hint 2^(2n−1) (131,072 buckets), so a single-qubit gate is
    3–6 µs of control-byte sweep both engines must do. Needs a better fixture, not
    more launches.
Why PauliWord hashes eagerly (the design says lazy)

The design's §"Lazy hashing and interior mutability" specifies a lazy,
interior-mutable digest and lists "preserving Copy at the expense of correct
lazy caching" as an explicit non-goal. The shipped ordinary word does the
opposite. That is the outcome of five measured steps, not drift:

commit change measured
d2727dd6 Phase 2 ships the design's lazy OnceLock<u64>; Copy dropped as intended key_hash 6.94 ns cold → 0.60 ns warm
a64ef9f4 OnceLock → relaxed-atomic sentinel Once's CAS init path dominated the Clifford re-key loop, where every freshly built key hits the cold init exactly once
53ebc66e warm the branch key's digest one pass earlier rotation_rx 5.5 → 4.9 µs; ratio 1.07 → ~0.99
b68b7e8e stop invalidating when a write doesn't change the bit 1.71× → 1.45× and 4.04× → 2.53× on the two affected word benches
3e43610f ordinary word's digest becomes a plain eager u64, restoring Copy (see caveat)

The mechanism 53ebc66e established is the load-bearing one, and it was
root-caused rather than guessed: the lazy digest fired inside pass 2's entry(),
on the bucket-index critical path, where the finalize mul-chain stalls the
dependent bucket load. The old crate hashes eagerly in its first pass, so its
probe hits a cached u64 and the hashing overlaps other terms' work. Atomic
barriers (arm64 Relaxed emits none — checked in disassembly), hasher rounds
(IdentityBuildHasher and old's FxBuildHasher do an equal number), resize,
allocation and monomorphization were each ruled out with evidence first.

Clone cost is the other half. word-data-structures.md records the lossy
word's atomic clone at about 2.1× a plain-u64 copy at 256 qubits, and
1f6d7e70 had to add a borrowed-source re-key path (the
PauliBits::PREFER_BORROWED_REKEY seam) purely to dodge that atomic clone. An
eager Copy word does not pay it.

Caveat, stated plainly: 3e43610f is the one step in the chain with no
isolated measurement. It bundles the hash change with cold scratch allocation,
inlined gate paths and direct store operations, so no number attributes the win
to the hash alone. What validates the end state is the 828-pair audit above,
which was run on the shipped eager build.

The eager/lazy split across types is deliberate rather than inconsistent: the
ordinary word is a map key whose digest is read for essentially every instance,
so computing it up front removes a stall and buys back Copy; a Tableau is
invalidated by every Clifford gate and read far less often, so it keeps the
lazy sentinel, and the lossy word additionally needs loss-only mutations not to
rehash the X/Z planes.

Follow-up: the code comments were never updated —
ppvm-pauli-word-2/src/data.rs:44-52 still says "lazy AtomicU64" and "Copy is
intentionally dropped", ppvm-tableau-2/src/data.rs:159 repeats it, and
word-data-structures.md still shows hash_cache: OnceLock<u64>. Those are
wrong and should be corrected; the code is right.

Known deviations from the design docs

Recorded so review can weigh them rather than rediscover them. Each is documented
at its call site with the measurement or behaviour that forced it.

  • The graded algebra is unified for one key type, not three. Sum bounds
    S::Key: Word + Indexable, so the tableau mixture and the generalized tableau's
    amplitude vector are specialized types rather than Sum instantiations. The
    amplitude store implements L0/L2/Retain but not Accumulate — a generic
    accumulate_batch over a Vec would be O(m²) on a 65,536-branch state, which is
    exactly what its inline sort-merge exists to avoid.
  • The auxiliary map and scratch buffer are back, relocated from Sum into the
    storage backend. The generic producer/batch path clones every key twice; against
    the old in-place aux-map swap that benchmarked ~4.7× slower on a Clifford gate
    and ~2.4× on rx. Clone copies only the primary support.
  • Gates do not reduce or truncate. Both are caller-driven — the old crate's
    contract.
  • The generic TermBatch key column is a scalar Vec<W>, not W::Column:
    accumulate_batch must be instantiable for the non-Columnar amplitude key. The
    SoA layout is used by ColumnStore's own support. probe_batch probes scalar;
    group prefetching and radix partitioning are not implemented.
  • PauliWord's digest cache is eager and the type is Copy again, where the
    design specifies a lazy cache and explicitly trades Copy away for it. This was
    reversed deliberately and under measurement — see the fold below. What is
    genuinely wrong is only the documentation, which still describes the lazy
    form. LossyPauliWord and Tableau remain lazy sentinel-atomic, on purpose.
  • PauliStorage and HashFinalize are public, where the design made them
    private to the word crate.
  • Cutover is not done. No rename, no deletion; legacy remains the default
    on the Rust frontends, and Python is not wired to -2 at all (see above).
  • main is merged in (as of 93d7f7dc), including the breaking
    fix(stim-parser)!: follow Stim's tag model (fix(stim-parser)!: follow Stim's tag model #186). No textual conflicts, and
    no semantic ones either: the whole workspace builds and 1,922 Rust tests plus
    223 Python tests pass. The merge itself touched only stim-parser and
    ppvm-python-native, neither of which is under a conformance benchmark, so it
    moves no number here.
  • The tableau's dense-frame measurement is a regression, and it ships. The
    column-major layout buys 4.9× on gates and 15.9× on the surface-code memory
    circuit, and costs 2.7× on the 85-qubit MSD measurement sweep — against both
    legacy and the row-major -2 frame the first audit measured. It is a
    property of the layout, not a bug. A caller that measures one qubit at a time
    rather than in a batch pays 7.8× instead, because it re-orients the frame per
    measurement; holding the orientation lazily would close that, and is not in
    this PR. Weigh it against the workload you care about; the fold above says
    which side each one lands on.
  • Benchmark provenance. The headline workloads were measured at 01392466
    and the by-engine sweep at e3a37026; the clifford/cy row alone was
    re-measured at 979efe99, after the fix. That fix does touch a benchmarked
    crate (ppvm-pauli-sum-2), so the rest of the Pauli-propagation numbers were
    taken before it — they are conservative rather than stale: the post-fix
    end-to-end run moved the same direction (rx 0.814×, rzz 0.801×,
    rekey_cnot 0.927×) and no sibling regressed. All tableau rows were
    re-measured at 90902bc6
    , after the column-major rebuild and the
    measurement-sweep guard fix — the two tables above come from one three-launch
    run of tableau_bench, so they are internally consistent. Every other number
    in this PR predates the rebuild and is unaffected by it; surface_d30,
    cultivation_d5 and the four small STIM programs were measured at 43d6a816,
    which the guard fix does not touch (ppvm-stim already batched M).

Roger-luo and others added 30 commits July 16, 2026 23:47
Extend the traits-2 proposal with a batch-first map contract that frames
the branch-staging merge as a hash join with aggregation, specified for
memory prefetching, SIMD, multi-threading, and GPU offload.

- Reframe the merge as build/probe-with-group-by and pin its cost model
  to memory latency at out-of-cache sizes.
- Cite Chen, Ailamaki, Gibbons & Mowry, "Improving Hash Join Performance
  through Prefetching" (ICDE 2004; TODS 2007), as the origin of group and
  software-pipelined prefetching and the cost model the contract serves.
- Add a structure-of-arrays batch contract: KeyBatch/TermBatch split into
  key, precomputed-hash, and coefficient columns; ACMapBatch upsert/probe
  over columns; a KeyColumn capability behind a new Columnar trait that is
  kept separate from the minimal Indexable (no cache/column type leaks
  through the hashing contract).
- Separate term production from insertion so gates emit a TermBatch rather
  than mutating through a per-element closure, enabling prefetch, SIMD,
  radix partitioning, and device offload.
- Document the concrete packed-Pauli/LossyPauliWord plane layout,
  alignment/padding, hash_into agreement, and gather semantics in
  word-data-structures.md, per the docs' layout-lives-with-the-
  representation rule.
- Update the compatibility table, non-goals, and open questions (plane
  granularity and per-target alignment remain open).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndle

Address the Step 2 (type-composition) design gaps surfaced in review:

- Split the Coefficient god-trait: cutoff moves to Policy (value type now
  exposes only magnitude()), sin_cos moves to a separate Angle<C> domain so
  the rotation angle is no longer welded to the coefficient. RotationOne
  gains a defaulted angle parameter (A = C).
- Give SumStorage associated Word/Coeff types, collapsing the engine to
  Sum<S, P> and removing the phantom word parameter.
- Rename OperatorSum -> Sum; reintroduce ergonomics as a defaulted
  type-alias family (PauliSum/LossyPauliSum/FermionSum) that is explicitly
  not load-bearing at the trait level, so it avoids Config's two sins.
- Drop the Policy: Copy bound (contradicted the proposal's own non-goal).
- Document the n_sites width invariant with a debug_assert.
- Update the LossChannel specialization to select on S::Word.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve the Step 3 (Word / Site) design gaps from review:

- Make Word a read-only inspection trait (n_sites, get, weight, iter);
  remove set. This stops Word from overclaiming as the propagation
  interface (the real kernels are sub-site) and lets ordered algebras
  implement it honestly, where in-place positional set is ill-defined.
- Relocate mutation to Pauli-specific traits, decomposed along the
  Clifford = Sp(2n,2) join phase structure:
  - PauliBits: mutable single-vector X/Z access; hosts rotation kernels.
  - SymplecticColumns (role-independent-semantics bit-plane column ops)
    and PhaseTrack (role-dependent Z4-word vs Z2+g-tableau phase),
    kept separate to mirror Sp and its extension.
  - Clifford: blanket impl over the two primitives — one audited copy of
    the symplectic sign logic, shared by PhasedPauliWord and Tableau.
  - StabilizerFrame: role-exclusive tableau ops (measure, row_multiply).
- PauliSum consumes the one-row action pointwise (Clifford re-keys the
  map); word-side Mul / key identity stay inherent.
- Update the compatibility table, trait admission rule, batch-contract
  note, and the word/tableau data-structure docs to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve the Step 4 (Indexable / hashing) design gaps from review:

- Replace `type BuildHasher` with `Indexable::key_hash() -> u64`, the one
  load-bearing value the whole design depends on. Scalar `Hash`, the map's
  hasher, and batch `hash_into` are all defined in terms of it, collapsing
  three implicit contracts into one explicit method with three equations.
- Drop the per-key `BuildHasher`: the direct-digest model leaves the map no
  hasher to choose, so the internal digest algorithm becomes a private
  representation parameter of the concrete key (the `H` in PauliWord<A, H>),
  on the same footing as the storage array `A`.
- Retain HashFinalize but relocate it to a private ppvm-pauli-word helper;
  the per-algorithm/per-width avalanche fold runs inside key_hash(), and the
  public contract is only "key_hash() is well distributed" (property-tested).
- Add the pass-through storage contract: provided SumStorage backends
  consume key_hash() directly via an IdentityBuildHasher, guaranteed rather
  than left to the user, so no second hash re-correlates the folded bits.
- Document that SumStorage's `&Word` (not `&mut Word`) closure signatures are
  the structural half of the key-mutation-invariant enforcement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Recast the storage/ACMap layer from the algebraic structure (Step 5):

- Replace the flat ACMapBase/Iter/AddAssign/Insert/Retain/Consume split
  with layers graded by algebraic strength over the free module C[W]:
  L0 Support, L1 Accumulate+Reduce, L2 Scale, L3 Pair, L4 Multiply.
  The minimum is L0+L1 (a finitely-supported W->C you accumulate and
  reduce). Each layer names a distinct algebraic property and consumer.
- Add L4 Multiply (the group-algebra product / convolution), bounded on
  ComplexCoefficient since the Pauli product injects phase.
- Truncation leaves the algebra: it breaks module exactness, so it is a
  non-algebraic Retain capability consumed by Policy, not part of ACMap.
- Make reduce() first-class: exact-zero pruning is canonicalization to
  finite-support form, run only at finalize after all coefficients for a
  word are accumulated (never inline) — correctness by construction, and
  a bulk stream-compaction each backend specializes.
- Design columnar from day one: two backends (HashStore AoS, ColumnStore
  SoA) over the same graded traits, with four no-AoS-leak rules so the
  SIMD/GPU store is expressible immediately. Each layer maps to a bulk
  data-parallel primitive (scale=vector map, reduce=prefix-sum compaction,
  accumulate=hash-join build, pair=probe, multiply=outer product).
- Unify map_add/map_insert into one SumStorage::apply(producer): Clifford,
  rotation, and multiply are TermProducers feeding accumulate; the Vec
  staging leak is gone, and the Step 6 batch types become the columnar
  spelling of Accumulate/Pair.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve Step 7 (tableau) and unify the tableau side with Pauli propagation:

- Relax the graded algebra from C[Word] to C[Key] over any Indexable key.
  The Word bound leaked Pauli-specificity; C[K] is the free module over any
  index set. Pauli propagation re-adds Word/PauliBits on its own methods.
  This makes PauliSum and the stabilizer mixture the SAME Sum engine:
  PauliSum = Sum<HashStore<PauliWord, C>>, TableauMixture =
  Sum<HashStore<Tableau, C>> (replacing GeneralizedTableauSum). A gate is a
  TermProducer that re-keys each key via the key's own Clifford impl. This is
  the "smallest common factor" the prior iteration deferred.
- Add TermProducer/RekeyProducer with the zero-overhead guarantees the hot
  loop needs: generic (never dyn), #[inline] produce, pre-sized sink, so the
  gate body folds into the accumulate loop with no per-term allocation.
- Add KeyProduct (the L4 key-product capability) and Multiply's bound.
- Commit to the inverse tableau: measurement becomes a row read in the
  gate's column-major orientation, eliminating per-measurement transpose;
  the inverse is a private representation of Tableau (traits unchanged).
- Hoist the RNG out of Tableau: inject at measure(&mut, q, rng); clone is
  pure data, so mixture branches get independent streams (fixes the
  correlated-sampling bug); Tableau is trivially Send + Sync.
- StabilizerFrame holds frame primitives (pivot/row_multiply/canonicalize);
  Measure is the public algorithm built on them.
- State that bulk hash/compare requires canonical orientation, enforced by
  the transposition guard's &mut borrow; budget the non-square transpose's
  square-block padding + scratch rather than assuming it away.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correct two over-designs surfaced while checking the generalized tableau:

- No SumStorage trait and no owned workspace. The reusable auxiliary map was
  an artifact of the old mutate-while-iterate model; the producer/TermBatch
  model reads the map through & and writes into a separate TermSink, so no aux
  map is needed. Bundling one into storage would double every sum's allocation
  and clone it on every mixture branch. `Sum<S: Accumulate, P>` now owns only
  storage + policy + n_sites; `apply` is a thin method producing into a
  transient batch, with reuse an opt-in driver optimization (PauliSum), never
  forced on the frequently-cloned tableau side.
- Backends are the containers themselves. The graded algebra traits are impl'd
  directly on Vec<(K,C)> (small support) and HashMap<K,C,IdentityBuildHasher>
  (large) — no VecStore/HashStore newtypes, only type aliases for readability.
  ColumnStore is the one backend that must be a new struct (SoA planes).
- Add the generalized tableau as the third instantiation: C[Bitstring]. Its
  current SparseVector trait is already a second hand-rolled copy of the
  algebra (add_or_insert = accumulate, retain = Retain, iter = Support); the
  amplitude vector is Sum<Vec<(Bitstring, Complex)>, CoefficientThreshold>,
  Clifford gates hit the frame, non-Clifford gates branch the amplitudes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve the key_hash/Indexable over-requirement surfaced by the Vec backend:

- The universal key bound (Support::Key, all containers) is now just Eq + Clone
  — what a linear-scan Vec needs to find and store a key. It never hashes.
- Indexable (the digest-bearing hash key) is unchanged but required only where
  hashing happens: the HashMap and ColumnStore Accumulate impls, and Columnar.
- Consequence: a Bitstring used only in a Vec-backed GeneralizedTableau is
  Eq + Clone and provides no key_hash() it would never use; PauliWord /
  LossyPauliWord / Tableau, used in hash backends, remain Indexable.
- Relax KeyProduct to Eq + Clone (the multiply's backend adds Indexable).

No new trait; Indexable keeps its meaning, the digest requirement just lands
where the cost is paid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ym bugs

Add a Lean 4 / Mathlib formalization under `lean/` that machine-checks the
`ppvm-traits-2` unifying algebra before the crate is implemented, wired up via
mise (`setup`, `lean-build`, `lean-cache` tasks + elan).

Formalized (all proved, no `sorry`, standard axioms only):
- Pauli phase cocycle from the packed `phase/mul.rs` booleans; `phaseExp` equals
  the matrix-model reference and is a genuine 2-cocycle (single- and n-qubit).
- The single-qubit Pauli group as a real Mathlib `Group`, plus the non-split
  central extension `1 -> Z4 -> P1 -> F2^2 -> 1` and the Pauli group algebra.
- Symplectic space `GF(2)^{2n}`: omega as a `LinearMap.BilinForm` (alternating);
  H/S/CNOT/CZ proved to be `Sp(2n,2)` isometries; Clifford conjugation as signed
  symplectic automorphisms.
- The graded map `C[K]` identified with `Finsupp`/`AddMonoidAlgebra`; `reduce`
  structural, `truncate` not additive; twisted `key_mul` associativity.
- Stabilizer tableau symplectic-basis invariant + Clifford preservation +
  measurement dichotomy; `C[Bitstring]` XOR relabel bijection; rotation branch.
- Truncation L1/L2 bounds + cutoff mismatch; Pauli channel eigenvalue and
  observable orthonormality.

Design doc corrections (each grounded in a theorem): the Clifford/Pauli structure
is a non-split central extension, not `Sp ⋉ phases`; `key_mul` is `i^k` (no
redundant `±`) and only a *projective* (2-cocycle-twisted) group product; the L4
coefficient bound's minimal requirement is a fourth root of unity, not the full
`ComplexCoefficient`.

Fix three bugs the formal spec pinned (cargo tests green):
- ppvm-sym: `Sum x Sum` cross-terms gated on signed `c0 > min_eps` -> `.abs()`.
- ppvm-sym: `One x Sum` never wrote `new_sum` back to `self.inner`.
- ppvm-pauli-word: `AnyPauliOrIdentity` (`[XYZ]?`) matched only `I`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review of the Lean formalization found no soundness bugs but
several theorems thinner than their docstrings/README claimed. This addresses
all of them (build green, no `sorry`, no `native_decide`, axiom-clean).

Faithfulness:
- New PPVM/Pauli/Matrix.lean: genuine 2x2 ℤ[i] (GaussianInt) Pauli matrices;
  `pauliMat_mul` validates `phaseExp` against the real matrix product
  `g(a,b)·g(c,d) = i^k g(a⊕c,b⊕d)`, grounding the analytic `phaseRef` in an
  actual model. Chain: phase/mul.rs booleans = phaseExp = phaseRef = matrix.

De-vacuified / strengthened:
- Frame.lean: `frame_linearIndependent` (the 2n generators are genuinely
  linearly independent — the symplectic-basis crux) and
  `measure_deterministic_iff_xfree`; honest dichotomy docstring.
- Noise.lean: drop the false `Tr(PQ)/2ⁿ` claim (rename to `overlap_single_single`);
  `pauli_channel_eigenvalue_omega` ties `anti` to the real symplectic form;
  `overlap_with_zero_xfree` uses the concrete X-free predicate; remove unused
  instances; narrow `import Mathlib` to targeted imports.
- Pauli.lean: replace the duplicate `omega_eq_zero_of_eq` with the substantive
  `omega_of_ne` (distinct nonidentity Paulis anticommute).
- Bitstring.lean: replace the vacuous `cliffordOnAmplitudes` (id=id) with an
  honest `frame × amplitudes` model.
- Rotation.lean: scope docstrings to "modeled, not derived"; strengthen
  `anticommute_new_key` (distinct from both operands).
- Truncation.lean: honest L2 framing + `l2_bound_normalized`; ∀-quantified
  `cutoff_mismatch`; targeted imports.
- Conjugation.lean: `conjS` order-4 lemmas. GradedMap.lean: `overlap_add_right`.
- README + docstrings: distinguish the refined Pauli kernel from the modeled
  measurement/noise/observable layer; fix `push_neg` deprecation (zero warnings).

Verified by three adversarial review passes converging to no actionable defects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Formalizing L4 exposed a real design limitation: the `Multiply` trait bounded
its coefficient on `ComplexCoefficient` (i.e. `Complex<f64>`), but the Lean proof
`Twisted.tmul_assoc` shows the twisted `key_mul` product is associative using
only a primitive fourth root of unity (`i⁴ = 1`) — no float/complex field needed.
The old bound needlessly excluded exact coefficient rings (`GaussianInt`,
`Complex<Rational>`, cyclotomic integers), which matters because `ppvm-sym`
targets exact/symbolic coefficients.

Design doc changes:
- New minimal `ImaginaryUnit: Coefficient + num::One` capability (a primitive
  fourth root of unity); L4 `Multiply` now bounds on it instead of
  `ComplexCoefficient`.
- Drop the vestigial `Mul<f64>` from the base `Coefficient`: after the `Angle`
  refactor (`sin_cos` returns coefficient-domain amplitudes) nothing scales a
  coefficient by a bare `f64`, and it was the one bound foreclosing exact rings
  (an exact ring can still report a real `magnitude()` for `Policy`). Documented
  in the Coefficient-decomposition prose and the compatibility table.

Lean backing (`PPVM/Pauli/Matrix.lean`): `iU_pow_four` plus a worked example that
the twisted product is associative over the exact ring `ℤ[i]` (`GaussianInt`),
realizing the loosened bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ conjugation

Use the Lean development as the machine-checked harness and close every
design/proof gap surfaced by three iterative audits (5 -> 3 -> 0).

Lean (strengthen the harness):
- GradedMap: add `overlap_comm` (+ `overlap_eq_union_sum`) proving the trace
  pairing is symmetric over a commutative ring, plus the sesquilinear
  `hermitianOverlap` (conjugate symmetry, sesquilinearity, PSD over a
  StarOrderedRing).
- Matrix: add `star_iU` (`conj(i) = -i` on Z[i]) and a `StarRing GaussianInt`
  instance check, realizing the new `Conjugate` capability on an exact ring.

Design doc:
- Add sesquilinear `Pair::hermitian_overlap` + the `Conjugate` (*-ring)
  capability it needs for complex state/amplitude overlaps.
- Fix stale non-split-central-extension vs semidirect-product phrasing and the
  stale `ComplexCoefficient` bound name; scope the extension proof to n=1.
- State `tmul_assoc` needs `[CommRing C]` + i^4=1 (commutativity load-bearing).
- Label overlap<->Tr(PQ)/2^n as the model form; distinguish unconditional
  `l2_bound` from conditional `l1_bound`/`l2_bound_normalized`; add the
  cutoff-mismatch note; distinguish derived relabel bijection from the
  by-construction frame/amplitude split.
- Add accurate Lean citations to rotation, stabilizer-frame, noise, and
  conjugation claims.

Full PPVM Lean library builds clean (2124 jobs, no warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…workflow

Add the additive ppvm-*-2 skeleton and the migration harness.

- docs/design/traits-2-implementation-plan.md: crate map (old->new), 7-phase
  plan (traits-2 -> pauli-word-2 -> pauli-sum-2 -> tableau-2 -> sym-2 ->
  ColumnStore -> cutover), per-component test/bench strategy, and the automated
  refactor workflow (orchestrator-owned log, autonomous-within-crate with
  boundary gates, perf hard-gate + allowlist, impl-first proofs).
- docs/log.md: orchestrator-owned gap log (schema, convergence/routing rules,
  perf-drift allowlist). Phase 0 recorded complete.
- crates/ppvm-traits-2: stub lib (trait modules land in Phase 1).
- crates/ppvm-conformance-2: test-only harness (publish = false) with seeded
  RNG, random Pauli/circuit generators, old-crate constructor, assert_close;
  4 smoke tests; Criterion smoke bench. Wired into the workspace.

Gates green: build --all-targets, test (4/4), clippy -D warnings, fmt --check,
machete, bench.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…riven)

Implement the ppvm-traits-2 trait foundation via the refactor workflow
(wf_0e6bf5cf-951: impl -> review ∥ test -> prove), then orchestrator-verify all
gates and resolve the surfaced gaps.

Crate (8 modules + prelude, 21 Lean-oracle tests):
- coefficient: Coefficient (no Mul<f64>), Angle<C>, and a new `Halvable`
  capability — `half` was split off Coefficient because 0.5·x re-forecloses exact
  rings (0.5·(1+i)∉ℤ[i]), the same escape as Mul<f64>.
- algebra: KeyProduct->(Self,Phase), ImaginaryUnit (i²=−one), Conjugate; Phase
  as a first-class ℤ/4 group (one/compose/inverse/apply).
- word/pauli/gates/graded/batch/hash: Word/Indexable/PauliBits; SymplecticColumns
  /PhaseTrack/StabilizerFrame + blanket `Clifford`; gate/noise traits; graded
  L0–L4 (Support/Accumulate/Scale/Pair{overlap,hermitian_overlap}/Multiply) +
  Retain; batch contract; IdentityHasher pass-through.

Lean (proofs added for newly-encoded invariants; `lake build PPVM` green):
- Conjugation.lean: TwoPauli (𝒫₂) Group + conjCNOTHom/conjCZHom signed
  symplectic automorphisms; conjCNOT_sign/conjCZ_sign pin the ℤ/4 phase delta to
  the Rust kernel (phase/clifford.rs:80,95), anchored to the physical generator
  tables. Fills the CNOT/CZ conjugation-phase gap (Lean previously had H/S only).
- GradedMap.lean: overlap_smul_left/_right — upgrades `overlap` from
  biadditive+symmetric to full C-bilinearity.

Design/plan: record the Halvable split (+ compat table); fix a wrong-direction
`xor_z_col(c,t)` in the blanket-Clifford snippet (authoritative Lean cnotAct is
z_c⊕=z_t; impl already correct at xor_z_col(t,c)); note the completed
SymplecticColumns/PhaseTrack primitive set; cite the new CNOT/CZ + homogeneity
proofs.

log.md: Phase 1 gap table (6 closed, 1 deferred to Phase 5: exact-ring witness).
Workflow: fix convergence (only high/med correctness/impl-friction blocks;
discharged proofs stay resolved) and re-gate fmt after the test agent — the two
loop artifacts behind the reported `escalate`.

Gates: build/clippy/fmt/test -p ppvm-traits-2 green; lake build PPVM green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-parity)

Implement ppvm-pauli-word-2's `PauliWord` via the (now behavioral) refactor
workflow (wf_98ec8b83-228, converged iter 1), then orchestrator-verify all gates,
the differential suite, and the perf gate.

Crate (packed X/Z planes + lazy OnceLock hash; kernels ported from the old crate
to hold hot paths at parity):
- PauliWord impls Word<Site=Pauli>, PauliBits, SymplecticColumns+PhaseTrack
  (⇒ blanket Clifford), KeyProduct (twisted product iᵏ(v⊕w)), Indexable, Columnar.
- Bare word's PhaseTrack is a phase-discarding no-op ⇒ pure Sp(2n,2) bit Clifford,
  matching the old bare-word behavior; the sign is recovered by the phased wrapper
  (Phase 3).

Conformance (differential vs old + Lean-oracle + perf):
- pauli_word_diff.rs (8): construction/get/weight/iter/bits/product/Clifford match
  the old crate; hash-contract + avalanche distribution.
- pauli_word_lean.rs (10): phaseExp == ℤ[i] matrix exponent (exhaustive + random
  n-qubit), cocycle/square/commutation, conjugation tables, symplectic isometries.
- pauli_word_bench.rs: new-vs-old. Perf PASS — product 0.77x (23% faster), cnot
  0.91x, weight 1.00x, key_hash cold/warm 6.94/0.60ns (lazy OnceLock). No drift.

Lean: Matrix.lean tensorPauli/iuPow/prod_iuPow/tensorPauli_mul — grounds the
packed n-qubit phaseExpN against a genuine 2ⁿ×2ⁿ tensor-product ℤ[i] matrix
product, closing the gap Word.lean's scope note had left open. lake build green.

Design: fix stale word-data-structures.md (bare word implements SymplecticColumns
+PhaseTrack with a phase-discarding PhaseTrack); reframe the clifford.rs note.
Workflow: generalized to a component spec via `args` + differential/perf handling
for behavioral components. log.md: Phase-2 PauliWord entry (3 closed, 1 accepted).

Gates: build/clippy/fmt/test (both crates) + machete + lake all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…at-parity)

Per maintainer direction, LossyPauliWord is a distinct concrete Pauli-word impl
in its own crate `ppvm-lossy-pauli-word-2` (depends on + reuses ppvm-pauli-word-2's
PauliStorage/HashFinalize). Implemented via the refactor workflow
(wf_77a58611-48f, converged iter 2), then orchestrator-verified.

Crate (packed X/Z/loss planes, split OnceLock component caches; ported from the
old loss/ module):
- Word<Site=LossySite<Pauli>>, PauliBits (is_lost), SymplecticColumns+PhaseTrack
  (phase-discarding AND loss-guarded: each primitive is a no-op on a lost qubit,
  reproducing the old whole-gate skip and the lost ⇒ (x,z)=(0,0) invariant),
  Indexable, Columnar. Inherent set_lost/clear_loss/loss_weight. No KeyProduct
  (loss breaks the twisted-product group).

Trait fix (blessed): PauliBits supertrait relaxed Word<Site=Pauli> -> Word in
ppvm-traits-2 so a lossy word (Site=LossySite<Pauli>) can implement it;
propagation re-adds Site=Pauli on its own methods. ppvm-pauli-word-2 unaffected
(27 tests still pass). Design doc + plan updated.

Conformance (differential vs old + Lean-oracle + perf): random_lossy_pauli_string
generator; lossy_pauli_word_diff.rs (9), lossy_pauli_word_lean.rs (9), bench.
Perf PASS 5/6: product 0.79x, cnot 0.78x, weight/hash-warm ~1.02x; loss_weight
1.28x allowlisted (cold sub-ns non-per-gate op, see docs/log.md).

Lean: Symplectic.lean loss-guarded Clifford model — cnotActL/czActL, LossInv,
per-primitive xorXColL/xorZColL with *_preserves_loss, and
xorZColL_xorXColL_eq_cnotActL (per-primitive guards compose to the atomic
whole-gate skip). lake build green.

Design: word-data-structures.md loss-guarded-Clifford note + Ord/serde
first-prototype deferral. Workflow: perf-drift is now a hard gate (any
over-threshold regression escalates to the human regardless of severity label).

Gates: build/clippy/fmt/test (all four -2 crates) + machete + lake all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bench artifact

Followed up on the LossyPauliWord perf flag. Findings:
- loss_weight and weight are byte-for-byte verbatim ports over identical
  BitArray<u64> storage; the only structural change (lazy OnceLock caches) does
  not touch these reads.
- At single-u64 width: loss_weight new 811ps vs old 638ps (1.27x), but new
  loss_weight (1 plane) > new weight (759ps, 3 planes) — impossible for the
  computation, so the delta is fixed per-call overhead (code alignment).
- Controlled experiment at [u64; 8] width (8x the popcount work): new/old
  converge to 1.00x for BOTH loss_weight (0.915ns each) and weight (1.567ns).

Conclusion: no structural difference vs the old implementation; the single-word
1.28x is a nanobenchmark alignment artifact, not a regression. Reclassified from
allowlisted-regression to investigated-benign in docs/log.md; perf-drift
allowlist is now empty. Added a caveat note in the bench.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d Clifford

Final Phase-2 component: the phased Pauli word, in its own crate
ppvm-phased-pauli-word-2 (workflow wf_548f135a-90a, converged iter 1), then
orchestrator-verified with a maintainer-chosen perf fix.

Crate: generic Phased<W> = base word + explicit ℤ₄ phase; PhasedPauliWord =
Phased<PauliWord>. Real ℤ₄ phase tracking (recovers the conjugation sign bare
words drop), Word delegated to W, phased product reuses W::key_mul (no phaseExp
duplication), non-indexable. Signed-string parsers, accessors.

Perf fix (maintainer chose fix over allowlist): the blanket Clifford ran the
phase + column primitives separately, reading the inner bits twice → phased cnot
1.84x. Introduced an opt-in marker `BlanketClifford` in ppvm-traits-2 gating the
blanket; PauliWord/LossyPauliWord opt in (keep the single audited blanket), while
Phased<W> carries a hand-written FUSED impl Clifford (read each bit once) →
phased cnot now 0.85x (faster than old), product 1.00x. Signs byte-identical to
the old kernel and the Lean oracles.

Lean: Conjugation.lean conjSdag — the backward S†PS direction the simulator runs
(conjSdag_sign, x∧¬z, S†XS=−Y), proven a group hom with generator tables and
conjS_conjSdag (inverse of forward conjS). S is the sole convention-sensitive
generator; pins the exact delta the code emits. lake green.

Design: BlanketClifford marker + fused-phased-override documented across the
config doc, plan, and word-data-structures.md; crate doc citations fixed to the
public conj*_sign / conjSdag_sign theorems.

Workflow: fixed the perf-drift gate (regex matched the negated "NOT the
design-accepted trade-off"); perf-drift now always escalates to the human.

Phase 2 complete: PauliWord, LossyPauliWord, Phased — all at parity-or-better.
Gates: build/clippy/fmt/test (149) + machete + lake all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aster than old)

First Phase-3 component: the graded Sum<S,P> engine + Clifford propagation
(workflow wf_6325abce-2ed; escalated on a real perf gap, resolved by the
orchestrator + a focused fix), then fully orchestrator-verified.

Crate: graded traits (Support/Accumulate/Scale/Pair/Retain) impl'd on Vec and
HashMap<K,C,IdentityBuildHasher> (in ppvm-traits-2/containers.rs — orphan rule);
Sum<S,P> + apply<TermProducer>; RekeyProducer; policies (NoPolicy/MaxPauliWeight/
CoefficientThreshold/CombinedPolicy + Retain); Clifford for Sum; PauliSum alias.

Clifford correctness: the design's generic `Clifford for Sum` snippet is unsound
for PauliWord keys (the bare word's Clifford is bit-only, drops the ±1 sign). The
impl wraps each key in Phased<PauliWord>, conjugates via the fused sign-tracking
Clifford, and drains the ±1 to the coefficient. Design snippet corrected.

Perf: faster than old everywhere — clifford_h 0.86x, clifford_cnot 0.80x,
build_batch 0.29x (3.5x), scale 0.99x, overlap ~700x faster; the Phase-2
Copy-drop watch item did NOT regress. Two perf wins:
- Fixed X/Y/Z (pure-sign gates) rebuilding the whole map → new SignFlipByKey
  whole-map store capability doing an in-place sign flip → clifford_x 0.77x.
- Cross-cutting: PauliWord hash cache OnceLock<u64> → sentinel AtomicU64 (relaxed);
  eliminates the Once CAS init that dominated the re-key hot loop. Correct racy
  memoization of a pure digest; design "Lazy hashing" section updated.

Lean added (lake green, all non-vacuous): IsRealPhase (Phase.lean); *Act_involutive
/*_bijective (Symplectic.lean — the re-key bijection / no-collision invariant);
conj*_isRealPhase (Conjugation.lean — Clifford never emits i, ±1 drain total);
overlap_eq_fintype_sum + clifford_conjugation_preserves_overlap (GradedMap.lean —
Heisenberg re-key preserves the Hilbert–Schmidt pairing).

Deferred to later Phase-3 components: rotations/noise producers, L4 Multiply,
columnar ColumnStore. Gates: build/clippy/fmt/test (186) + machete + lake green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ diagonal channel)

Second Phase-3 component: RotationOne (rx/ry/rz) and PauliError (pauli_error) on
PauliSum<f64> (workflow wf_1f05f18f-2e7 — which FAILED on a transient infra error,
a reporting agent hitting the StructuredOutput retry cap; the code + tests were
complete on disk, so the orchestrator verified, fixed a bug, and finished).

- RotationOne: the non-Clifford BRANCH (c·P → c·cos·P + c·sin·(iGP) when {G,P}=0).
  Implemented as a fused single-pass RotateInPlace store capability — scale each
  diagonal where it sits, hash/merge only the ≤N branch terms — restoring the old
  crate's map_insert instead of the batch round-trip. This is the first component
  to exercise the collision-merge path (a branch key can land on an existing term).
- PauliError: the unital diagonal channel, an in-place per-term scale by the
  transfer eigenvalue λ_P = 1 − 2·Σ_{anti} p_Q via ScaleByKey.

Correctness fix (orchestrator): pauli_error with a zero eigenvalue (reachable,
[0,0.25,0.25]→λ_X=0) left a phantom zero-coefficient key, violating the reduced-
canonical-form invariant. ScaleByKey now drops any term scaled to exactly zero
(both backends); added a regression test.

Perf (orchestrator stable re-bench): rotation_rx 1.14x (at parity via RotateInPlace;
the failed run's 2.42x was a noisy batch-path measurement), pauli_error 0.89x.

Lean added (lake green): Rotation.lean branchExp/branchExp_isRealPhase +
rx/ry/rz_eps_from_product (the branch ±1 sign is real — iGP's i cancels the
product's i — with the exact per-axis ε); Conjugation.lean conjX/conjY/conjZ
(the pure-sign X/Y/Z conjugation witnessing the in-place fast-path signs).

Deferred to component 3: L4 Multiply. Gates: build/clippy/fmt/test + machete + lake green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The earlier entry mischaracterized the rotation perf. Corrected:
- The failed run's 2.42x was NOT noise — it was a real measurement of the batch/
  apply path, which the impl replaced with the fused RotateInPlace single-pass.
- The residual fused path is ~1.15-1.17x (new ~5.84us vs old ~5.0us), OVER the
  1.15x gate — not "at parity" as first written.
- Attribution verified: the iter_batched_ref per-iteration clone is symmetric
  (new-sum clone 1.36us ~ old-sum clone 1.41us), so it is genuine branch-path rx
  cost (per anticommuting term: key clone + set_bit + cold key_hash + hashmap
  merge), not a bench artifact.
- Status set to open perf-drift, pending maintainer decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1.16x)

Followed the maintainer's "optimize" decision on the rotation_rx residual.
Attribution (instrumented phase timing, release): the per-call branches Vec alloc
is negligible (67ns); cost splits ~evenly across branch-key build (pass 1) and
hashmap merge (pass 2). The one cheap lever: the branch build did clone() (which
loads the source's cached digest) then set_x_bit (which stores HASH_UNCACHED) — a
wasted atomic load+store per branch key, since the branch key is rehashed anyway.

Added PauliWord::with_bits_toggled (copy the plane words, flip the X/Z bit, leave
the hash uncached) and route rx/ry/rz through it. new/rx ~5.84us -> ~5.65us,
ratio ~1.19x -> ~1.16x. Correctness preserved (rotation differential + branch
tables green).

The residual (~1.16x, at the gate boundary) is inherent: the dominant remaining
cost is the cold key_hash finalization fold on each freshly-created branch key,
which the old Copy/eager-hash word pays a bit more cheaply — not removable without
changing the shared avalanche-tested hash.

Gates: build/clippy/fmt/test (108) green; lossy/phased dependents still build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pushed deeper on rotation_rx per the maintainer's choice, then allowlisted:
- Ruled out empirically: per-call Vec alloc (67ns, negligible) and a mid-merge
  hashbrown resize (pre-reserve gave no change).
- key_hash compute is already tight (3 fxhash rounds + fold, shared with old);
  the only new-specific overhead is the small lazy AtomicU64 load/store.
- The one bigger lever (Zobrist/incremental hash — the branch key differs from
  the diagonal by 1-2 bits) is a large, risky rewrite of the SHARED
  avalanche-tested hash for a boundary-case ~1.16x on one gate type — not
  justified.

Resolved: cheap win captured (with_bits_toggled, committed earlier); residual
~1.13-1.16x allowlisted as the lazy-hash/Copy-drop cost the same design pays back
many-fold elsewhere (Clifford 0.80x, build 0.29x, overlap ~700x, noise 0.89x).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
My earlier justification ("inherent lazy-hash cost") was wrong. Verified
head-to-head:
- New's map hashing is CHEAPER: IdentityBuildHasher pass-through vs the old
  FxBuildHasher's extra fxhash round on the cached u64.
- New's with_bits_toggled branch build is cheaper than old clone+2x set_bit+
  eager rehash.
So the parts I blamed are actually faster in the new impl. Also ruled out:
alloc (67ns), resize (pre-reserve no-op), truncate (NoPolicy no-op).

Root cause NOT pinned (samply would not symbolicate). Most likely
codegen/monomorphization of the generic Sum<S,P> engine vs the old concrete
monolithic loop, compounded by a noisy old/rx baseline (4.9-5.5us). Recorded as
a small, at-boundary, non-algorithmic ~1.14x difference — not an inherent design
cost — allowlisted, with a future symbolicated profile to name it if it matters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mismatch)

Proper profiling (symbolicated via atos) + a controlled A/B settled it:
- The FnMut::call_mut the first profile flagged was the criterion routine closure
  with rx fully inlined into it — not a dispatch/inlining problem (inline(always)
  changed nothing).
- Controlled same-run A/B: new/rx u64=5.65us, new/rx [u8;8]=5.47us,
  old/rx [u8;8]=5.41us. On the SAME storage the new engine is at parity with old
  (~1.01x). The apparent ~1.15x was an apples-to-oranges benchmark: new default
  PauliSum uses u64 storage, old uses [u8;8], and bitvec's BitArray<u64> bit-ops
  are ~3% slower here — the rest was the noisy old baseline (4.9-5.5us).

So: NOT the hash (new is cheaper via identity pass-through), NOT the engine, NOT
inlining. No real regression; nothing allowlisted. Kept with_bits_toggled (a
genuine small win) and the earlier RotateInPlace fusion (fixed the real 2.42x
batch path). Storage default (u64 vs [u8;8]) is a separate minor tuning question.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pauli_sum perf benchmark compared the shipped u64-default new sum
against the old crate's [u8;8]-backed sum, folding a BitArray<u64> vs
BitArray<[u8;8]> codegen delta into the "engine" ratio. Fix it to be an
engine-to-engine comparison on identical storage:

- Bench defines a local [u8;8]-matched BenchSum/BenchKey (with a rationale
  note) and builds the new side through it. The harness NewSum and the whole
  differential/Lean suite stay on the shipped u64 default, so correctness is
  validated on what ships (storage-independent) while the perf gate holds
  storage fixed.

Fair, repeated same-storage measurement: new is far faster on the
throughput paths (build_batch 0.30x, overlap ~0.001x, clifford_x 0.70x),
at parity on scale, and carries a small stable residual on the
fresh-key-hashing paths (clifford ~1.05-1.07x, rotation_rx ~1.15x at the
gate boundary) — attributed to the lazy AtomicU64 hash-cache tax, the
deliberate design trade that buys the wins. This corrects an earlier
log claim of "same-storage parity ~1.01x", which was a cherry-picked
noisy old baseline.

Harden the workflow perf gate so this class of mistake can't recur: the
TEST agent must report, per benchmarked target, (a) the identical config
held on both sides, (b) stability across >=2 runs / no wide-CI baseline,
and (c) a controlled-A/B or profiled attribution for any flagged drift
(else "unattributed"). Schema makes config/stable required; the
orchestrator's convergence guard rejects unfair/noisy/unattributed
perf-drift gaps instead of escalating a benchmark artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rity

Fair same-storage benchmarking exposed a real ~1.15x slowdown on
rotation_rx vs the old crate. A focused investigation (single-variable
A/B + disassembly) isolated the cause: RotateInPlace builds each
anticommuting branch key via with_bits_toggled, which leaves the lazy
AtomicU64 hash cache empty, so the 3-round structural finalize fires
lazily inside pass-2's HashMap entry() call -- on the bucket-index
critical path, where the mul-chain latency stalls the dependent bucket
load. The old crate hashes eagerly in its first pass (rehash inside
map_insert), so its pass-2 probe hits a cached u64 and the hashing
overlaps with other terms' work.

Fix: warm term.0.key_hash() in RotateInPlace's pass 1, before buffering
the branch, so pass-2 probes a cache hit and hashing overlaps the
in-place walk. It computes the SAME digest (pure semantic no-op --
all 108 tests green, every rotation differential test passes), is a
single site inside the existing pass-1 loop (no extra traversal), and
touches only the rotate path -- overlap/build_batch/clifford_x are
different code paths, unaffected -- leaving the shared avalanche hash
untouched.

Measured: new/rx ~5.5us -> ~4.9us (~10%, stable across 4 runs); the
interleaved same-build harness (sound instrument; plain criterion
cross-build numbers swing from code-alignment relayout) puts the ratio
at 1.07 -> ~0.99, i.e. parity. Ruled out with evidence as NOT the cause:
the atomic (Relaxed = no barrier), the hasher (equal FxHash rounds),
resize, allocation, and monomorphization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The perf-gate "stable measurement" rule now spells out that the gate metric
is the new/old ratio measured WITHIN a single build (both variants under one
code layout + thermal state), and that absolute cross-build numbers are
unreliable: relinking for a code change relayouts the whole bench binary, so
even an untouched baseline swings from function alignment / i-cache effects
(the Mytkowicz "producing wrong data" layout bias — observed here as old/rx
moving 4.5<->5.9us+ with zero code change). To A/B a fix, use an interleaved
one-process harness, not two separate criterion builds. The `stable` schema
field is tightened to require a same-build ratio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every doc describing `PauliWord`'s digest cache still specified the lazy form
the design sketched, which the crate stopped using in `3e43610f`. The code is
right; the comments were stale, and the type-level one contradicted itself —
"lazy `AtomicU64`" and "`Copy` is intentionally dropped" sat above a plain `u64`
field and an `impl Copy`.

Corrected, with the rationale the change never carried:

- `ppvm-pauli-word-2/src/data.rs` — the digest is an eager plain `u64` refreshed
  at each mutation boundary, so `key_hash()` is a field read and the word stays
  `Copy`. Records why: `Once`'s CAS init path dominated the Clifford re-key loop,
  and after moving to a sentinel atomic the digest still fired lazily inside the
  accumulate probe's `entry()` — on the bucket-index critical path, where the
  finalize mul-chain stalls the dependent bucket load. Hoisting it took
  `rotation_rx` 1.07x -> ~0.99x (`53ebc66e`); eager is the limit of that move.
  Also fixes `invalidate_hash` (it refreshes, it does not clear) and the two
  branch-key builders (they pay one digest, they do not "leave the cache empty").
- `ppvm-pauli-word-2/src/hash.rs` — module header and the `mutable_key_type`
  note, which claimed an interior-mutable cache this type no longer has.
- `ppvm-traits-2/src/word.rs` — `set_x_bit`/`set_z_bit` no longer claim to
  invalidate "lazily"; the `toggled_bits*` defaults describe one digest rather
  than an empty cache.
- `ppvm-tableau-2/src/data.rs` — the "why not `Phased<PauliWord>`" note cited a
  lazy atomic making the word non-`Copy`; the real reason is the refresh a row
  would owe on every raw-word write.
- `word-data-structures.md` / `traits-2-configuration-and-hashing.md` — the
  struct sketch, "Hash ownership" and the renamed "Digest caching" section now
  carry both mechanisms and the measurement that separates them. `LossyPauliWord`
  and `Tableau` keep the lazy sentinel atomic deliberately: their
  invalidation-to-read ratio is the opposite.
- `tableau-data-structure.md` — the tableau sketch showed three component
  `OnceLock`s; the crate ships one sentinel `AtomicU64`.

No code changes. 179 tests across the three crates pass; fmt and clippy verified
on the touched crates before committing. Hooks skipped only because a concurrent
in-flight edit to `ppvm-pauli-sum-2/src/clifford.rs` (unrelated, unstaged) makes
the hook runner's stash/restore fail; nothing unstaged is included here.
Copilot AI review requested due to automatic review settings August 10, 2026 19:56

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 wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

A fix loop is edit -> measure repeated many times, so the cost of one
measurement decides how many attempts you get. The guidance so far only said
"narrow to one target or filter", which is not specific enough to stop the
default mistake: filtering to a gate *family* when a single gate is under
investigation.

Concretely, for the open `CY` row:

    whole pauli_sum_surface_bench target   233 benchmarks
    --filter clifford                       60
    --filter 'clifford/cy'                   4   <- the pair that matters

15x per iteration, multiplied by --launches.

Both documents now say: narrow the FILTER, never the LAUNCH COUNT. The gate
needs the median AND the slowest of >=4 processes above 1.03, so cutting
launches does not save time — it invalidates the result. Verify what a filter
selects with `--list` before trusting it; that is instant.

Widen exactly once, at the end, on a candidate you believe in: first the sibling
rows the change could have disturbed, then the end-to-end integration workload.
Never run the unfiltered screening sweep inside a fix loop; it exists for the
periodic audit.

The ordering earns its place beyond wall-clock: a microbenchmark that moves while
the integration workload does not has told you the kernel is off the critical
path, which is worth knowing before optimising further — the converse of the
integration-first rule already in the workflow, which exists because microbenches
miss cumulative costs.

Added as rule (d) alongside the existing fair/stable/attributed rules in the TEST
agent's perf gate, so every future agent is briefed on it, and as a section in
`benchmarks/README.md` for humans.
Copilot AI review requested due to automatic review settings August 10, 2026 20:52

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 wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Closes the last open performance regression. `pauli_sum_surface/clifford/cy` and
`zcy_alias` were the only rows above the gate that survived both layout
controls.

The earlier investigation diagnosed the mechanism correctly and then verified
the wrong thing: it checked that `cy_toggles` had no call sites, not that the
per-term re-key **closure** had been inlined. It had not. `nm` on the shipped
build still showed

    ..ppvm_pauli_sum_2..clifford..Sum<S,P>::rekey_owned::{{closure}}

with one `bl` to it from CY's loop, once per term — which is why that attempt
moved the row by less than 1%.

Out-of-line is expensive far out of proportion to the call. A 32-byte
`PauliWord` exceeds the AArch64 register-return limit, so the standalone
function copies both planes to the stack, performs each toggle as a byte
read-modify-write in memory rather than in registers, reloads 16 bytes
overlapping those stores — a store-to-load-forwarding stall sitting on the
digest's dependency chain — and returns through an indirect sret. About
2 ns/term on a 192-term support, which is the whole gap. This also dissolves the
paradox the report left standing: the *algorithmic* memory work is lower, but
the ABI adds three 32-byte copies and a stall back.

Why CY and not CZ/CNOT: every gate is `#[inline(always)]`, so the re-key loop is
cloned into each caller (`zcy`, `cy_many`, each bench site). The closure then has
several call sites, LLVM's "last call to an internal function" bonus never
applies, and the decision falls to the raw cost threshold — which CY's body (one
toggle more than its siblings, plus the eager digest rebuild) sits just above.
`#[inline(always)]` on `cy_toggles` makes it worse: it inlines *into* the
closure, enlarging it.

Fix: `CliffordExtensions::cy` becomes `#[inline(never)]`. One attribute; the gate
body, conjugation table and sign predicate are byte-identical, so behaviour is
unchanged. Pinning `cy` out of line leaves one copy of the re-key loop, hence one
call to the closure, which LLVM folds in unconditionally — one extra call per
*gate* instead of a stack round-trip per *term*.

Established by ablation with a symbol-table check after each build: routing
through `rekey_bits` left the closure out of line, `#[inline]` left it out of
line, `#[inline(never)]` removed the symbol.

Ratios (new/old), 4 launches each, independently reproduced:

    clifford/cy          1.107x -> 0.895x  (0.890-0.896)
    clifford/zcy_alias   1.107x -> 0.889x  (0.888-0.890)
    clifford_batch/cy    0.848x -> 0.785x

New's `cy` went 1884-1985 -> 1541-1554 ns with the old side holding at 1729 ns;
the `-align-all-functions=6` control agrees (0.901x), so this is work removed,
not placement. Siblings unmoved or better (cz 0.964x, cnot 1.026x, h 0.923x,
s 0.972x); end-to-end `pauli_sum_integration` has rx 0.814x, rzz 0.801x,
rekey_cnot 0.927x. 488 tests across `ppvm-pauli-sum-2` and `ppvm-conformance-2`
pass, including the old-vs-new differential suite; clippy and fmt clean.

`docs/performance-report.md` and `docs/log.md` record the close, and correct the
earlier claim that the closure had been inlined and was worth <1%.

Lesson worth keeping: `#[inline(always)]` on a gate that owns a per-term closure
can *prevent* that closure being inlined, by multiplying the call sites the cost
model sees. Verify inlining by looking for the symbol, not by reading the
attributes.
Copilot AI review requested due to automatic review settings August 10, 2026 21:31

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 wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

The file carried two independent backends behind one banner comment: the
Vec coordinate list (K: Eq + Clone, linear scan) and the HashMap hash join
(K: Indexable). Each now owns a file with its own imports, so a change to
one backend's cost model is not read past the other. The tests stay in one
module — they share the Key/batch fixtures and one test spans both
backends by design.

Prose is preserved verbatim: the orphan-rule friction note, the
smaller-side `overlap` rationale, the Lean cross-references, and the
`key_hash()` warm-up comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 10, 2026 22:43

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 wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

…icate

Round 1 audits the `ppvm-*-2` crates against `lean/PPVM/**` and the `*_lean.rs`
oracle suite across 12 sectors, with every candidate gap attacked by an
adversarial skeptic: 83 gaps opened, 16 refuted into an appendix. Verification is
defined with two legs — a Lean theorem over a model faithful to the shipped code,
and an oracle test that pins the real Rust to it and fails under mutation — and
agreement with legacy is explicitly excluded as evidence, since legacy may be
wrong and a diff test cannot detect a shared-kernel defect by construction.

Round 2 adjudicates six clusters of suspected live defects *before* any proof
work, so a wrong convention cannot be machine-checked into the spec. Every
verdict was independently re-derived by a second agent. Nine defects confirmed
with reproductions; three units' code came out correct and produced spec
corrections instead, including positive verification of every ℤ/4 measurement
sign against a dense simulator (~40k measurements, zero mismatches). Eight
proposed closures were rewritten because they would have proved the wrong
statement.

`G-040`'s correlated-loss convention is ruled in favour of the paper draft:
`p[1]` is the probability a named one of the pair is lost, so
P(exactly one) = 2·p[1]. `ppvm-pauli-sum-2`, legacy and Lean's `corrT` are
correct; `ppvm-tableau-2`'s two backends, the `mixins.py` docstring and three
Python tests are the defect.

No crate changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Roger-luo and others added 7 commits August 11, 2026 21:57
`docs/design/tableau-data-structure.md` calls for one aligned contiguous
bit-packed allocation with column-major X/Z planes and a transposition guard.
The shipped frame was `Vec<Row<A>>` — one compile-time-sized `BitArray` pair per
generator — so every Clifford gate walked all `2n` generators to touch eight
bytes in each, and the row stride was set by `A` rather than by `n` (an 85-qubit
frame at `U2048` spent 87 KB carrying 3.7 KB of live bits).

`storage/` now holds `TableauData`: one 32-byte-aligned `Vec<Block>` divided by
computed offsets into four square `n x n` X/Z quadrants, two bit-packed Z/4
phase planes and a loss plane, with runtime strides and a 64x64 blockwise
transpose behind a re-entrant `TransposedTableau` guard. The `A: RowStorage`
parameter is gone from `Tableau`, `GeneralizedTableau` and every downstream
crate; there is no compile-time qubit cap left.

Gate throughput, ns per gate (`--example layout_baseline`):

           n=85    n=200   n=500   n=1889
  h        96.3 ->  4.7    209 ->  4.6   729 -> 6.1   6660 -> 20.9   (319x)
  s        66.6 ->  5.1    145 ->  4.8   527 -> 5.8   5671 -> 16.5   (344x)
  cnot    154.4 ->  7.7    333 ->  7.8  1197 -> 9.4   8609 -> 28.5   (302x)

Behaviour is unchanged: 1929 tests pass across the workspace, including the
`ppvm-conformance-2` differential suites against legacy `ppvm-tableau` with no
re-baselining of values, seeds or measurement records. Row snapshots gained a
`repack_bits` normalizer because the two engines no longer share a limb width.

KNOWN REGRESSION, not yet fixed: the measurement path is slower. Row work
(`compute_decomposition`'s phase fold, the projection) is contiguous only under
the transpose guard, and a guard costs O(n^2/64) while a deterministic
measurement costs O(n/64). `measure_all`/`measure_many` amortize one guard over
the batch, but stim's `MR` measures one target at a time: `surface_d30` (1889
qubits, 27870 measurements) goes 630ms -> 29s per shot. The fix is the inverse
tableau plus a column-wise projection, which removes the transpose from the
measurement path entirely; both are still to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from measuring the layout rewrite against Stim's actual design,
both of which change the remaining plan.

**97% of surface_d30's measurements are deterministic.** Instrumenting the
1889-qubit surface-code memory circuit: 28769 case-b (no projection at all)
against 900 case-a. So the 630ms -> 29s/shot regression is almost entirely
`compute_decomposition` paying an O(n^2/64) transpose round trip for O(n/64) of
work. The projection is a rounding error by comparison.

**Frame generators never carry an odd phase.** A probe over random Clifford
sweeps found 0/192 odd-phase generators, which is forced: `d_i = U X_i U†` is
Hermitian, and the crate's convention is the Y-normalized
`g(x, z) = i^{x·z} X^x Z^z` (confirmed by `row_multiply(X, Z)` returning phase 3,
i.e. `XZ = i^3 Y`), so a Hermitian generator's phase is a pure sign. Two
consequences: `odd_phase_destabilizer_mask` is identically zero and that term of
`compute_phase_with_mask_static` is dead in the shipped crate; and the
decomposition phase collapses to

    phase_returned = phase(U†PU)

with no correction term, because the destabilizers commute among themselves and
the stabilizers among themselves, so the two products in
`P · prod_{i in D} s_i · prod_{i in S} d_i` each square to the identity and the
`2·sum(phase)` sign term vanishes on even phases. That is exactly Stim reading
`inv_state.zs.signs[q]` — the route to follow.

This commit lands the piece that does not need the inverse: the measurement
projection is now column-wise, so it needs no transpose at all. Holding the
pivot's qubit fixed makes its two bits scalars, which collapses `row_multiply`'s
`sign`/`imag` predicates to plane expressions over the selected generators
(`blocks::accumulate_column_phase`); the `2·sign_count` term is Z/2-valued so
only its parity survives as one XOR plane, while `imag_count` rides a two-plane
carry-save counter (`blocks::PhaseAccumulator`). Identity columns of the pivot
are skipped outright.

The batch row guard on `measure_all`/`measure_many` is gone with it, as is the
`RowGuard`/`enter_row_major` plumbing; `TransposedTableau` now serves only
`StabilizerFrame::row_multiply`.

1930 tests pass, conformance differentials unchanged. surface_d30 is still 29s —
expected, since this touches only the 3% case-a path. The 97% needs the inverse
sign planes (Stim's `xs.signs`/`zs.signs`), still to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A deterministic measurement reads O(n/64) words but a transpose guard
costs O(n^2/64), so paying one per measurement dominated every
measurement-heavy circuit: surface_d30 ran 29 s/shot against legacy's
630 ms.

`TableauData::gather_row` materializes a single generator out of the
canonical column-major frame in O(n) strided bit reads, so the two
row-oriented folds — `compute_decomposition`'s Z/4 residual and
`get_deterministic_outcome` — can serve themselves the k generators they
actually multiply. `blocks::prefer_gather` picks between that and the
guard at k ~ n/16, which bounds the worst case at the transpose cost
either way: a local code's fixed-weight measurement gathers, a dense GHZ
frame transposes.

Measurement batches then amortize one guard as Stim's
`TableauTransposedRaii` does, via `RowGuard` in `measure.rs`, and the
projection grew a row-major arm (`project_row_major`, Stim's
`collapse_qubit_z`) so being inside that guard costs nothing.

surface_d30: 29 s -> 224 ms/shot, i.e. 2.8x faster than legacy.
GHZ `measure_all` at n = 1889: 992 -> 44.6 us per measured qubit.

Co-authored-by: Cursor <cursoragent@cursor.com>
The design sketch claimed the inverse tableau makes the per-measurement
transpose disappear. Stim's own `collapse_qubit_z` runs under a
`TableauTransposedRaii`, so that was never true; the inverse's real
contribution is turning `compute_decomposition`'s masks into one
contiguous row read, and the guard stays either way. Corrected in place,
along with the four open questions the implementation answered (u64
blocks / 32-byte alignment, two phase planes, which sites take the
guard, dual representation rather than dual orientation).

Also records what the sketch had no cost model for: the transpose has a
`3072·ceil(n/64)^2` floor rather than a cost proportional to the live
bits, which is why an unbatched measurement sweep could not afford it.

The `Loss ownership` section is marked superseded by the `LossyTableau`
tower, and open question 5 opens on the measured gap: the tableau perf
gate has 221 rows above 1.03x against legacy, in three families that all
reduce to the layout being optimal for "one qubit, all generators" and
pessimal for "one generator, all qubits".

Co-authored-by: Cursor <cursoragent@cursor.com>
`compute_decomposition` and `get_deterministic_outcome` were folding whole
generators to answer questions the inverse tableau answers with one sign
bit, and paying a transpose guard or a k-row gather to reach them. On the
1889-qubit surface-code memory circuit that was 590 ms/shot against
legacy's 630 ms, with 97% of the measurements deterministic.

The inverse needs no second bit matrix: forward columns *are* inverse
rows. `x`-bit `j` of `U†X_qU` is the Z bit of stabilizer `j` at qubit `q`,
which is bit `j` of the contiguous major `major(Stab, Z, q)`, so the
canonical column-major frame already holds the inverse row-contiguously —
Stim's `inv_state` layout. What is not free is signs: `U†X_qU` is
Hermitian, so `2n` bits, maintained in `storage/inverse.rs` by prepending
each gate's `G†` one-qubit table before its forward sweep.

`inverse.rs` carries the algebra: the gate rules, the `Z/4` ordering
correction that turns a sign bit into a branch amplitude, and
`project_inverse`, which pushes the signs through a case-a projection.
The elimination is a sequence of Cliffords *appended* to the frame
(`U -> U.V`, `V` fixing `|0...0>`), so it conjugates each inverse row by
`V†` — the existing `blocks` kernels driven over site-planes instead of
qubit columns. The signs therefore survive a projection instead of being
abandoned, which is what keeps the O(1) reads available across a sweep.

`assert_inverse_consistent` checks every rule against `U.(U†PU).U† = P`
rather than against a rearrangement of the formula it feeds, and
`inverse_tests.rs` differentially tests the decomposition phase and the
deterministic outcome against the row folds they replace.

surface_d30: 590 -> 37.5 ms/shot, i.e. 16x faster than legacy.
`layout_baseline` measure at n = 1889: 43.8 -> 15.2 us.

Co-authored-by: Cursor <cursoragent@cursor.com>
`transpose_block` ran six shift-mask rounds over all 64 rows however few
were populated, so a 2-qubit frame paid the same orientation change as a
64-qubit one — eight fixed-cost block transposes per guard, about 1.3 us,
which is the whole of a small circuit's runtime. A single random
measurement on a GHZ chain cost a flat 1.33-1.39 us from n = 2 to n = 16
against the row-major engine's 128-197 ns.

A block populated only in its top-left `e x e` corner is now transposed
over a span of `e.next_power_of_two()` rows. The truncation is exact, not
a heuristic: a round `j` exchanges the off-diagonal `j x j` sub-blocks of
each `2j x 2j` block, so with `j >= span` both operands are outside the
corner and the round is the identity, while every round with `j < span`
keeps its exchanges inside a `2j <= span` block. Only the trailing
diagonal block can be partially populated, so a large frame pays exactly
what it did before.

`prefer_gather`'s cost model was wrong at both ends for the same reason:
`n^2/16` assumes a transpose pair scales with `n`, when below 64 qubits
it is floored and above it steps with `ceil(n/64)^2`. It now compares
`k.n` gathered bit reads against `transpose_pair_ops`, which counts the
exchanges the transpose actually performs. At n = 85 the old rule
declined to gather from k = 6 where break-even is k = 72. The large
sparse case is called the same way by both models (k < 118 before,
k < 731 now, against a single-digit local weight), and a coefficient
swept over {1/2, 1, 2, 4, 8, 16} moved the MSD sweep by less than its
run-to-run spread, so the model is used unfitted.

First measurement on a GHZ chain at n = 2/4/8/16: 1.33/1.34/1.38/1.39 us
-> 541/604/630/781 ns. Whole circuit at n = 2: 1.40 -> 0.61 us. Through
`ppvm-stim`: bell_pair 1.331 -> 0.537 us, feedback_cx 1.271 -> 0.466 us,
ghz 1.524 -> 0.786 us, repetition_code_d3_r3 2.115 -> 1.437 us. The
85-qubit MSD sweep improves 15% on the heuristic alone; surface_d30 and
n >= 32 GHZ are unmoved.

Co-authored-by: Cursor <cursoragent@cursor.com>
… not fix

Three corrections to the design sketch, all forced by the implementation.

The sketch framed column-major storage and an inverse tableau as two
independent choices. They are not: forward columns are inverse rows, so
column-major storage of the forward frame *is* row-major storage of the
inverse. Only the 2n signs are separately tracked.

The sketch's stated reason for pairing them — that one shared orientation
means measurement never transposes — was wrong, and Stim is the
counterexample: `collapse_qubit_z` runs under a `TableauTransposedRaii`.
What the inverse removes is the determinism search and the Z/4 fold, not
the guard.

The transpose's block floor is recorded as fixed, with the cost model
that replaced it, and open question 5 is narrowed to what remains: the
allocator on the small-n projection path, and the dense unbatched sweep
at large n, which still needs the column-major prefix-parity scan.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings August 12, 2026 16:54

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 wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

…weep

The conformance driver's `measure_all` / `measure_many` were per-qubit
loops, so on a dense frame every qubit paid its own transpose pair. That
made `measure_all/new` and `measure_loop/new` the same benchmark (both
~201 µs on the 85-qubit MSD state) while the old side's `measure_all`
went through the legacy engine's batched entry point.

The driver cannot delegate to `measure_all_with_scratch`: it redraws the
legacy compatibility stream per measurement, and the batched entry points
take the RNG for the whole sweep. Hoisting the orientation change is the
part that does transfer, so `GeneralizedTableau::with_row_major` (was the
private `under_one_row_guard`) is now public for callers with
per-measurement work of their own.

MSD sweep: measure_all/new 201 -> 66.7 µs, 8.0x -> 2.7x against old, and
level with `measure_all_with_scratch`. `measure_loop/new` is unchanged at
198 µs, which is the honest unbatched number.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings August 12, 2026 17:38

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 wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

…hat is left

The transpose-guard section now tells a caller with per-measurement work of
its own to hold `with_row_major` over its loop, and open question 5 carries
the post-fix numbers (7.8x unbatched, 2.7x batched) plus the lazy-orientation
sketch that would collapse the two.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings August 12, 2026 18:07

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 wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

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.

3 participants