Symmetry-merged evolution: Trotter merging + momentum-sector CTPP - #182
Merged
Merged
Conversation
This was referenced Jul 16, 2026
|
david-pl
force-pushed
the
split/2-ctpp-core
branch
from
September 1, 2026 15:15
64d248e to
83d7014
Compare
Two consumers of the translation-symmetry primitive:
Trotter path: PauliSum.symmetry_merge (k=0) and PauliSum.momentum_merge
(k≠0 carried as a real pair, character-weighted fold, |G|-rescaled to
the summing projector so merging after every step is idempotent), with
TranslationGroup and canonicalize_basis_arr{,_complex} /
check_momentum_sector_arr exposed to Python. Inputs validated at the
boundary (ValueError, not panics).
CTPP path: pc_step_orbit_rep — per-step evolution entirely in
orbit-representative form (complex coefficients, phase-aware action,
cached-CSC expm; basis ~|G|× smaller than full-basis evolution,
persisting through every step), with the same PcStepConfig truncation
policy incl. displacement admission.
Split 3/4 of the CTPP work; full history on branch
continuous-time-pauli-propagation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
david-pl
force-pushed
the
split/3-symmetric-evolution
branch
from
September 2, 2026 07:16
36cb95d to
070bc75
Compare
…ep layout
`orbit_rep.rs` (463 lines) re-implemented one function from each of the
crate's existing modules, so every function in it had a near-twin — three
of the truncation helpers were verbatim copies with `.abs()` -> `.norm()`.
Each function now sits beside its twin, on shared generic code:
- new `sector.rs`: `Sector { group, k_modes }` bundles the two values that
every phase-aware routine needs together, with one `canonicalize_phase`
method replacing the canonicalize/character/multiply idiom at its three
call sites. Carries the orbit-rep narrative docs.
- new `truncate.rs`: one `prune_basis` / `cap_basis` / `add_leakage_capped`
(plus `cap_map_to_room`, `order_by_desc_mag`) generic over the new
`scalar::Coeff`, replacing five real/complex duplicate pairs.
- `mf_expm.rs`: `build_orbit_rep_cols` + `expm_apply_orbit_rep` beside the
real pair, both on a shared `expm_apply_cached` tail (mu, 1-norm,
`from_parts`, `apply`). The paths now differ only in the `(m*, s, tol)`
selection closure.
- `basis.rs`: `leakage_orbit_rep` beside `leakage_complex`.
- `step.rs`: `pc_step_orbit_rep` beside `pc_step`.
The `Sector` bundle drops `leakage_orbit_rep` 7->6 args and
`pc_step_orbit_rep` 8->7, so both `#[allow(clippy::too_many_arguments)]`
suppressions are gone; the crate now has zero clippy allows.
No behavior change: 348 insertions, 659 deletions. `cargo clippy
--workspace --all-targets` is clean, `cargo test --workspace` and the 239
Python tests pass, including the orbit-rep vs full-basis exactness test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PauliSum.momentum_merge` carried ~90 lines of numerics inside a `macro_rules!` body: gather two real sums into a `HashMap<Word, Complex>`, fold onto orbit reps, rescale, split back into real/imaginary parts. That made it untestable from Rust (only reachable through Python) and expanded it once per `Config` variant. The algorithm now lives in `ppvm_pauli_sum::symmetry` as `momentum_merge_pauli_sum_pair`, beside `canonicalize_pauli_sum_complex` which it reuses, and parallel to `symmetry_merge_pauli_sum`. The PyO3 method keeps only its qubit-count / momentum-length validation (so the Python error messages are unchanged) and one call. New Rust tests pin the two things the wrapper used to hide: - `momentum_merge_pair_matches_symmetry_merge_at_k0`: on free orbits the k=0 pair merge agrees with the independent real-coefficient `symmetry_merge_pauli_sum` path — this is what pins the `|G|` rescale. - `momentum_merge_pair_matches_summing_projector`: for every k on a 4-chain, the rep coefficient equals `Σ_orbit χ_k(g) · c`, computed from the group API. - two `should_panic` tests for the qubit-count and momentum-length asserts. No behavior change. `cargo clippy --workspace --all-targets` is clean and the Python suite still passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five public Python entry points shipped with no Python test at all; `momentum_merge` was the only one covered. Adds: - `test_symmetry_merge.py` — `TranslationGroup` constructors, properties, `from_generators` validation, `canonicalize`, and `PauliSum.symmetry_merge` (orbit summing, distinct orbits, idempotency, coefficient-sum conservation, qubit-count mismatch). - `test_symmetry_arrays.py` — the three `_core` array functions (`canonicalize_basis_arr`, `canonicalize_basis_arr_complex`, `check_momentum_sector_arr`) against numpy references derived from the group action, plus every validation path. `check_momentum_sector_arr` was previously exported and referenced from docstrings but never exercised. - `test/lindblad/test_pc_step_orbit_rep.py` — the `pc_step_orbit_rep` binding: orbit-rep evolution vs a dense full-space numpy matrix exponential projected at the end (k = 0, 1, 2), `canonicalize_first`, the `max_basis` rank cap, `protected` reps, input validation, and returned dtypes/shapes. Also adds an xfail (strict) documenting a real bug found while writing these: `momentum_merge` rescales by `|G|`, but the projector averages over the `|orbit|` DISTINCT members, so an orbit with a non-trivial stabilizer is amplified by `|G|/|orbit|` on every merge — `ZZZZ` on a 4-chain grows 4x per merge, `ZIZI` 2x. The existing idempotency test only seeds free orbits, where the two factors coincide, which is why it passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`momentum_merge` rescaled the orbit-*averaged* projection by a global
`group.order()`, but `canonicalize_pauli_sum_complex` divides by the number
of DISTINCT orbit members. The two agree only for free orbits, so any orbit
with a non-trivial stabilizer was amplified by `|G|/|orbit|` on every merge.
Since the documented workflow is "merge after every Trotter step", the error
compounded geometrically — on a 4-site chain `ZZZZ` grew 4x per merge and
`ZIZI` 2x, so `4^steps` for a translation-invariant word:
ZIII (|orbit|=4): 1.0 -> 1.0 -> 1.0 (was already correct)
ZIZI (|orbit|=2): 1.0 -> 2.0 -> 4.0 now 1.0 -> 1.0 -> 1.0
ZZZZ (|orbit|=1): 1.0 -> 4.0 -> 16.0 now 1.0 -> 1.0 -> 1.0
The character-weighted fold moves into a shared `project_onto_reps`, which
returns the un-normalized sum together with `|orbit|`. The two conventions
are now explicit at the two call sites: `canonicalize_pauli_sum_complex`
divides to average (unchanged behavior and signature),
`momentum_merge_pauli_sum_pair` takes the sum as-is. Summing is what makes
the merge idempotent for every orbit and makes it reduce exactly to
`symmetry_merge_pauli_sum` at k=0 — which the strengthened
`momentum_merge_pair_matches_symmetry_merge_at_k0` now asserts over
stabilized orbits too, not just free ones.
The strict xfail added in 4841ffc becomes a plain passing test, and every
pre-existing test still passes untouched — including the exact-diagonalization
Trotter checks in test_momentum_merge.py, which only ever exercised free
orbits. Also corrects the docs that described the old `1/|G|` relationship.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry API Two related pieces of boilerplate cleanup on the `(basis_arr, coeffs)` array surface. Rust: the validate-decode-encode triple was written out three times in `symmetry.rs` and again in `lindblad.rs::pc_step_orbit_rep`, and `symmetry.rs` reached across into `crate::lindblad::decode_basis` for the decode half. All of it now lives in one `pauli_arr` module — `decode_basis` (moved), `encode_basis`, and the three argument checks (`check_group_width`, `check_coeffs_len`, `check_momentum_len`). Error messages are unchanged, so the existing tests that match on them still pass. Net -155 lines across `lindblad.rs` and `symmetry.rs`. One incidental improvement: `check_momentum_sector_arr` never validated its `coeffs` / `momentum` lengths, so a mismatch reached the core's `assert_eq!` and surfaced as a PanicException. It now raises ValueError like its siblings. Python: the three `_core` symmetry functions had no wrapper, so they demanded exact dtypes and rejected the natural `np.array([1])` momentum (numpy's default int64) with `TypeError: 'ndarray' object is not an instance of 'ndarray'`. A new `ppvm.symmetry` module wraps them with the same dtype coercion `lindblad.py` already applies, and re-exports `TranslationGroup` — which previously had to be imported from the private `ppvm._core` despite the docstrings pointing users at it. `ppvm` now exports `TranslationGroup`, `canonicalize_basis_arr`, `canonicalize_basis_arr_complex` and `check_momentum_sector_arr`; the docstrings that said `ppvm._core.TranslationGroup` are updated, and griffe picks the module up for the docs site automatically. 285 Python tests pass (one new, for the coercion), `cargo test --workspace` is green, clippy/ruff/ty are clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three small fixes closing out the symmetry-evolution cleanup. `pc_step_orbit_rep` silently ignored `cfg.num_threads` while its sibling `pc_step` honoured it — the orbit-rep path never went through `run_in_pool`. It does now, and `num_threads` is threaded through the PyO3 signature, the `.pyi` stub and the `lindblad.py` wrapper so the two adaptive step entry points take the same knobs. Previously the binding hardcoded `num_threads: None`, so the argument could not even be passed. `PauliSum.momentum_merge` took `other` as `PyRefMut`, so passing the same object as both the real and imaginary part died with PyO3's raw already-borrowed error instead of the "must be distinct objects" the docstring promised. It now takes a `Bound` and converts the failed borrow into that message. `group` / `momentum` parameters on `PauliSum.symmetry_merge`, `PauliSum.momentum_merge` and `Lindbladian.pc_step_orbit_rep` were unannotated; they now carry `_core.TranslationGroup` / `Sequence[int]` / `npt.ArrayLike`. Also finishes a dedup that 7432704 got wrong: the `coeffs has length ...` check appears four times in `lindblad.rs`, and the replacement there landed on the first occurrence rather than the intended one in `pc_step_orbit_rep`. All four now call `check_coeffs_len`. Messages were identical, so no behavior changed either way. 288 Python tests pass (three new: the same-object rejection and num_threads=1/2 result-equivalence), `cargo test --workspace` green, clippy/ruff/ty clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…up inputs Two fixes from a review of the orbit-rep CTPP path. **Stabilized orbits.** The phase-aware action sums characters over the output orbit's *distinct* members, which makes it the orbit-rep generator in the summing convention `ĉ_r = |orbit_r| · c_r`. The public API carries averaged coefficients (`c_r` = the plain coefficient of the rep word, as `canonicalize_pauli_sum_complex` produces), so every matrix entry needs the similarity factor `|orbit_in| / |orbit_out|` — which is 1 only when both orbits are free. Without it, evolving `ZIZI + IZIZ` on a 4-site chain came out a factor 2 too large on every coefficient. Reps whose stabilizer is incompatible with the sector are now dropped too, matching what the reference projection does. `TranslationGroup::canonicalize_in_sector` computes the rep, the shift counter, the distinct-orbit size and sector compatibility from ONE orbit traversal (orbit-stabilizer, counting stabilizer elements during the lex-min walk), so the hot action loop pays one extra word compare. **FFI validation.** `pc_step_orbit_rep` never checked the group's qubit count against the spec's, and ran `canonicalize_first` — documented as not deduplicating — without a uniqueness check, so a release wheel silently collapsed same-orbit rows onto one CSC index. The lattice constructors and the unchecked half of `from_generators` (zero order, inexact order, non-commuting generators) aborted through `assert!`. All now raise `ValueError`. The order and commutation checks move into a fallible core constructor, `TranslationGroup::try_from_generators` -> `GroupError`, rather than being duplicated in the binding; `from_generators` panics with the same messages as before. `LossyPauliSum` gained explicit `symmetry_merge` / `momentum_merge` overrides raising `NotImplementedError`, and the `.pyi` stubs moved onto a non-loss base so type checkers reject the call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The orbit-rep (momentum-sector) step was 3-5x slower on the split stack than on `wide-pauli-words`: three canonicalization commits from that branch (7e8ff64, b0420ef, 5f343b3) never made it into the PRs. Port them onto the current `symmetry/` layout, keeping this stack's stabilized-orbit semantics (`canonicalize_in_sector` still reports |orbit| and rejects stabilizer-incompatible orbits on exact numerators). - Odometer by index: the group walk tracks a mixed-radix index instead of cloning a counter `Vec` per element. `canonicalize_with_index` returns the element index; `canonicalize_with_shift` decodes it. - `TranslationGroup::character_table(k)` -> `CharacterTable`, built once per `Sector::new`; `canonicalize_in_sector_indexed` looks characters (and stabilizer triviality) up by index, so the hot loops in `build_orbit_rep_cols` / `leakage_orbit_rep` no longer do a counter Vec, numerator arithmetic and sin/cos per action term. `Sector` now owns that table and is passed by reference. - O(N) least-rotation canonicalizer (plane-by-plane Booth/Duval) for single-generator block-cyclic groups (chain_1d, ladder). The final survivor spacing is the stabilizer generator, which gives |orbit| and the sector-compatibility check for free. Tie-break matches the odometer, so reps and shifts are bit-identical. - Block-rotation generators (every lattice translation) are applied as masked shifts of the two bit planes instead of a per-qubit gather, and intermediates are no longer rehashed; only returned/yielded words are. Tests: masked shift vs gather (u64 and u32 chunk layouts), block-cyclic vs odometer (rep, shift, stabilizer) on stabilized words, and every fast entry point vs the previous counter-carrying traversal, including a non-faithful action. bench_orbit.py torus 4 3000 6, RAYON_NUM_THREADS=2 (2-vCPU sandbox): 13.2 s/step -> ~3.9 s/step (wide-pauli-words: ~2.8-3.9 s/step on the same box), C(t=0.6) unchanged to 12 digits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TmnGNkxQqpmFmGszAV7dm5
Adds the general GKSL form
`D*(O) = Σ_nm K_nm (A_n† O A_m − ½{A_n† A_m, O})` to `ppvm-lindblad`,
alongside the existing jump-operator form, with Python bindings, tests
and benchmarks.
Replaces #179, which was stacked on the pre-split #178 and could not be
reopened usefully: its branch had never been rebased, so its diff
against
the split stack was 160 files and reverted work that #180–#182 had since
landed. This branch is the same feature ported onto `split/3` and
cleaned
up; the original commits are preserved on `kossakowski-dissipator`.
Stacked on #182 — **review after that one settles**, see the note below.
## What's here
| Commit | |
|---|---|
| `7b52cf6b` | core dissipator: `kossakowski.rs`, `PairShape`,
`add_kossakowski` |
| `e6266b52` | superradiance + dipolar-relaxation benchmarks, profiling
example |
| `dc8c6234` | `Lindbladian(..., kossakowski=(ops, K))` Python surface |
| `b52d22cc` | dense-reference and orbit-representative tests |
Conjugate pairs `(n,m)`/`(m,n)` are folded into a single upper-triangle
entry — both sandwiches produce the same output words with conjugate
phase and the action keeps only the real part — halving the pair count.
`PairShape` distinguishes the diagonal entry from a folded off-diagonal
one and carries the term list only the folded case needs, so the
off-diagonal-only path is unreachable on a diagonal pair.
Against the equivalent eigenmode-jump decomposition on a superradiant
chain, the pair-matrix path costs 27.7 ms vs 103 ms at n=10 and 96 ms vs
1.71 s at n=30: the per-string action scales with the number of nonzero
`K_nm` entries instead of carrying an extra factor of `M`.
## Differences from #179
Ported into the `spec`/`basis`/`step`/`algebra` layout `split/2` and
`split/3` introduced, rather than appended to `lib.rs`:
- New `kossakowski.rs` module. `spec.rs` grows 65 lines, not 520.
- `add_kossakowski` (147 lines) split into `validate_k` / `parse_ops` /
`compile_pair` / `compile`; the ~90-line action arm became
`Pair::accumulate` + the one-sided and both-sided halves.
- `PairShape` enum replaces an `off_diag` bool that drove three separate
branches in the hot path. Benchmarked both shapes: the deltas
(+2.4% / −1.3% / +2.2%, n30 p=0.17) are smaller than the drift on the
`eigenmode_*` control benchmarks (+3.4% to +6.6%) in the same run, so
this is noise, not a regression.
- **7 clippy `allow`s removed, 0 added.** `split/3` had none in
`ppvm-lindblad`; three `needless_range_loop` became iterator loops and
four `type_complexity` became two named aliases.
- `precompute_ldagger_l` generalized to `precompute_adag_b(a, b)`, moved
to `algebra.rs` beside `PauliTerm` and a named `COEFF_DROP_TOL` (was a
`1e-14` literal in five places).
- Dropped a dead `let _ = i;`, and row-length errors now report which
row.
- `support_mask` lifted out of the pair loop, where it was being rebuilt
per iteration.
- Deleted the `k_ops` field — `LindbladSpec` state that never outlived
the constructor.
- Did not port the original's `:func:`/`:meth:` docstrings; `split/3`
had already converted those to backticks per AGENTS.md.
## One thing to check
`test_orbit_dissipative.py` failed on arrival. `d8313e0e` on `split/3`
("orbit-rep evolution on stabilized orbits") made rep coefficients
member
coefficients on *every* orbit, where stabilized orbits were previously
divided by `|G|`; the tests encoded the old behaviour and `c_I` came out
exactly 6× the asserted value on a 6-site ring.
I rewrote the reconstruction as `Σ |orbit(rep)| · c_rep` and updated the
identity-bookkeeping expectation to `coeff_I`. The new values match the
closed form exactly. **If `d8313e0e` was not meant to change that
convention, the bug is there and not in these tests** — worth confirming
while reviewing #182.
## Verification
- `cargo test -p ppvm-lindblad` — 8 passed
- `pytest test/lindblad/` — 35 passed, 1 skipped
- `cargo clippy -p ppvm-lindblad --all-targets` — zero warnings
- pre-commit green on all four commits
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Alexander Schuckert <schuckertalexander@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 3 of 4 splitting #178 (stacked on #181). Full history: branch
continuous-time-pauli-propagation.The two consumers of the translation-symmetry primitive from PR 1:
PauliSum.symmetry_merge(k = 0) andPauliSum.momentum_merge(k ≠ 0, complex operator carried as a real pair; character-weighted fold rescaled by |G| to the summing projector, so merging after every gate layer is idempotent).TranslationGroup,canonicalize_basis_arr{,_complex}, andcheck_momentum_sector_arrexposed viappvm._core. All inputs validated at the FFI boundary (ValueError, not panics).pc_step_orbit_rep— per-step adaptive evolution entirely in orbit-representative form (complex coefficients, phase-aware action, same cached-CSC expm engine andPcStepConfigtruncation policy as the real-space step). The live basis is ~|G|× smaller than full-basis evolution, and the reduction persists through every step; observables come out per momentum mode.Tests: momentum-merge correctness incl. k-resolved Trotter convergence vs exact diagonalization; orbit-rep evolution vs full-basis-then-project (exact match); real/complex equivalence at k = 0. Regression gates rerun on this exact tree: L=5 momentum sanity bit-identical to the recorded reference; the τ_add real-space cell reproduces median 5.19e-3 / peak basis 77,820 exactly.
🤖 Generated with Claude Code