Translation symmetry: TranslationGroup + orbit-representative Pauli-sum merging - #180
AlexSchuckert wants to merge 7 commits into
Conversation
…erging TranslationGroup (finite abelian permutation groups: 1D chains, 2D/3D tori, multi-leg ladders, arbitrary generators) with lex-min orbit canonicalization, shift counters, and momentum-sector characters. Pauli-sum merging in both sectors: canonicalize_pauli_sum / symmetry_merge_pauli_sum (k=0, real coefficients) and canonicalize_pauli_sum_complex (k≠0, character-weighted projection, 1/|G| normalization), plus check_momentum_sector to validate an input before projecting. Following Teng, Chang, Rudolph & Holmes (arXiv:2512.12094). Split 1/4 of the CTPP work (see PR body for the stack); full development history on branch continuous-time-pauli-propagation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
There was a problem hiding this comment.
Pull request overview
Adds a new Rust-only symmetry layer to ppvm-pauli-sum for translation-invariant (and momentum-resolved) Pauli-sum canonicalization/merging, intended to support upcoming continuous-time Pauli propagation work (#178 split).
Changes:
- Introduces
ppvm_pauli_sum::symmetrywithTranslationGroupplus orbit-canonicalization utilities. - Adds real (
k=0) and complex/momentum-sector merging/canonicalization helpers, along with momentum-sector validation. - Exposes the new module from
ppvm-pauli-sum’s public API and adds a comprehensive test suite for canonicalization/merge behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| crates/ppvm-pauli-sum/src/symmetry.rs | New symmetry module: translation group model, orbit canonicalization, (momentum) merging, and tests. |
| crates/ppvm-pauli-sum/src/lib.rs | Exposes the new symmetry module publicly. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@AlexSchuckert I think this is a pretty good boiled down PR here. I just saw that the single file here was pretty large. codex suggested some semantic split, see below. Codex output:
The I would not split the individual lattice constructors into separate files—the 1D/2D/3D/ladder constructors are cohesive parts of |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (6)
crates/ppvm-pauli-sum/src/symmetry.rs:101
from_generatorsacceptsordersthat can be zero. That leads to division/modulo by zero later (e.g.characterdivides byorders[g], and mixed-radix decoding uses% o). Add an upfrontorder > 0validation per generator.
pub fn from_generators(n_qubits: usize, perms: Vec<Vec<u32>>, orders: Vec<u32>) -> Self {
assert_eq!(perms.len(), orders.len(), "perms and orders must match");
for (g, perm) in perms.iter().enumerate() {
crates/ppvm-pauli-sum/src/symmetry.rs:219
TranslationGroup::order()multiplies generator orders withproduct(), which can silently overflowusizein release builds. That can makeorder()wrong and can even feed1.0 / orderin momentum merging. Use checked multiplication and fail fast on overflow.
/// Total group order: `Π orders[g]`.
pub fn order(&self) -> usize {
self.orders.iter().map(|&o| o as usize).product()
}
crates/ppvm-pauli-sum/src/symmetry.rs:133
- The convenience constructors can panic on zero dimensions (e.g.
chain_1d(0)does(q + 1) % n). Add explicitassert!(...)guards so invalid sizes fail with a clear message.
/// 1D chain of `n` sites with periodic boundary conditions.
/// Single generator: cyclic shift by one site.
pub fn chain_1d(n: usize) -> Self {
let perm: Vec<u32> = (0..n).map(|q| ((q + 1) % n) as u32).collect();
Self::from_generators(n, vec![perm], vec![n as u32])
}
crates/ppvm-pauli-sum/src/symmetry.rs:152
torus_2d(0, ly)/torus_2d(lx, 0)will panic due to modulo-by-zero in the permutation builders. Add explicit dimension assertions so the API fails fast with a clear message.
/// 2D `lx × ly` torus, qubit at `(i, j)` indexed as `j*lx + i`.
/// Two generators: x-shift (i → i+1 mod lx) and y-shift (j → j+1 mod ly).
pub fn torus_2d(lx: usize, ly: usize) -> Self {
let n = lx * ly;
let perm_x: Vec<u32> = (0..n)
.map(|q| {
let (i, j) = (q % lx, q / lx);
(j * lx + (i + 1) % lx) as u32
})
.collect();
let perm_y: Vec<u32> = (0..n)
.map(|q| {
let (i, j) = (q % lx, q / lx);
(((j + 1) % ly) * lx + i) as u32
})
.collect();
Self::from_generators(n, vec![perm_x, perm_y], vec![lx as u32, ly as u32])
}
crates/ppvm-pauli-sum/src/symmetry.rs:187
torus_3dhas the same modulo-by-zero hazard astorus_2dwhen any dimension is zero. Add explicit assertions forlx,ly, andlz> 0.
/// 3D `lx × ly × lz` torus, qubit at `(i, j, k)` indexed as
/// `k*lx*ly + j*lx + i`.
pub fn torus_3d(lx: usize, ly: usize, lz: usize) -> Self {
let n = lx * ly * lz;
let perm_x: Vec<u32> = (0..n)
.map(|q| {
let i = q % lx;
let j = (q / lx) % ly;
let k = q / (lx * ly);
(k * lx * ly + j * lx + (i + 1) % lx) as u32
})
.collect();
let perm_y: Vec<u32> = (0..n)
.map(|q| {
let i = q % lx;
let j = (q / lx) % ly;
let k = q / (lx * ly);
(k * lx * ly + ((j + 1) % ly) * lx + i) as u32
})
.collect();
let perm_z: Vec<u32> = (0..n)
.map(|q| {
let i = q % lx;
let j = (q / lx) % ly;
let k = q / (lx * ly);
(((k + 1) % lz) * lx * ly + j * lx + i) as u32
})
.collect();
Self::from_generators(
n,
vec![perm_x, perm_y, perm_z],
vec![lx as u32, ly as u32, lz as u32],
)
}
crates/ppvm-pauli-sum/src/symmetry.rs:204
ladder(l, n_legs)can panic whenl == 0due to(j + 1) % l, andn_legs == 0produces a degenerate 0-qubit group. Add input validation for clarity and to avoid modulo-by-zero.
/// Multi-leg ladder: `l` sites along the chain × `n_legs` legs.
/// Single generator: cyclic shift along the chain direction (all
/// legs simultaneously). Qubit at `(leg, j)` indexed as
/// `leg * l + j`. No translation along the leg axis (legs are
/// distinguished).
pub fn ladder(l: usize, n_legs: usize) -> Self {
let n = l * n_legs;
let perm: Vec<u32> = (0..n)
.map(|q| {
let leg = q / l;
let j = q % l;
(leg * l + (j + 1) % l) as u32
})
.collect();
Self::from_generators(n, vec![perm], vec![l as u32])
}
This implements the split suggested in #180 (comment) and fixes some issues that surfaced during the split or from copilot findings. Should be merged before #181 cc @AlexSchuckert --------- Co-authored-by: Cursor <cursoragent@cursor.com>
| } => write!( | ||
| f, | ||
| "input not in target momentum sector: orbit rep {rep} expected c={expected:?}, \ | ||
| but orbit member {offending_pauli} (shift {shift:?}) has c={actual:?}" | ||
| ), |
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
crates/ppvm-pauli-sum/src/symmetry/momentum.rs:325
SectorCheckError::CoefficientMismatch's Display message is misleading: it says the orbit representative "expected c=…" butexpectedis the expected coefficient for the offending orbit member (after applying the sector relation). This makes debugging confusing.
Self::CoefficientMismatch {
rep,
offending_pauli,
expected,
actual,
shift,
} => write!(
f,
"input not in target momentum sector: orbit rep {rep} expected c={expected:?}, \
but orbit member {offending_pauli} (shift {shift:?}) has c={actual:?}"
),
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
| /// Returns `Ok(())` on pass; `Err(SectorCheckError)` on fail with the | ||
| /// offending orbit-rep, expected coefficient, and actual coefficient. | ||
| /// | ||
| /// Use this on a user-supplied initial state before feeding it to a | ||
| /// phase-aware merging pipeline — silently projecting a wrongly-typed | ||
| /// input throws away meaningful physics. | ||
| pub fn check_momentum_sector<A, S, const R: bool>( | ||
| basis: &[PauliWord<A, S, R>], | ||
| coeffs: &[Complex<f64>], | ||
| group: &TranslationGroup, | ||
| k_modes: &[i32], | ||
| tol: f64, | ||
| ) -> Result<(), SectorCheckError<A, S, R>> | ||
| where | ||
| A: PauliStorage, | ||
| S: BuildHasher + Clone + Default + HashFinalize, | ||
| { | ||
| assert_eq!(basis.len(), coeffs.len()); | ||
| assert_eq!(k_modes.len(), group.n_generators()); |
## Summary - Same kind of cleanup as #193 did for #180: split the 1300-line `ppvm-lindblad` `lib.rs` / `LindbladSpec` impl into `word`, `algebra`, `spec`, `basis`, `step`, and `tests`, keeping the public `ppvm_lindblad::*` API. - Collapses the timed/untimed `pc_step` copy, demotes `ppvm-pauli-sum` to a dev-dependency, drops unused `approx`, strips 182-forward `orbit_rep` doc links, and forwards `admit_basis` / `tau_add` on the Python string `pc_step`. - **Base is #181 (`split/2-ctpp-core`) on purpose.** #193 replaces #180 and is not an ancestor of #181; retargeting this at #193 now would mix the whole CTPP crate into the diff. After #193 merges and #181 is rebased onto it, this PR rebases with #181. ## Test plan - [x] `cargo test -p ppvm-lindblad` (6 passed) - [x] `cargo clippy -p ppvm-lindblad --all-targets -- -D warnings` - [x] `cargo check -p ppvm-python-native` - [ ] Rebase onto #181 after #181 is retargeted onto #193 Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
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>
…olution (#181) **PR 2 of 4** splitting #178 (stacked on #180). Full history: branch [`continuous-time-pauli-propagation`](https://github.com/QuEraComputing/ppvm/tree/continuous-time-pauli-propagation). The core CTPP method: direct Heisenberg-picture evolution `O ← exp(dt·L*) O` of Pauli observables under a Lindbladian, on an adaptively truncated Pauli-string basis. Exact in `dt` within the working basis — no Trotter splitting; the only approximation is truncation. - **`ppvm-lindblad` crate**: `LindbladSpec` precompiles Hermitian-Pauli jumps (fast diagonal path) and general complex Pauli-sum jumps (σ±/amplitude damping via a precomputed L†L expansion). `pc_step` = two-hop leakage enrichment + predictor/corrector matrix-free exponential (cached-CSC columns fed to `quspin-expm`, MIT-licensed pin; Al-Mohy–Higham Taylor partition selection with a truncation-matched relaxed table). Truncation policy in one `PcStepConfig`: `max_basis` rank cap (primary dial), `admit_basis` working-set bound (displacement truncation), `drop_tol` prune, `tau_add` admission filter. - **Python**: `ppvm.Lindbladian` with a numpy-array hot path plus string-keyed convenience API; `mimalloc` as the extension's global allocator (~50% peak-RSS reduction on the allocation-heavy adaptive paths); two jupytext demos. - **Tests**: action/generator/leakage against dense-Liouvillian and Pauli-table references; adaptive-evolution convergence to a closed bilinear solution; `pc_step` pinned against `numpy.linalg.eig`; O(dt³) per-step scaling of the predictor-corrector. Benchmarks backing the design choices (190× vs Trotter at L=21 matched accuracy; external cross-validation vs Begušić & Chan, PRX Quantum 6, 020302 at L=41): ledgers arrive in PR 4 of the stack. The translation-symmetric (momentum-sector) variant follows in PR 3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: David Plankensteiner <david-pl@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: David Plankensteiner <dplankensteiner@quera.com>
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Unresolved critical and moderate correctness findings remain, and the broad Lindblad scope makes automated approval unsafe.
Review effort: Lite
Findings: 4
Open (9)
Verify Pauli word equality after hash lookup · New Validate array width before constructing Pauli words · New Propagate generator overflow instead of panicking · New Validate Pauli codes before uint8 coercion · New Reject indefinite Kossakowski matrices · New Bound leakage candidates despite cutoff ties · Newcheck_momentum_sectorreturns aResult, but it can still panic on common input-shape errors via… Keep ppvm-lindblad out of the symmetry-only PR · New TheCoefficientMismatchdisplay message is misleading: it says the orbit rep “expected c=…”,…
| let terms = self.compute_action_terms(p, s1, s2, lm); | ||
| let mut out = Vec::with_capacity(terms.len()); | ||
| for (w, v) in terms.iter() { | ||
| let h = word_hash(w); |
| let p_slice = p.as_slice()?; | ||
| let p_word = word_from_codes::<C>(p_slice).map_err(map_err)?; |
| let inner = core_sym::TranslationGroup::try_from_generators(n_qubits, perms, orders) | ||
| .map_err(|e| PyValueError::new_err(e.to_string()))?; |
| def _basis(basis_arr: npt.ArrayLike) -> np.ndarray: | ||
| return np.ascontiguousarray(basis_arr, dtype=np.uint8) | ||
|
|
| // A non-Hermitian K is not a valid GKSL pair matrix and would produce an | ||
| // action that does not preserve Hermiticity. | ||
| let tol = HERMITICITY_TOL * max_abs.max(1.0); | ||
| for (n, row_n) in k.iter().enumerate() { | ||
| for (m, k_nm) in row_n.iter().enumerate().skip(n) { | ||
| if (k_nm - k[m][n].conj()).norm() > tol { |
| let mut mags: Vec<f64> = merged.values().map(|v| v.mag()).collect(); | ||
| let k = room.min(mags.len() - 1); | ||
| let cutoff = nth_largest(&mut mags, k); | ||
| merged.retain(|_, v| v.mag() >= cutoff); |
| "crates/ppvm-pauli-word", | ||
| "crates/ppvm-pauli-sum", | ||
| "crates/ppvm-sym", | ||
| "crates/ppvm-lindblad", |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved correctness, panic-safety, truncation-bound, dependency, and API validation issues remain.
Review effort: Lite
Findings: 7
Open (14)
Large permutation orders panic instead of returning GroupError · New Generator overflow panics instead of returning GroupError · New CharacterTable validation ignores the originating group · New Validate Pauli codes before uint8 coercion Propagate generator overflow instead of panicking Validate array width before constructing Pauli words Verify Pauli word equality after hash lookup Tied cutoff values can exceed the max_basis cap · New Demo dependency group omits matplotlib · New Bound leakage candidates despite cutoff ties Reject indefinite Kossakowski matricescheck_momentum_sectorreturns aResult, but it can still panic on common input-shape errors via… Keep ppvm-lindblad out of the symmetry-only PR TheCoefficientMismatchdisplay message is misleading: it says the orbit rep “expected c=…”,…
| let exact = permutation_order(&perms[generator], generator); | ||
| if declared != exact { |
| let order = checked_group_order(&orders); | ||
| let phase_modulus = orders.iter().fold(1usize, |acc, &value| { | ||
| checked_lcm(acc, value as usize, "character phase modulus") | ||
| }); |
| assert_eq!( | ||
| table.len(), | ||
| self.order(), | ||
| "character table does not belong to this group" | ||
| ); |
| retain_in_place(basis, coeffs, |w, c| { | ||
| protected_set.contains(w) || c.mag() >= cutoff | ||
| }); |
| # Optional: only the `demo/` scripts use it (`expm_multiply` for the reference | ||
| # matrix exponential). Runtime ppvm and the test suite have no scipy dep. | ||
| demo = [ | ||
| "scipy>=1.13", | ||
| ] |



PR 1 of 4 splitting #178 into reviewable chunks (this → CTPP core → symmetry-merged evolution → ledgers). Full development history: branch
continuous-time-pauli-propagation.Adds
ppvm_pauli_sum::symmetry(pure Rust, no new dependencies):TranslationGroup: finite abelian permutation groups — 1D chains, 2D/3D tori, multi-leg ladders, or arbitrary generator lists — with lex-min orbit canonicalization, shift counters, and momentum-sector charactersχ_k(g).canonicalize_pauli_sum(Vec-pair form used by the upcoming adaptive evolution) andsymmetry_merge_pauli_sum(PauliSumform). Preserves all G-invariant expectation values for G-commuting dynamics (Theorem 1 of Teng, Chang, Rudolph & Holmes, arXiv:2512.12094).canonicalize_pauli_sum_complex(character-weighted projection with 1/|G| normalization) andcheck_momentum_sectorto validate inputs before projecting (silent projection discards physics).Tests: canonicalization/orbit properties on chains, tori, ladders; merge correctness; momentum-eigenstate round trips; a Trotter end-to-end check that per-step merging matches merge-at-end in the dt → 0 limit.
🤖 Generated with Claude Code