From d662d367f7a9abf91425da18fada4d0fb743fe03 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:09:07 +0200 Subject: [PATCH 01/14] docs(pauli-sum): design symmetry module split --- .../specs/2026-07-22-pr180-symmetry-design.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md diff --git a/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md b/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md new file mode 100644 index 00000000..b7d9c14e --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md @@ -0,0 +1,227 @@ +# Translation-Symmetry Module Design + +## Status + +This design applies to the translation-symmetry functionality introduced by +PR 180. Implementation should occur on a branch based on +`split/1-translation-symmetry`; follow-up PRs remain responsible for their own +Lindbladian, Python-binding, and benchmarking changes. + +## Goals + +- Split `symmetry.rs` along semantic boundaries without changing the public + `ppvm_pauli_sum::symmetry::*` import surface. +- Make `TranslationGroup` reject malformed generator descriptions eagerly. +- Give real-space merging and momentum projection explicit, distinct + semantics. +- Correct momentum projection for words with nontrivial stabilizers. +- Make momentum-sector validation detect missing orbit members and + stabilizer-incompatible sectors. +- Centralize group traversal so canonicalization, shift recovery, and orbit + iteration cannot diverge. + +## Non-goals + +- Moving functionality from PRs 181–183 into PR 180. +- Adding Python bindings or changing Python APIs; those belong to PR 182. +- Splitting individual lattice constructors into separate files. +- Generalizing beyond finite abelian permutation actions. +- Introducing a new fallible public constructor API in this change. + +## Module structure + +Replace the single source file with the following module: + +```text +crates/ppvm-pauli-sum/src/symmetry/ +├── mod.rs # module documentation and public re-exports +├── group.rs # TranslationGroup, construction, traversal, canonicalization +├── merge.rs # unnormalized k=0 Vec and PauliSum merging +├── momentum.rs # characters, normalized projection, sector errors/checking +└── tests.rs # unit and regression tests for the complete module +``` + +`lib.rs` continues to declare `pub mod symmetry`. `mod.rs` re-exports the +existing public names, so consumers do not need to change imports. Internal +traversal helpers are `pub(super)` at most. + +`canonicalize_with_shift` remains in `group.rs`: although momentum code is its +current consumer, it is fundamentally an orbit operation. `character` belongs +in `momentum.rs` and may be implemented as an additional inherent +`TranslationGroup` implementation using the public generator accessors. + +## Group model and validation + +`TranslationGroup` represents an abstract product +`G = C_orders[0] × ... × C_orders[r-1]` together with a commuting permutation +action on qubits. The action may have a kernel: different elements of `G` may +produce the same qubit permutation or fix a particular Pauli word. Consequently, +`order()` returns the order of the abstract parameter group, while a word's +distinct orbit can be smaller. + +This definition is preferred over requiring a faithful action. Proving +faithfulness by enumerating every composed permutation costs +`O(|G| × n_qubits)` during construction and would reject useful descriptions +that the stabilizer-aware projection can handle correctly. Two alternatives +were considered and rejected: + +1. Require all generated permutations to be unique. This makes `order()` equal + the image-group order but introduces potentially prohibitive construction + work. +2. Support only the built-in lattice constructors. This avoids arbitrary-group + validation but removes an intentional public extension point. + +`from_generators` retains its current infallible signature and validates: + +- equal numbers of permutations and orders; +- nonzero orders; +- permutation length, range, and uniqueness; +- `perm^order == identity` for every generator; +- pairwise commutativity of the generator permutations; and +- checked multiplication of generator orders into a cached `usize` group + order. + +The built-in constructors additionally reject zero dimensions, use checked +dimension products, and reject values that cannot be represented by their +`u32` permutation/order storage. Assertions must have precise messages. A +future fallible constructor can be added separately if callers need to recover +from invalid input. + +All public operations perform release-mode shape validation. In particular, +word width must match `n_qubits`, and `character` requires one momentum mode +and one counter per generator. + +## Group traversal + +`group.rs` owns one internal mixed-radix traversal primitive. It maintains the +current counter and Pauli word as an odometer. Incrementing a digit applies its +generator once; rollover applies the same generator once more, returning that +digit to the identity before carrying. Order-one generators are skipped. + +The traversal yields `(word, counter)` for every element of the abstract group, +including duplicate words caused by stabilizers. It uses `usize` for radix +arithmetic and casts only a checked per-digit remainder to `u32`. + +`canonicalize`, `canonicalize_with_shift`, and `orbit` all consume this +primitive. `canonicalize_with_shift` returns the inverse counter taking the +chosen representative back to the input word. When several counters describe +the same mapping, it returns the first in traversal order; momentum code must +not assume that this counter is unique. + +With order-one generators removed from carry processing, traversal performs an +amortized constant number of generator applications per group element, giving +the documented `O(|G| × n_qubits)` time and `O(r)` counter state. + +## Merge semantics + +The APIs intentionally expose two different coefficient conventions: + +- `canonicalize_pauli_sum` and `symmetry_merge_pauli_sum` perform an + **unnormalized merge**. Coefficients of input entries with the same orbit + representative are summed. +- `canonicalize_pauli_sum_complex` performs a **normalized momentum + projection**. Its output coefficient is the projected state's coefficient + on the chosen representative. + +The complex projection first coalesces duplicate input Pauli entries. For each +represented orbit it enumerates the abstract group action and determines: + +1. the distinct orbit members and their character phases; and +2. whether the requested character is trivial on the representative's + stabilizer. + +If the character is nontrivial on the stabilizer, the group projector vanishes +on that orbit and the orbit is omitted from the result. Otherwise the projected +representative coefficient is the average of the phase-adjusted coefficients +over the **distinct orbit**, with absent input members contributing zero and +normalization by the orbit size rather than by `|G|`. +This is equivalent to the full `1/|G|` group projector because every distinct +orbit member occurs once per stabilizer element. + +For `k = 0`, the complex routine is an orbit average, not the unnormalized real +merge. Documentation and test names must state that distinction explicitly. + +## Momentum-sector validation + +`check_momentum_sector` validates the represented operator, not merely pairs of +entries that happen to be present. It therefore: + +1. coalesces duplicate Pauli entries; +2. chooses each unvisited orbit representative and infers its coefficient from + the first present nonzero member; +3. enumerates all group elements acting on that representative; +4. verifies that repeated occurrences of the same word have compatible + character phases, thereby checking stabilizer compatibility; and +5. compares every distinct orbit member's expected coefficient with its actual + coefficient, treating a missing entry as zero. + +Relative comparison continues to use `tol * max(|reference|, 1)`. Exact-zero +orbits produced by coalescing are ignored. + +`SectorCheckError` gains a public `kind: SectorCheckErrorKind` field, where the +kind is either `CoefficientMismatch` or `IncompatibleStabilizer`. The error +retains the representative, offending or missing word, expected coefficient, +actual coefficient, and shift. Its `Debug` and `Display` implementations print +the relevant Pauli words; the necessary hasher bounds are placed on those +formatting implementations. + +## Tests + +The split must preserve all existing tests and add focused regressions for: + +- zero generator orders and zero lattice dimensions; +- incorrect generator periods and noncommuting generators; +- checked group-order and lattice-dimension multiplication; +- public word-width, momentum-length, and counter-length checks; +- mixed-radix decoding without pre-modulo `u32` truncation; +- agreement of the shared traversal with brute-force composition on small 1D, + 2D, 3D, and ladder groups; +- a period-two word on a four-site chain round-tripping in `k=0`; +- the same orbit round-tripping in a compatible nonzero momentum sector; +- an incompatible momentum character vanishing on projection; +- rejection of a basis with missing orbit members; +- rejection of a nonzero coefficient with an incompatible stabilizer; +- acceptance of existing complete momentum eigenstates; and +- the documented normalization difference between real merge and complex + projection. + +Run `cargo fmt --check`, `cargo test -p ppvm-pauli-sum`, and +`cargo test --workspace` before updating PR 180. + +## Stacked-PR integration + +PR 180 owns the Rust core module split and all correctness changes above. Once +it is updated, rebase descendants in this order: + +```text +split/1-translation-symmetry +└── split/2-ctpp-core # PR 181 + └── split/3-symmetric-evolution # PR 182 + ├── split/4-autotune-ledgers # PR 183 + └── kossakowski-dissipator # PR 179 +``` + +PR 181 uses only the stable real-space API and should need no source changes. +PR 182 is the first momentum consumer and must update its Rust/Python comments, +wrappers, and tests that currently describe a fixed `1/|G|` prefactor. Add a +Python period-two regression there after rebasing. PRs 183 and 179 should then +be rebased without importing their features into PR 180. + +Review-thread replies should consolidate duplicates: generator validation, +`k=0` documentation, mixed-radix decoding, word-width checks, and zero-sized +constructors each have multiple Copilot threads. Resolve them only after the +corresponding implementation and regression tests are present. + +## Acceptance criteria + +- Existing public Rust import paths compile unchanged. +- Each new source file has one clear responsibility as described above. +- All malformed group definitions covered by the constructor contract fail + immediately with clear messages. +- Complex projection round-trips valid eigenstates with both full and reduced + orbits. +- Sector validation rejects missing members and incompatible stabilizers. +- Real merge remains unnormalized and behavior-compatible. +- The full Rust workspace passes after the split. +- Descendant PRs can be rebased in order without pulling their functionality + into PR 180. From 9a558644054a92cd0c5ef4f306e0498fa4abfc7a Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:15:27 +0200 Subject: [PATCH 02/14] docs(pauli-sum): refine symmetry design --- .../specs/2026-07-22-pr180-symmetry-design.md | 93 +++++++++++++------ 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md b/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md index b7d9c14e..94a32bb2 100644 --- a/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md +++ b/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md @@ -54,10 +54,12 @@ in `momentum.rs` and may be implemented as an additional inherent `TranslationGroup` represents an abstract product `G = C_orders[0] × ... × C_orders[r-1]` together with a commuting permutation -action on qubits. The action may have a kernel: different elements of `G` may -produce the same qubit permutation or fix a particular Pauli word. Consequently, -`order()` returns the order of the abstract parameter group, while a word's -distinct orbit can be smaller. +action on qubits. Every declared `orders[g]` is the exact order of its generator +permutation, preserving the existing `generator_order()` contract. Relations +between different generators may still give the combined action a kernel: +different elements of `G` may produce the same qubit permutation or fix a +particular Pauli word. Consequently, `order()` returns the order of the abstract +parameter group, while a word's distinct orbit can be smaller. This definition is preferred over requiring a faithful action. Proving faithfulness by enumerating every composed permutation costs @@ -65,9 +67,9 @@ faithfulness by enumerating every composed permutation costs that the stabilizer-aware projection can handle correctly. Two alternatives were considered and rejected: -1. Require all generated permutations to be unique. This makes `order()` equal - the image-group order but introduces potentially prohibitive construction - work. +1. Require all composed generator products to be unique. This makes `order()` + equal the image-group order but introduces potentially prohibitive + construction work. 2. Support only the built-in lattice constructors. This avoids arbitrary-group validation but removes an intentional public extension point. @@ -76,11 +78,17 @@ were considered and rejected: - equal numbers of permutations and orders; - nonzero orders; - permutation length, range, and uniqueness; -- `perm^order == identity` for every generator; +- the exact permutation order equals the declared order for every generator; - pairwise commutativity of the generator permutations; and - checked multiplication of generator orders into a cached `usize` group order. +Exact permutation orders are computed in `O(n_qubits)` per generator from the +permutation's cycle lengths. The cycle-length least common multiple is checked +and compared with the declared `u32` order; validation must not apply a +generator `order` times. This rejects both an order that is too small and an +inflated multiple of the actual order. + The built-in constructors additionally reject zero dimensions, use checked dimension products, and reject values that cannot be represented by their `u32` permutation/order storage. Assertions must have precise messages. A @@ -91,6 +99,20 @@ All public operations perform release-mode shape validation. In particular, word width must match `n_qubits`, and `character` requires one momentum mode and one counter per generator. +The group also caches `phase_modulus = lcm(orders)`, using `1` for the trivial +group with no generators. It fits in `usize` because it divides the already +checked product of the orders. Momentum code represents a character phase +exactly by the numerator + +```text +Σ_g ((k[g] rem_euclid orders[g]) * counter[g] mod orders[g]) + * (phase_modulus / orders[g]) mod phase_modulus. +``` + +Terms are evaluated with `u128` intermediates. A character is trivial exactly +when this numerator is zero; conversion to `Complex` happens only when a +numeric phase is required for a coefficient. + ## Group traversal `group.rs` owns one internal mixed-radix traversal primitive. It maintains the @@ -130,11 +152,14 @@ represented orbit it enumerates the abstract group action and determines: 2. whether the requested character is trivial on the representative's stabilizer. -If the character is nontrivial on the stabilizer, the group projector vanishes -on that orbit and the orbit is omitted from the result. Otherwise the projected -representative coefficient is the average of the phase-adjusted coefficients -over the **distinct orbit**, with absent input members contributing zero and -normalization by the orbit size rather than by `|G|`. +Stabilizer compatibility is decided with the exact character numerator, never +by comparing `Complex` values. If the character is nontrivial on the +stabilizer, the group projector vanishes on that orbit and the orbit is omitted +from the result. Otherwise every distinct orbit member has a well-defined phase, +and the projected representative coefficient is the average of the +phase-adjusted coefficients over the **distinct orbit**, with absent input +members contributing zero and normalization by the orbit size rather than by +`|G|`. This is equivalent to the full `1/|G|` group projector because every distinct orbit member occurs once per stabilizer element. @@ -146,41 +171,51 @@ merge. Documentation and test names must state that distinction explicitly. `check_momentum_sector` validates the represented operator, not merely pairs of entries that happen to be present. It therefore: -1. coalesces duplicate Pauli entries; -2. chooses each unvisited orbit representative and infers its coefficient from +1. rejects a non-finite or negative tolerance and any coefficient with a + non-finite real or imaginary component; +2. coalesces duplicate Pauli entries; +3. chooses each unvisited orbit representative and infers its coefficient from the first present nonzero member; -3. enumerates all group elements acting on that representative; -4. verifies that repeated occurrences of the same word have compatible - character phases, thereby checking stabilizer compatibility; and -5. compares every distinct orbit member's expected coefficient with its actual +4. enumerates all group elements acting on that representative; +5. verifies with exact character numerators that repeated occurrences of the + same word have compatible phases, thereby checking stabilizer compatibility; + and +6. compares every distinct orbit member's expected coefficient with its actual coefficient, treating a missing entry as zero. Relative comparison continues to use `tol * max(|reference|, 1)`. Exact-zero orbits produced by coalescing are ignored. -`SectorCheckError` gains a public `kind: SectorCheckErrorKind` field, where the -kind is either `CoefficientMismatch` or `IncompatibleStabilizer`. The error -retains the representative, offending or missing word, expected coefficient, -actual coefficient, and shift. Its `Debug` and `Display` implementations print -the relevant Pauli words; the necessary hasher bounds are placed on those -formatting implementations. +`SectorCheckError` becomes a public enum with four variants: + +- `InvalidTolerance { tol }`; +- `NonFiniteCoefficient { pauli, coeff }`; +- `CoefficientMismatch { rep, offending_pauli, expected, actual, shift }`; and +- `IncompatibleStabilizer { rep, shift }`. + +Its `Debug` and `Display` implementations print the relevant Pauli words; the +necessary hasher bounds are placed on those formatting implementations. The +type name and function signature remain stable, and current stacked consumers +only format the error rather than constructing or matching it. ## Tests The split must preserve all existing tests and add focused regressions for: - zero generator orders and zero lattice dimensions; -- incorrect generator periods and noncommuting generators; +- incorrect, inflated, and noncommuting generator orders; - checked group-order and lattice-dimension multiplication; - public word-width, momentum-length, and counter-length checks; -- mixed-radix decoding without pre-modulo `u32` truncation; -- agreement of the shared traversal with brute-force composition on small 1D, - 2D, 3D, and ladder groups; +- exact character numerators for negative modes and compatible/incompatible + stabilizers; +- odometer counter rollover and agreement of the shared traversal with + brute-force composition on small 1D, 2D, 3D, and ladder groups; - a period-two word on a four-site chain round-tripping in `k=0`; - the same orbit round-tripping in a compatible nonzero momentum sector; - an incompatible momentum character vanishing on projection; - rejection of a basis with missing orbit members; - rejection of a nonzero coefficient with an incompatible stabilizer; +- rejection of negative/NaN tolerances and non-finite coefficients; - acceptance of existing complete momentum eigenstates; and - the documented normalization difference between real merge and complex projection. From 6c1c5e5866f1c60ca790d8e3dfcf7a9d15b59941 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:21:20 +0200 Subject: [PATCH 03/14] docs(pauli-sum): plan symmetry module update --- ...026-07-22-pr180-symmetry-implementation.md | 1071 +++++++++++++++++ 1 file changed, 1071 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md diff --git a/docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md b/docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md new file mode 100644 index 00000000..379c3f0c --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md @@ -0,0 +1,1071 @@ +# PR 180 Translation-Symmetry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split PR 180's translation-symmetry implementation into focused modules and correct group validation, traversal, momentum projection, and sector checking without changing existing public import paths. + +**Architecture:** Keep `ppvm_pauli_sum::symmetry` as the public facade. Put group construction and a shared odometer traversal in `group.rs`, unnormalized real merging in `merge.rs`, and exact-character momentum operations in `momentum.rs`; `mod.rs` re-exports the existing API. Treat each declared generator order as exact while allowing relations between different generators, and use exact modular character numerators to handle stabilizers. + +**Tech Stack:** Rust 2024, `PauliWord`, `PauliSum`, `fxhash`, `num::Complex`, Cargo workspace tests. + +--- + +## File map + +- Delete: `crates/ppvm-pauli-sum/src/symmetry.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/mod.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/group.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/merge.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` +- Create: `crates/ppvm-pauli-sum/tests/symmetry_api.rs` +- Preserve: `crates/ppvm-pauli-sum/src/lib.rs` (`pub mod symmetry;` remains unchanged) + +Every new Rust file starts with the repository's SPDX header. + +## Task 1: Split the module without changing behavior + +**Files:** + +- Delete: `crates/ppvm-pauli-sum/src/symmetry.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/mod.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/group.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/merge.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` +- Create: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` +- Create: `crates/ppvm-pauli-sum/tests/symmetry_api.rs` + +- [ ] **Step 1: Establish the behavioral baseline** + +Run: + +```bash +cargo test -p ppvm-pauli-sum symmetry::tests +``` + +Expected: all 15 existing symmetry unit tests pass. + +- [ ] **Step 2: Add an external public-surface smoke test** + +Create `crates/ppvm-pauli-sum/tests/symmetry_api.rs`: + +```rust +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use num::Complex; +use ppvm_pauli_sum::symmetry::{ + TranslationGroup, canonicalize_pauli_sum, canonicalize_pauli_sum_complex, + check_momentum_sector, +}; +use ppvm_pauli_word::word::PauliWord; + +type W = PauliWord<[u8; 1], fxhash::FxBuildHasher, true>; + +#[test] +fn public_symmetry_imports_remain_available() { + let group = TranslationGroup::chain_1d(2); + + let mut real_basis: Vec = vec![W::from("XI"), W::from("IX")]; + let mut real_coeffs = vec![1.0, 1.0]; + canonicalize_pauli_sum(&mut real_basis, &mut real_coeffs, &group); + assert_eq!(real_basis.len(), 1); + + let mut complex_basis: Vec = vec![W::from("ZI"), W::from("IZ")]; + let mut complex_coeffs = vec![Complex::new(1.0, 0.0); 2]; + assert!(check_momentum_sector( + &complex_basis, + &complex_coeffs, + &group, + &[0], + 1e-12, + ) + .is_ok()); + canonicalize_pauli_sum_complex( + &mut complex_basis, + &mut complex_coeffs, + &group, + &[0], + ); + assert_eq!(complex_basis.len(), 1); +} +``` + +- [ ] **Step 3: Move symbols to their semantic owners** + +Use this exact routing, making no body or documentation changes yet: + +| Destination | Symbols/content | +| --- | --- | +| `mod.rs` | Existing module-level docs, module declarations, public re-exports | +| `group.rs` | `TranslationGroup` and its complete current inherent implementation | +| `merge.rs` | `canonicalize_pauli_sum`, `symmetry_merge_pauli_sum` | +| `momentum.rs` | `character` inherent impl, `canonicalize_pauli_sum_complex`, `check_momentum_sector`, `SectorCheckError` and formatting impls | +| `tests.rs` | Existing `tests` module contents without the outer `mod tests { ... }` wrapper | + +Start `tests.rs` with the imports that were previously inherited from the +monolithic parent module: + +```rust +use super::*; +use fxhash::FxHashMap; +use num::Complex; +use ppvm_pauli_word::word::PauliWord; +use std::f64::consts::PI; +``` + +`mod.rs` must end with this facade: + +```rust +mod group; +mod merge; +mod momentum; + +pub use group::TranslationGroup; +pub use merge::{canonicalize_pauli_sum, symmetry_merge_pauli_sum}; +pub use momentum::{ + SectorCheckError, canonicalize_pauli_sum_complex, check_momentum_sector, +}; + +#[cfg(test)] +mod tests; +``` + +Move `character` out of the main `TranslationGroup` impl into this additional +impl in `momentum.rs`: + +```rust +impl TranslationGroup { + pub fn character(&self, k_modes: &[i32], counter: &[u32]) -> Complex { + // Move the existing body verbatim in this task. + } +} +``` + +- [ ] **Step 4: Verify the behavior-neutral split** + +Run: + +```bash +cargo fmt --all +cargo test -p ppvm-pauli-sum symmetry +cargo test -p ppvm-pauli-sum --test symmetry_api +``` + +Expected: the 15 unit tests and the public-surface integration test pass. + +- [ ] **Step 5: Commit the structural split** + +```bash +git add crates/ppvm-pauli-sum/src/symmetry.rs \ + crates/ppvm-pauli-sum/src/symmetry \ + crates/ppvm-pauli-sum/tests/symmetry_api.rs +git commit -m "refactor(pauli-sum): split translation symmetry module" +``` + +## Task 2: Enforce the translation-group construction contract + +**Files:** + +- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` + +- [ ] **Step 1: Add failing constructor-validation tests** + +Append these tests to `tests.rs`: + +```rust +#[test] +#[should_panic(expected = "generator 0 order must be nonzero")] +fn rejects_zero_generator_order() { + TranslationGroup::from_generators(2, vec![vec![1, 0]], vec![0]); +} + +#[test] +#[should_panic(expected = "declared order 4 != exact permutation order 2")] +fn rejects_inflated_generator_order() { + TranslationGroup::from_generators(2, vec![vec![1, 0]], vec![4]); +} + +#[test] +#[should_panic(expected = "generators 0 and 1 do not commute")] +fn rejects_noncommuting_generators() { + let swap_01 = vec![1, 0, 2]; + let swap_12 = vec![0, 2, 1]; + TranslationGroup::from_generators(3, vec![swap_01, swap_12], vec![2, 2]); +} + +#[test] +fn rejects_zero_lattice_dimensions() { + assert!(std::panic::catch_unwind(|| TranslationGroup::chain_1d(0)).is_err()); + assert!(std::panic::catch_unwind(|| TranslationGroup::torus_2d(0, 2)).is_err()); + assert!(std::panic::catch_unwind(|| TranslationGroup::torus_3d(2, 0, 2)).is_err()); + assert!(std::panic::catch_unwind(|| TranslationGroup::ladder(2, 0)).is_err()); +} + +#[test] +fn rejects_dimension_product_overflow_before_allocation() { + assert!( + std::panic::catch_unwind(|| TranslationGroup::torus_2d(usize::MAX, 2)).is_err() + ); + assert!( + std::panic::catch_unwind(|| TranslationGroup::ladder(usize::MAX, 2)).is_err() + ); +} + +#[test] +fn rejects_group_order_overflow() { + let orders = if usize::BITS == 64 { + vec![u32::MAX, u32::MAX, u32::MAX] + } else { + vec![u32::MAX, u32::MAX] + }; + assert!(std::panic::catch_unwind(|| { + super::group::checked_group_order(&orders) + }) + .is_err()); +} +``` + +- [ ] **Step 2: Run the new tests and confirm the old behavior fails** + +Run: + +```bash +cargo test -p ppvm-pauli-sum symmetry::tests::rejects_ +``` + +Expected: at least the zero-order, inflated-order, and noncommuting tests fail. + +- [ ] **Step 3: Add checked arithmetic and exact permutation-order helpers** + +Add these private helpers to `group.rs`: + +```rust +fn gcd(mut a: usize, mut b: usize) -> usize { + while b != 0 { + (a, b) = (b, a % b); + } + a +} + +fn checked_lcm(a: usize, b: usize, context: &str) -> usize { + a.checked_div(gcd(a, b)) + .and_then(|q| q.checked_mul(b)) + .unwrap_or_else(|| panic!("{context} overflow")) +} + +fn permutation_order(perm: &[u32], generator: usize) -> u32 { + let mut seen = vec![false; perm.len()]; + let mut order = 1usize; + for start in 0..perm.len() { + if seen[start] { + continue; + } + let mut length = 0usize; + let mut q = start; + loop { + assert!(!seen[q], "generator {generator} contains a malformed cycle"); + seen[q] = true; + length += 1; + q = perm[q] as usize; + if q == start { + break; + } + } + order = checked_lcm(order, length, "permutation order"); + } + u32::try_from(order).unwrap_or_else(|_| { + panic!("generator {generator} exact permutation order does not fit in u32") + }) +} + +fn permutations_commute(left: &[u32], right: &[u32]) -> bool { + (0..left.len()).all(|q| { + left[right[q] as usize] == right[left[q] as usize] + }) +} + +pub(super) fn checked_group_order(orders: &[u32]) -> usize { + orders.iter().enumerate().fold(1usize, |acc, (g, &value)| { + acc.checked_mul(value as usize) + .unwrap_or_else(|| panic!("group order overflows usize at generator {g}")) + }) +} +``` + +- [ ] **Step 4: Cache validated group metadata** + +Extend the private fields: + +```rust +pub struct TranslationGroup { + n_qubits: usize, + perms: Vec>, + orders: Vec, + order: usize, + phase_modulus: usize, +} +``` + +After validating each permutation's length/range/uniqueness, validate its exact +order and pairwise commutativity. Compute metadata with checked folds: + +```rust +for (g, &declared) in orders.iter().enumerate() { + assert!(declared != 0, "generator {g} order must be nonzero"); + let exact = permutation_order(&perms[g], g); + assert_eq!( + declared, exact, + "generator {g} declared order {declared} != exact permutation order {exact}", + ); +} +for left in 0..perms.len() { + for right in left + 1..perms.len() { + assert!( + permutations_commute(&perms[left], &perms[right]), + "generators {left} and {right} do not commute", + ); + } +} +let order = checked_group_order(&orders); +let phase_modulus = orders.iter().fold(1usize, |acc, &value| { + checked_lcm(acc, value as usize, "character phase modulus") +}); +``` + +Return cached `self.order` from `order()`. + +- [ ] **Step 5: Make lattice constructors fail before arithmetic/allocation** + +At the start of each constructor, assert every dimension is positive. Convert +each generator order with `u32::try_from`, form site counts with `checked_mul`, +and convert every generated target index with `u32::try_from`. Use messages that +name the constructor and offending dimension/product. + +- [ ] **Step 6: Run validation tests and the crate suite** + +```bash +cargo fmt --all +cargo test -p ppvm-pauli-sum symmetry::tests::rejects_ +cargo test -p ppvm-pauli-sum symmetry +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit group validation** + +```bash +git add crates/ppvm-pauli-sum/src/symmetry/group.rs \ + crates/ppvm-pauli-sum/src/symmetry/tests.rs +git commit -m "fix(pauli-sum): validate translation groups" +``` + +## Task 3: Replace duplicated reconstruction with one odometer traversal + +**Files:** + +- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` + +- [ ] **Step 1: Add failing traversal and release-check tests** + +```rust +#[test] +fn odometer_yields_expected_counter_order() { + let group = TranslationGroup::torus_2d(2, 3); + let counters: Vec> = group + .orbit_with_counters(&word("XIIIII")) + .map(|(_, counter)| counter) + .collect(); + assert_eq!( + counters, + vec![ + vec![0, 0], vec![1, 0], vec![0, 1], + vec![1, 1], vec![0, 2], vec![1, 2], + ], + ); +} + +#[test] +fn traversal_matches_brute_force_composition() { + let group = TranslationGroup::torus_2d(2, 3); + let source = word("XYZIII"); + for (candidate, counter) in group.orbit_with_counters(&source) { + let mut brute = source; + for (g, &count) in counter.iter().enumerate() { + for _ in 0..count { + brute = group.apply_generator(&brute, g); + } + } + assert_eq!(candidate, brute); + } +} + +#[test] +fn public_word_width_checks_are_not_debug_only() { + let group = TranslationGroup::chain_1d(4); + let short = word("XI"); + assert!(std::panic::catch_unwind(|| group.canonicalize(&short)).is_err()); + assert!(std::panic::catch_unwind(|| group.canonicalize_with_shift(&short)).is_err()); + assert!(std::panic::catch_unwind(|| group.orbit(&short)).is_err()); +} +``` + +- [ ] **Step 2: Run the tests and confirm the traversal API is absent** + +```bash +cargo test -p ppvm-pauli-sum symmetry::tests::odometer_yields_expected_counter_order +``` + +Expected: compilation fails because `orbit_with_counters` does not exist. + +- [ ] **Step 3: Implement the shared iterator** + +Add this iterator to `group.rs` and make `apply_generator` `pub(super)`: + +```rust +pub(super) struct GroupOrbit<'a, A, S, const R: bool> +where + A: PauliStorage, +{ + group: &'a TranslationGroup, + current: PauliWord, + counter: Vec, + remaining: usize, +} + +impl Iterator for GroupOrbit<'_, A, S, R> +where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + type Item = (PauliWord, Vec); + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let item = (self.current, self.counter.clone()); + self.remaining -= 1; + if self.remaining == 0 { + return Some(item); + } + for g in 0..self.group.orders.len() { + if self.group.orders[g] == 1 { + continue; + } + self.current = self.group.apply_generator(&self.current, g); + self.counter[g] += 1; + if self.counter[g] < self.group.orders[g] { + break; + } + self.counter[g] = 0; + } + Some(item) + } +} +``` + +Add the constructor, asserting width before returning the lazy iterator: + +```rust +pub(super) fn orbit_with_counters<'a, A, S, const R: bool>( + &'a self, + word: &'a PauliWord, +) -> GroupOrbit<'a, A, S, R> +where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!(word.n_qubits(), self.n_qubits, "word and group must agree on n_qubits"); + GroupOrbit { + group: self, + current: *word, + counter: vec![0; self.orders.len()], + remaining: self.order, + } +} +``` + +- [ ] **Step 4: Rewrite all three consumers** + +Use these bodies so all consumers share traversal and preserve the first +counter on equal representatives: + +```rust +// canonicalize +let mut traversal = self.orbit_with_counters(word); +let (mut best, _) = traversal.next().expect("a finite group contains the identity"); +for (candidate, _) in traversal { + if candidate < best { + best = candidate; + } +} +best + +// canonicalize_with_shift +let mut traversal = self.orbit_with_counters(word); +let (mut best, mut counter_from_word) = + traversal.next().expect("a finite group contains the identity"); +for (candidate, counter) in traversal { + if candidate < best { + best = candidate; + counter_from_word = counter; + } +} +let counter_to_word = counter_from_word + .iter() + .zip(self.orders.iter()) + .map(|(&counter, &order)| (order - counter) % order) + .collect(); +(best, counter_to_word) + +// orbit +self.orbit_with_counters(word) + .map(|(candidate, _)| candidate) +``` + +- [ ] **Step 5: Run traversal, symmetry, and public API tests** + +```bash +cargo fmt --all +cargo test -p ppvm-pauli-sum symmetry::tests::odometer_ +cargo test -p ppvm-pauli-sum symmetry::tests::traversal_matches_ +cargo test -p ppvm-pauli-sum symmetry +cargo test -p ppvm-pauli-sum --test symmetry_api +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit traversal centralization** + +```bash +git add crates/ppvm-pauli-sum/src/symmetry/group.rs \ + crates/ppvm-pauli-sum/src/symmetry/tests.rs +git commit -m "refactor(pauli-sum): centralize symmetry traversal" +``` + +## Task 4: Add exact character arithmetic + +**Files:** + +- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` + +- [ ] **Step 1: Add exact-character tests** + +```rust +#[test] +fn character_numerator_normalizes_negative_modes() { + let group = TranslationGroup::chain_1d(4); + assert_eq!(group.character_numerator(&[-1], &[1]), 3); + assert_eq!(group.character_numerator(&[3], &[1]), 3); + assert!((group.character(&[-1], &[1]) - Complex::new(0.0, -1.0)).norm() < 1e-12); +} + +#[test] +fn exact_character_detects_cross_generator_kernel() { + let swap = vec![1, 0]; + let group = TranslationGroup::from_generators( + 2, + vec![swap.clone(), swap], + vec![2, 2], + ); + assert_ne!(group.character_numerator(&[1, 0], &[1, 1]), 0); + assert_eq!(group.character_numerator(&[1, 1], &[1, 1]), 0); +} + +#[test] +fn character_checks_slice_lengths_in_release_builds() { + let group = TranslationGroup::chain_1d(4); + assert!(std::panic::catch_unwind(|| group.character(&[], &[0])).is_err()); + assert!(std::panic::catch_unwind(|| group.character(&[0], &[])).is_err()); +} +``` + +- [ ] **Step 2: Run the tests and confirm the exact helper is absent** + +```bash +cargo test -p ppvm-pauli-sum symmetry::tests::character_numerator_ +``` + +Expected: compilation fails because `character_numerator` does not exist. + +- [ ] **Step 3: Implement exact numerator calculation** + +Add a `pub(super)` getter for `phase_modulus` if momentum code needs it, then +implement this method in the `TranslationGroup` impl in `momentum.rs`: + +```rust +pub(super) fn character_numerator(&self, k_modes: &[i32], counter: &[u32]) -> usize { + assert_eq!(k_modes.len(), self.n_generators(), "k_modes length mismatch"); + assert_eq!(counter.len(), self.n_generators(), "counter length mismatch"); + let modulus = self.phase_modulus() as u128; + let mut numerator = 0u128; + for g in 0..self.n_generators() { + let order = self.generator_order(g); + let k = (k_modes[g] as i64).rem_euclid(order as i64) as u128; + let count = (counter[g] % order) as u128; + let reduced = (k * count) % order as u128; + let factor = self.phase_modulus() as u128 / order as u128; + numerator = (numerator + reduced * factor) % modulus; + } + numerator as usize +} +``` + +Rewrite `character` to convert only this exact numerator: + +```rust +let numerator = self.character_numerator(k_modes, counter); +let phase = 2.0 * PI * numerator as f64 / self.phase_modulus() as f64; +Complex::from_polar(1.0, phase) +``` + +- [ ] **Step 4: Run exact-character and full symmetry tests** + +```bash +cargo fmt --all +cargo test -p ppvm-pauli-sum symmetry::tests::character_ +cargo test -p ppvm-pauli-sum symmetry +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit exact character arithmetic** + +```bash +git add crates/ppvm-pauli-sum/src/symmetry/group.rs \ + crates/ppvm-pauli-sum/src/symmetry/momentum.rs \ + crates/ppvm-pauli-sum/src/symmetry/tests.rs +git commit -m "fix(pauli-sum): compute symmetry characters exactly" +``` + +## Task 5: Correct momentum projection for stabilizers + +**Files:** + +- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` + +- [ ] **Step 1: Add projection regressions** + +```rust +#[test] +fn period_two_k_zero_round_trip_preserves_rep_coefficient() { + let group = TranslationGroup::chain_1d(4); + let mut basis = vec![word("XIXI"), word("IXIX")]; + let mut coeffs = vec![Complex::new(1.0, 0.0); 2]; + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[0]); + assert_eq!(basis.len(), 1); + assert!((coeffs[0] - Complex::new(1.0, 0.0)).norm() < 1e-12); +} + +#[test] +fn period_two_compatible_k_two_round_trip_preserves_rep_coefficient() { + let group = TranslationGroup::chain_1d(4); + let rep = group.canonicalize(&word("XIXI")); + let mut members: FxHashMap> = FxHashMap::default(); + for (member, counter) in group.orbit_with_counters(&rep) { + members.entry(member).or_insert_with(|| group.character(&[2], &counter).conj()); + } + let (mut basis, mut coeffs): (Vec, Vec>) = members.into_iter().unzip(); + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[2]); + assert_eq!(basis, vec![rep]); + assert!((coeffs[0] - Complex::new(1.0, 0.0)).norm() < 1e-12); +} + +#[test] +fn incompatible_stabilizer_projects_orbit_to_zero() { + let group = TranslationGroup::chain_1d(4); + let mut basis = vec![word("XXXX")]; + let mut coeffs = vec![Complex::new(1.0, 0.0)]; + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[1]); + assert!(basis.is_empty()); + assert!(coeffs.is_empty()); +} + +#[test] +fn partial_period_two_orbit_is_averaged_with_missing_member_zero() { + let group = TranslationGroup::chain_1d(4); + let mut basis = vec![word("XIXI")]; + let mut coeffs = vec![Complex::new(1.0, 0.0)]; + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[0]); + assert_eq!(basis.len(), 1); + assert!((coeffs[0] - Complex::new(0.5, 0.0)).norm() < 1e-12); +} +``` + +- [ ] **Step 2: Run the regressions and verify current normalization fails** + +```bash +cargo test -p ppvm-pauli-sum symmetry::tests::period_two_ +cargo test -p ppvm-pauli-sum symmetry::tests::incompatible_stabilizer_projects_ +``` + +Expected: the period-two and incompatible-stabilizer tests fail. + +- [ ] **Step 3: Implement one stabilizer-aware projection per represented orbit** + +Replace the fixed `inv_g` loop with this data flow: + +```rust +let mut input: FxHashMap, Complex> = FxHashMap::default(); +for (word, &coeff) in basis.iter().zip(coeffs.iter()) { + *input.entry(*word).or_insert(Complex::new(0.0, 0.0)) += coeff; +} +let reps: FxHashSet<_> = input.keys().map(|word| group.canonicalize(word)).collect(); +let mut projected = FxHashMap::default(); + +for rep in reps { + let mut members: FxHashMap<_, (Vec, usize)> = FxHashMap::default(); + let mut compatible = true; + for (member, counter) in group.orbit_with_counters(&rep) { + let numerator = group.character_numerator(k_modes, &counter); + match members.entry(member) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((counter, numerator)); + } + std::collections::hash_map::Entry::Occupied(entry) => { + if entry.get().1 != numerator { + compatible = false; + break; + } + } + } + } + if !compatible { + continue; + } + let orbit_size = members.len() as f64; + let mut rep_coeff = Complex::new(0.0, 0.0); + for (member, (counter, _)) in members { + let coeff = input + .get(&member) + .copied() + .unwrap_or(Complex::new(0.0, 0.0)); + rep_coeff += group.character(k_modes, &counter) * coeff / orbit_size; + } + projected.insert(rep, rep_coeff); +} +``` + +Use `fxhash::FxHashSet`. Replace `basis` and `coeffs` from `projected` exactly +as the current function does from `merged`. Update the rustdoc to call `k=0` +an orbit average rather than a plain merge. Rename +`momentum_zero_complex_merge_matches_real_merge` to +`momentum_zero_complex_projection_is_orbit_average` and retain its assertions +that the real result is `10.0` while the complex result is `2.5`. + +- [ ] **Step 4: Run projection and existing momentum tests** + +```bash +cargo fmt --all +cargo test -p ppvm-pauli-sum symmetry::tests::period_two_ +cargo test -p ppvm-pauli-sum symmetry::tests::incompatible_stabilizer_projects_ +cargo test -p ppvm-pauli-sum symmetry::tests::momentum_ +``` + +Expected: all tests pass; the renamed `k=0` test still demonstrates real sum +`10.0` versus complex average `2.5`. + +- [ ] **Step 5: Commit projection correctness** + +```bash +git add crates/ppvm-pauli-sum/src/symmetry/momentum.rs \ + crates/ppvm-pauli-sum/src/symmetry/tests.rs +git commit -m "fix(pauli-sum): normalize momentum projection by orbit" +``` + +## Task 6: Make sector validation complete and diagnostic + +**Files:** + +- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` + +- [ ] **Step 1: Add validator regressions** + +```rust +#[test] +fn sector_check_rejects_missing_orbit_members() { + let group = TranslationGroup::chain_1d(4); + let basis = vec![word("ZIII")]; + let coeffs = vec![Complex::new(1.0, 0.0)]; + assert!(matches!( + check_momentum_sector(&basis, &coeffs, &group, &[0], 1e-12), + Err(SectorCheckError::CoefficientMismatch { .. }) + )); +} + +#[test] +fn sector_check_rejects_incompatible_stabilizer() { + let group = TranslationGroup::chain_1d(4); + let basis = vec![word("XXXX")]; + let coeffs = vec![Complex::new(1.0, 0.0)]; + assert!(matches!( + check_momentum_sector(&basis, &coeffs, &group, &[1], 1e-12), + Err(SectorCheckError::IncompatibleStabilizer { .. }) + )); +} + +#[test] +fn sector_check_rejects_invalid_numeric_inputs() { + let group = TranslationGroup::chain_1d(2); + let basis = vec![word("ZI")]; + assert!(matches!( + check_momentum_sector(&basis, &[Complex::new(1.0, 0.0)], &group, &[0], f64::NAN), + Err(SectorCheckError::InvalidTolerance { .. }) + )); + assert!(matches!( + check_momentum_sector(&basis, &[Complex::new(f64::NAN, 0.0)], &group, &[0], 1e-12), + Err(SectorCheckError::NonFiniteCoefficient { .. }) + )); +} + +#[test] +fn sector_error_display_names_the_words() { + let group = TranslationGroup::chain_1d(2); + let basis = vec![word("ZI")]; + let coeffs = vec![Complex::new(1.0, 0.0)]; + let message = check_momentum_sector(&basis, &coeffs, &group, &[0], 1e-12) + .unwrap_err() + .to_string(); + assert!(message.contains("ZI") || message.contains("IZ")); +} +``` + +- [ ] **Step 2: Run the regressions and confirm current validation accepts bad input** + +```bash +cargo test -p ppvm-pauli-sum symmetry::tests::sector_check_rejects_ +``` + +Expected: the missing-member, incompatible-stabilizer, and NaN tests fail. + +- [ ] **Step 3: Replace the error struct with explicit variants** + +```rust +pub enum SectorCheckError { + InvalidTolerance { tol: f64 }, + NonFiniteCoefficient { + pauli: PauliWord, + coeff: Complex, + }, + CoefficientMismatch { + rep: PauliWord, + offending_pauli: PauliWord, + expected: Complex, + actual: Complex, + shift: Vec, + }, + IncompatibleStabilizer { + rep: PauliWord, + shift: Vec, + }, +} +``` + +Implement `Debug` and `Display` with +`S: BuildHasher + Clone + Default + HashFinalize`; format `rep`, `pauli`, and +`offending_pauli` with `{}` rather than placeholders. + +- [ ] **Step 4: Validate the complete represented orbit** + +At function entry, return `InvalidTolerance` unless `tol.is_finite() && tol >= +0.0`, and return `NonFiniteCoefficient` for any non-finite component. Coalesce +duplicate words and remove exactly-zero totals. Build the set of canonical +representatives. For each representative: + +1. enumerate `orbit_with_counters` into a map from distinct word to its first + `(counter, exact_numerator)`; +2. return `IncompatibleStabilizer` if the same word reappears with a different + numerator; +3. select the first present nonzero member and infer + `rep_coeff = character(counter) * actual`; +4. for every distinct member compute + `expected = character(counter).conj() * rep_coeff`; +5. load absent members as `Complex::new(0.0, 0.0)` and return + `CoefficientMismatch` when + `(actual - expected).norm() > tol * rep_coeff.norm().max(1.0)`. + +Keep the existing assertions for basis/coeff and momentum-mode lengths. +Implement the core loop as follows after those assertions: + +```rust +if !tol.is_finite() || tol < 0.0 { + return Err(SectorCheckError::InvalidTolerance { tol }); +} +let mut input: FxHashMap, Complex> = FxHashMap::default(); +for (pauli, &coeff) in basis.iter().zip(coeffs.iter()) { + if !coeff.re.is_finite() || !coeff.im.is_finite() { + return Err(SectorCheckError::NonFiniteCoefficient { + pauli: *pauli, + coeff, + }); + } + *input.entry(*pauli).or_insert(Complex::new(0.0, 0.0)) += coeff; +} +input.retain(|_, coeff| *coeff != Complex::new(0.0, 0.0)); +let reps: FxHashSet<_> = input.keys().map(|pauli| group.canonicalize(pauli)).collect(); + +for rep in reps { + let mut members: FxHashMap<_, (Vec, usize)> = FxHashMap::default(); + for (member, counter) in group.orbit_with_counters(&rep) { + let numerator = group.character_numerator(k_modes, &counter); + match members.entry(member) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((counter, numerator)); + } + std::collections::hash_map::Entry::Occupied(entry) => { + if entry.get().1 != numerator { + return Err(SectorCheckError::IncompatibleStabilizer { + rep, + shift: counter, + }); + } + } + } + } + + let (reference_word, (reference_counter, _)) = members + .iter() + .find(|(member, _)| input.contains_key(*member)) + .expect("represented orbit has a nonzero member"); + let rep_coeff = group.character(k_modes, reference_counter) * input[reference_word]; + + for (member, (counter, _)) in members { + let expected = group.character(k_modes, &counter).conj() * rep_coeff; + let actual = input + .get(&member) + .copied() + .unwrap_or(Complex::new(0.0, 0.0)); + if (actual - expected).norm() > tol * rep_coeff.norm().max(1.0) { + return Err(SectorCheckError::CoefficientMismatch { + rep, + offending_pauli: member, + expected, + actual, + shift: counter, + }); + } + } +} +Ok(()) +``` + +- [ ] **Step 5: Run validator, projection, and public API tests** + +```bash +cargo fmt --all +cargo test -p ppvm-pauli-sum symmetry::tests::sector_ +cargo test -p ppvm-pauli-sum symmetry::tests::momentum_ +cargo test -p ppvm-pauli-sum --test symmetry_api +``` + +Expected: all tests pass, including the pre-existing valid `k=1` eigenstate. + +- [ ] **Step 6: Commit complete validation** + +```bash +git add crates/ppvm-pauli-sum/src/symmetry/momentum.rs \ + crates/ppvm-pauli-sum/src/symmetry/tests.rs +git commit -m "fix(pauli-sum): validate complete momentum sectors" +``` + +## Task 7: Finish documentation and verify the branch + +**Files:** + +- Modify: `crates/ppvm-pauli-sum/src/symmetry/mod.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` +- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` +- Verify: `crates/ppvm-pauli-sum/tests/symmetry_api.rs` + +- [ ] **Step 1: Align rustdoc with the implemented contracts** + +Update module and item docs to state all of the following explicitly: + +- generator orders are exact, but the combined action may have a kernel; +- `order()` is the abstract product order and `orbit()` may repeat words; +- `canonicalize_with_shift` returns the first valid counter and counters are not + unique in the presence of stabilizers; +- complex `k=0` projection averages a distinct orbit while real merging sums; +- incompatible stabilizer sectors project to zero; and +- `check_momentum_sector` treats absent orbit members as zero. + +- [ ] **Step 2: Run formatting, lint, tests, docs, and wasm verification** + +Run each command and stop on the first failure: + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo doc --workspace --no-deps +cargo build --target wasm32-unknown-unknown --workspace \ + --exclude ppvm-python-native --exclude ppvm-cli --exclude ppvm-tui +``` + +Expected: every command exits successfully with no warnings or failed tests. + +- [ ] **Step 3: Inspect the final public diff** + +```bash +git diff origin/split/1-translation-symmetry...HEAD --stat +git diff origin/split/1-translation-symmetry...HEAD -- crates/ppvm-pauli-sum/src/lib.rs +git status --short +``` + +Expected: `lib.rs` is unchanged, the old `symmetry.rs` is replaced by the five +semantic module files, the integration test is present, and the worktree is +clean except for intentional documentation changes. + +- [ ] **Step 4: Commit final rustdoc changes if Step 1 produced a diff** + +```bash +git add crates/ppvm-pauli-sum/src/symmetry +git commit -m "docs(pauli-sum): clarify symmetry projection semantics" +``` + +If Step 1 was already completed in earlier implementation commits and `git +status --short` is empty, do not create an empty commit. + +## Task 8: Prepare the stacked-PR handoff + +**Files:** None on this branch. + +- [ ] **Step 1: Record the descendant rebase order in the handoff** + +Use this exact order after the implementation branch is merged into +`split/1-translation-symmetry`: + +```text +split/2-ctpp-core +split/3-symmetric-evolution +split/4-autotune-ledgers +kossakowski-dissipator (also based on split/3-symmetric-evolution) +``` + +- [ ] **Step 2: Identify PR 182 follow-up edits without applying them here** + +After rebasing PR 182, update its fixed-`1/|G|` comments in +`crates/ppvm-python-native/src/interface.rs`, preserve the Rust re-export paths, +and add a Python period-two regression to +`ppvm-python/test/test_momentum_merge.py`. Run: + +```bash +uv run --project ppvm-python --group dev pytest ppvm-python/test/test_momentum_merge.py +``` + +Expected: the rebased Python momentum tests pass. Do not copy those binding or +Python changes into this PR 180 implementation branch. + +- [ ] **Step 3: Prepare review-thread responses** + +Group duplicate Copilot discussions under the commits that fix generator +validation, zero dimensions, shared traversal, runtime shape checks, `k=0` +documentation, stabilizer normalization, and error diagnostics. Resolve a +thread only after its regression test and implementation are both present. From 0b98abfa83c7613e4ff68ace6c0bcbe855429830 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:29:48 +0200 Subject: [PATCH 04/14] refactor(pauli-sum): split translation symmetry module Co-authored-by: Cursor --- crates/ppvm-pauli-sum/src/symmetry.rs | 958 ------------------ crates/ppvm-pauli-sum/src/symmetry/group.rs | 341 +++++++ crates/ppvm-pauli-sum/src/symmetry/merge.rs | 75 ++ crates/ppvm-pauli-sum/src/symmetry/mod.rs | 66 ++ .../ppvm-pauli-sum/src/symmetry/momentum.rs | 184 ++++ crates/ppvm-pauli-sum/src/symmetry/tests.rs | 327 ++++++ crates/ppvm-pauli-sum/tests/symmetry_api.rs | 26 + 7 files changed, 1019 insertions(+), 958 deletions(-) delete mode 100644 crates/ppvm-pauli-sum/src/symmetry.rs create mode 100644 crates/ppvm-pauli-sum/src/symmetry/group.rs create mode 100644 crates/ppvm-pauli-sum/src/symmetry/merge.rs create mode 100644 crates/ppvm-pauli-sum/src/symmetry/mod.rs create mode 100644 crates/ppvm-pauli-sum/src/symmetry/momentum.rs create mode 100644 crates/ppvm-pauli-sum/src/symmetry/tests.rs create mode 100644 crates/ppvm-pauli-sum/tests/symmetry_api.rs diff --git a/crates/ppvm-pauli-sum/src/symmetry.rs b/crates/ppvm-pauli-sum/src/symmetry.rs deleted file mode 100644 index 68537044..00000000 --- a/crates/ppvm-pauli-sum/src/symmetry.rs +++ /dev/null @@ -1,958 +0,0 @@ -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Lattice translation symmetry groups for operator-space Pauli evolution. -//! -//! A [`TranslationGroup`] represents a finite abelian group `G` acting on -//! qubit positions by permutations. Given such a group, every Pauli word -//! belongs to a translation orbit, and operator dynamics that commute -//! with `G` can be tracked using **one canonical representative per -//! orbit** instead of all `|G|` orbit members — reducing per-step memory -//! and compute by a factor up to `|G|`. -//! -//! Following Teng, Chang, Rudolph, and Holmes (arXiv:2512.12094), this -//! module implements **plain (real-coefficient) merging** of Pauli sums -//! into orbit-representative form — see [`canonicalize_pauli_sum`] and -//! [`symmetry_merge_pauli_sum`]. This handles observables in the trivial -//! (`k=0`) symmetry sector, e.g. sums of single-Z operators over the -//! lattice. -//! -//! **Non-trivial momentum sectors (`k ≠ 0`)** are handled by -//! [`canonicalize_pauli_sum_complex`], which folds with the character -//! phase `χ_k(g)` of each translation. On the Python side, an operator in -//! sector `k` is carried as a *real pair* (real + imaginary components, two -//! real `PauliSum`s) and merged via `PauliSum.momentum_merge`, which reuses -//! this routine — letting gate-based Trotter evolution stay symmetry- -//! compressed in any momentum sector with real coefficients throughout. -//! -//! ## Data model -//! -//! A `TranslationGroup` is specified by a list of generator permutations -//! and their cyclic orders. The group order is the product of the orders. -//! For instance, a 2D `L × L` torus has two generators (translation in -//! x and y) each of order `L`. -//! -//! ## Canonicalization -//! -//! [`TranslationGroup::canonicalize`] returns the **lex-minimum** Pauli -//! word reachable from the input via group action. The ordering is the -//! standard `Ord` impl on `PauliWord` (compare `xbits`, then `zbits`). -//! All orbit members canonicalize to the same representative; orbits are -//! disjoint by construction, so the rep uniquely identifies the orbit. -//! -//! ## Merging -//! -//! [`canonicalize_pauli_sum`] takes parallel `Vec` / `Vec` -//! buffers (the representation used by ppvm-lindblad's adaptive -//! evolution) and replaces each Pauli by its canonical rep, summing -//! coefficients for collisions. The output is an orbit-rep basis with -//! coefficients equal to the sum of the input coefficients over each -//! orbit's members. For dynamics that commute with `G` and initial -//! states that are also `G`-invariant, this preserves the expectation -//! value of any `G`-invariant observable (Theorem 1 of arXiv:2512.12094). -//! -//! See the dedicated tests for correctness against full-basis evolution -//! on small systems with no truncation. - -use crate::sum::PauliSum; -use fxhash::FxHashMap; -use num::Complex; -use ppvm_pauli_word::word::PauliWord; -use ppvm_traits::Config; -use ppvm_traits::{HashFinalize, PauliStorage, PauliWordTrait}; -use std::f64::consts::PI; -use std::hash::BuildHasher; - -/// A finite abelian symmetry group acting on qubit positions by -/// permutations. -/// -/// Build via the convenience constructors [`Self::chain_1d`], -/// [`Self::torus_2d`], [`Self::torus_3d`], [`Self::ladder`], or -/// [`Self::from_generators`] for an arbitrary list of generator -/// permutations. -/// -/// `perms[g]` is the permutation that **generator `g`** applies to qubit -/// indices: a qubit at position `q` moves to position `perms[g][q]` -/// under one application of generator `g`. `orders[g]` is the cyclic -/// order of generator `g` (i.e. applying it `orders[g]` times returns -/// the identity). The full group is the direct product of the cyclic -/// subgroups, with size `Π orders[g]`. -/// -/// Only the **generators** are stored; the algorithm in -/// [`Self::canonicalize`] walks the group via mixed-radix increments. -#[derive(Debug, Clone)] -pub struct TranslationGroup { - /// Number of qubits the group acts on. - n_qubits: usize, - /// One permutation per generator. `perms[g][q]` is the position - /// that qubit `q` maps to under one application of generator `g`. - perms: Vec>, - /// Cyclic order of each generator. - orders: Vec, -} - -impl TranslationGroup { - /// Construct from explicit generator permutations and orders. - /// - /// Each `perm` must be a permutation of `0..n_qubits`. Each `order` - /// must satisfy `perm^order == identity`. - pub fn from_generators(n_qubits: usize, perms: Vec>, orders: Vec) -> Self { - assert_eq!(perms.len(), orders.len(), "perms and orders must match"); - for (g, perm) in perms.iter().enumerate() { - assert_eq!( - perm.len(), - n_qubits, - "generator {g} permutation has length {} != n_qubits {n_qubits}", - perm.len() - ); - let mut seen = vec![false; n_qubits]; - for &p in perm { - assert!( - (p as usize) < n_qubits, - "generator {g} maps to out-of-range position {p}" - ); - assert!( - !seen[p as usize], - "generator {g} is not a permutation (duplicate target {p})" - ); - seen[p as usize] = true; - } - } - Self { - n_qubits, - perms, - orders, - } - } - - /// 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 = (0..n).map(|q| ((q + 1) % n) as u32).collect(); - Self::from_generators(n, vec![perm], vec![n as u32]) - } - - /// 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 = (0..n) - .map(|q| { - let (i, j) = (q % lx, q / lx); - (j * lx + (i + 1) % lx) as u32 - }) - .collect(); - let perm_y: Vec = (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]) - } - - /// 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 = (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 = (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 = (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], - ) - } - - /// 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 = (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]) - } - - /// Number of qubits the group acts on. - pub fn n_qubits(&self) -> usize { - self.n_qubits - } - - /// Number of generators (rank of the group as an abelian product). - pub fn n_generators(&self) -> usize { - self.perms.len() - } - - /// Total group order: `Π orders[g]`. - pub fn order(&self) -> usize { - self.orders.iter().map(|&o| o as usize).product() - } - - /// Permutation associated with the `g`-th generator (one application). - pub fn generator_perm(&self, g: usize) -> &[u32] { - &self.perms[g] - } - - /// Cyclic order of the `g`-th generator. - pub fn generator_order(&self, g: usize) -> u32 { - self.orders[g] - } - - /// Apply a single generator's permutation to a Pauli word, returning - /// the resulting word. - /// - /// For each qubit `q` of the input, the corresponding `(xbit, zbit)` - /// pair is placed at position `perm[q]` of the output. - fn apply_generator( - &self, - w: &PauliWord, - g: usize, - ) -> PauliWord - where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, - { - let perm = &self.perms[g]; - let mut out: PauliWord = PauliWord::new(self.n_qubits); - for (q, &pq) in perm.iter().enumerate().take(self.n_qubits) { - let xb = w.get_xbit(q); - let zb = w.get_zbit(q); - if xb { - out.set_xbit(pq as usize, true); - } - if zb { - out.set_zbit(pq as usize, true); - } - } - out.rehash(); - out - } - - /// Lex-min canonical representative of `w`'s translation orbit - /// under this group. Walks the full group via mixed-radix counters, - /// keeping the smallest word seen. - /// - /// Total cost: `O(|G| × n_qubits)` per call. - pub fn canonicalize(&self, w: &PauliWord) -> PauliWord - where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, - { - debug_assert_eq!( - w.n_qubits(), - self.n_qubits, - "word and group must agree on n_qubits" - ); - if self.perms.is_empty() { - return *w; - } - // Mixed-radix counter `(c[0], c[1], …)` ranges over - // `0..orders[0] × 0..orders[1] × …`. We track the "current" - // word obtained by applying generator `g` once each time - // `c[g]` increments; rolling over `c[g]` means we apply - // generator `g` exactly `orders[g]` times (= identity), so - // `cur` returns to the orbit member that had `c[g..]` as its - // tail and `0` in slots 0..g. - // - // The simplest correct implementation just enumerates: for each - // group element index, build the corresponding word from scratch - // by applying the right number of each generator. - let mut best = *w; - let order = self.order(); - let mut idx = 0usize; - while idx < order { - // Decode `idx` to mixed-radix counter `c` - let mut rem = idx; - let mut counters: Vec = Vec::with_capacity(self.perms.len()); - for &o in &self.orders { - counters.push((rem as u32) % o); - rem /= o as usize; - } - // Construct the group element's permutation by composing - // `generator g` applied `c[g]` times, for each g. - // We do this lazily by iterating over qubits. - let mut cur = *w; - for (g, &c) in counters.iter().enumerate() { - for _ in 0..c { - cur = self.apply_generator(&cur, g); - } - } - if cur < best { - best = cur; - } - idx += 1; - } - best - } - - /// Lex-min canonical representative `r` of `w` together with the - /// **mixed-radix counter** `c = (c_0, c_1, …)` of the group element - /// `g` such that `g·r = w`. - /// - /// In other words: if `r = self.canonicalize(w)`, this returns - /// `(r, c)` where applying generator `i` exactly `c[i]` times in - /// sequence to `r` produces `w`. The counter is used to compute - /// momentum phases by the phase-aware merge routines. - /// - /// Same `O(|G| × n_qubits)` cost as `canonicalize`. - pub fn canonicalize_with_shift( - &self, - w: &PauliWord, - ) -> (PauliWord, Vec) - where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, - { - debug_assert_eq!(w.n_qubits(), self.n_qubits); - if self.perms.is_empty() { - return (*w, Vec::new()); - } - let mut best = *w; - let mut best_counter: Vec = vec![0; self.perms.len()]; - let order = self.order(); - for idx in 0..order { - // Decode `idx` to mixed-radix counter. - let mut rem = idx; - let mut counter: Vec = Vec::with_capacity(self.perms.len()); - for &o in &self.orders { - counter.push((rem as u32) % o); - rem /= o as usize; - } - // Build the candidate by applying generator `g` exactly - // `counter[g]` times. - let mut cur = *w; - for (g, &c) in counter.iter().enumerate() { - for _ in 0..c { - cur = self.apply_generator(&cur, g); - } - } - if cur < best { - best = cur; - // We need the counter such that g·best = w. The loop - // above computed cur = g·w with counter, so w = g^{-1}·cur. - // For abelian cyclic groups, g^{-1} = g^{order-1}, i.e. - // the counter `(orders[g] - counter[g]) mod orders[g]`. - best_counter = counter - .iter() - .zip(self.orders.iter()) - .map(|(&c, &o)| (o - c) % o) - .collect(); - } - } - (best, best_counter) - } - - /// Momentum-sector character `χ_k(g) = exp(i Σ_g 2π · k[g] · counter[g] / orders[g])` - /// where `k[g] ∈ ℤ` is the integer momentum mode along generator `g` - /// (the corresponding wavenumber is `2π · k[g] / orders[g]`). - /// - /// `k.len()` must equal `self.n_generators()`. The character of the - /// identity element (`counter = [0, …]`) is `1`. For the trivial - /// (`k = [0, …]`) sector all characters are `1` — phase-aware merging - /// reduces to plain merging. - pub fn character(&self, k_modes: &[i32], counter: &[u32]) -> Complex { - debug_assert_eq!(k_modes.len(), self.perms.len()); - debug_assert_eq!(counter.len(), self.perms.len()); - let mut phase = 0.0_f64; - for ((&k, &c), &o) in k_modes.iter().zip(counter.iter()).zip(self.orders.iter()) { - phase += 2.0 * PI * (k as f64) * (c as f64) / (o as f64); - } - Complex::from_polar(1.0, phase) - } - - /// Iterate over all group elements applied to `w`. Yields `|G|` - /// Pauli words (including `w` itself for the identity element). - pub fn orbit<'a, A, S, const R: bool>( - &'a self, - w: &'a PauliWord, - ) -> impl Iterator> + 'a - where - A: PauliStorage + 'a, - S: BuildHasher + Clone + Default + HashFinalize + 'a, - { - let order = self.order(); - (0..order).map(move |idx| { - let mut rem = idx; - let mut cur = *w; - for (g, &o) in self.orders.iter().enumerate() { - let c = (rem as u32) % o; - rem /= o as usize; - for _ in 0..c { - cur = self.apply_generator(&cur, g); - } - } - cur - }) - } -} - -/// Replace `(basis, coeffs)` in-place with the orbit-representative -/// form: each Pauli word becomes its canonical rep, and coefficients -/// of words that collapse to the same rep are summed. -/// -/// Output length ≤ input length. Entries whose summed coefficient -/// equals zero exactly are *not* removed — caller should run a final -/// `drop_tol` prune if desired. -/// -/// For dynamics that commute with `group` and initial states that are -/// `group`-invariant (i.e. in the trivial momentum sector), this -/// preserves all `G`-invariant expectation values. -pub fn canonicalize_pauli_sum( - basis: &mut Vec>, - coeffs: &mut Vec, - group: &TranslationGroup, -) where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, -{ - assert_eq!( - basis.len(), - coeffs.len(), - "basis and coeffs length mismatch" - ); - let mut merged: FxHashMap, f64> = - FxHashMap::with_capacity_and_hasher(basis.len(), Default::default()); - for (w, &c) in basis.iter().zip(coeffs.iter()) { - let rep = group.canonicalize(w); - *merged.entry(rep).or_insert(0.0) += c; - } - basis.clear(); - coeffs.clear(); - basis.reserve(merged.len()); - coeffs.reserve(merged.len()); - for (w, c) in merged { - basis.push(w); - coeffs.push(c); - } -} - -/// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form -/// **projected onto momentum sector `k_modes`**. -/// -/// Each Pauli `p` is replaced by its canonical rep `r`; the contribution -/// is `(1/|G|) · χ_k(g) · c_p` where `g` is the group element such that -/// `g · r = p` and `χ_k(g) = exp(2πi · Σ_g k_modes[g] · counter[g] / orders[g])`. -/// -/// If the input was already a momentum-`k_modes` eigenstate (i.e. the -/// coefficients satisfy `c_{g·p} = χ_k(g)⁻¹ · c_p` for every orbit), -/// the output is the orbit-rep coefficients of that state unchanged. -/// Otherwise the merge discards the components in other sectors — -/// use [`check_momentum_sector`] beforehand to validate. -/// -/// For the `k_modes = [0, 0, …]` (trivial) sector this reduces to plain -/// [`canonicalize_pauli_sum`] (real coefficients work, but on complex -/// input the result is complex with vanishing imaginary part). -pub fn canonicalize_pauli_sum_complex( - basis: &mut Vec>, - coeffs: &mut Vec>, - group: &TranslationGroup, - k_modes: &[i32], -) where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, -{ - assert_eq!( - basis.len(), - coeffs.len(), - "basis and coeffs length mismatch" - ); - assert_eq!( - k_modes.len(), - group.n_generators(), - "k_modes length {} != number of generators {}", - k_modes.len(), - group.n_generators() - ); - let inv_g: f64 = 1.0 / (group.order() as f64); - let mut merged: FxHashMap, Complex> = - FxHashMap::with_capacity_and_hasher(basis.len(), Default::default()); - for (w, &c) in basis.iter().zip(coeffs.iter()) { - let (rep, cnt) = group.canonicalize_with_shift(w); - let chi = group.character(k_modes, &cnt); - let contrib = inv_g * chi * c; - *merged.entry(rep).or_insert(Complex::new(0.0, 0.0)) += contrib; - } - basis.clear(); - coeffs.clear(); - basis.reserve(merged.len()); - coeffs.reserve(merged.len()); - for (w, c) in merged { - basis.push(w); - coeffs.push(c); - } -} - -/// Verify that a `(basis, complex_coeffs)` Pauli sum lies entirely in -/// the momentum sector `k_modes` under `group`. -/// -/// Concretely: for every orbit represented in the basis, all members -/// must satisfy `c_{g·r} = χ_k(g)⁻¹ · c_r` for some choice of orbit-rep -/// coefficient `c_r`. -/// -/// 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( - basis: &[PauliWord], - coeffs: &[Complex], - group: &TranslationGroup, - k_modes: &[i32], - tol: f64, -) -> Result<(), SectorCheckError> -where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, -{ - assert_eq!(basis.len(), coeffs.len()); - assert_eq!(k_modes.len(), group.n_generators()); - - // Group entries by orbit rep, picking the first-seen member as - // reference and checking later members against it. - let mut reference: FxHashMap, (Complex, Vec)> = - FxHashMap::default(); - for (p, &c) in basis.iter().zip(coeffs.iter()) { - let (rep, cnt) = group.canonicalize_with_shift(p); - let chi = group.character(k_modes, &cnt); - // expected c_p given the rep coefficient c_r: - // c_p = χ_k(g)⁻¹ · c_r, where p = g·r - // equivalently, c_r = χ_k(g) · c_p (a rearrangement). - let implied_rep_coeff = chi * c; - if let Some((rep_coeff, _ref_cnt)) = reference.get(&rep) { - if (implied_rep_coeff - rep_coeff).norm() > tol * rep_coeff.norm().max(1.0) { - return Err(SectorCheckError { - rep, - expected: *rep_coeff, - got_implied: implied_rep_coeff, - offending_pauli: *p, - offending_coeff: c, - shift: cnt.clone(), - }); - } - } else { - reference.insert(rep, (implied_rep_coeff, cnt)); - } - } - Ok(()) -} - -/// Detail report for a failed [`check_momentum_sector`]. -pub struct SectorCheckError { - /// Canonical orbit representative for which the check failed. - pub rep: PauliWord, - /// Coefficient that the *first* basis entry implied for `rep`. - pub expected: Complex, - /// Coefficient that `offending_pauli` implies for `rep` under the - /// purported momentum sector. - pub got_implied: Complex, - /// The basis entry whose coefficient is inconsistent with the - /// expected `rep` value. - pub offending_pauli: PauliWord, - /// Original coefficient of `offending_pauli` in the input basis. - pub offending_coeff: Complex, - /// Counter encoding the group element `g` such that - /// `g · rep == offending_pauli`. - pub shift: Vec, -} - -impl std::fmt::Debug for SectorCheckError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "SectorCheckError {{ rep: , expected: {:?}, got_implied: {:?}, \ - offending: , offending_coeff: {:?}, shift: {:?} }}", - self.expected, self.got_implied, self.offending_coeff, self.shift, - ) - } -} - -impl std::fmt::Display for SectorCheckError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "input not in target momentum sector: orbit rep expected c={:?}, but \ - orbit member (shift {:?}, coeff {:?}) implies c={:?}", - self.expected, self.shift, self.offending_coeff, self.got_implied, - ) - } -} - -/// Symmetry-merge a [`PauliSum`] in place: each Pauli word becomes its -/// canonical orbit representative, and entries collapsing to the same -/// rep accumulate coefficients. -/// -/// This is the Trotter-mode counterpart to [`canonicalize_pauli_sum`] -/// (which operates on the `Vec, Vec` representation used by -/// `ppvm-lindblad`'s adaptive evolution). Same semantics: preserves all -/// `G`-invariant expectation values when the dynamics commutes with -/// `group` and the initial state is `group`-invariant. -/// -/// Generic over the [`Config`] but constrained to PauliWord-backed -/// representations (i.e. not the loss-aware variant) since -/// canonicalization needs raw `(xbit, zbit)` access. -pub fn symmetry_merge_pauli_sum( - psum: &mut PauliSum, - group: &TranslationGroup, -) where - T: Config>, - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, -{ - psum.map_add(|word, coeff| (group.canonicalize(word), coeff.clone())); -} - -#[cfg(test)] -mod tests { - use super::*; - - type W = PauliWord<[u8; 1], fxhash::FxBuildHasher, true>; - - fn word(s: &str) -> W { - W::from(s) - } - - #[test] - fn chain_1d_canonicalizes_via_cyclic_shift() { - let g = TranslationGroup::chain_1d(4); - // All cyclic shifts of "IIXY" should canonicalize to the same rep. - let candidates = ["IIXY", "IXYI", "XYII", "YIIX"]; - let canon: Vec = candidates - .iter() - .map(|s| g.canonicalize(&word(s))) - .collect(); - for c in &canon[1..] { - assert_eq!( - *c, canon[0], - "all cyclic shifts must canonicalize to same rep" - ); - } - } - - #[test] - fn chain_1d_canonicalize_is_lex_min() { - let g = TranslationGroup::chain_1d(4); - let canon = g.canonicalize(&word("YIIX")); - let orbit: Vec = g.orbit(&word("YIIX")).collect(); - let min = orbit.iter().min().unwrap(); - assert_eq!(canon, *min); - } - - #[test] - fn orbit_has_correct_size_for_chain() { - let g = TranslationGroup::chain_1d(4); - // "XIII" has orbit of size 4 (full chain). - let orbit: Vec = g.orbit(&word("XIII")).collect(); - assert_eq!(orbit.len(), 4); - // "XIXI" has orbit of size 2 (period-2 invariant); 4 elements - // total in the orbit iterator, but only 2 unique. - let orbit: Vec = g.orbit(&word("XIXI")).collect(); - assert_eq!(orbit.len(), 4); // iterator yields |G|, including duplicates - let unique: std::collections::HashSet = orbit.into_iter().collect(); - assert_eq!(unique.len(), 2); - } - - #[test] - fn torus_2d_canonicalize() { - // 3x2 torus, 6 qubits. - let g = TranslationGroup::torus_2d(3, 2); - assert_eq!(g.n_qubits(), 6); - assert_eq!(g.order(), 6); - // X at (0,0) — orbit is all 6 single-X positions. - let w = word("XIIIII"); - let orbit: Vec = g.orbit(&w).collect(); - let unique: std::collections::HashSet = orbit.into_iter().collect(); - assert_eq!(unique.len(), 6); - // All canonicalize to the same rep. - let canon = g.canonicalize(&w); - for u in &unique { - assert_eq!(g.canonicalize(u), canon); - } - } - - #[test] - fn ladder_canonicalize() { - // 2-leg ladder, L=3 → 6 qubits, group order 3 (no swap of legs). - let g = TranslationGroup::ladder(3, 2); - assert_eq!(g.n_qubits(), 6); - assert_eq!(g.order(), 3); - // X on leg 0 site 0: orbit = {(0,0), (0,1), (0,2)}, NOT including leg 1 sites. - let w = word("XIIIII"); // qubit 0 = X - let orbit: Vec = g.orbit(&w).collect(); - assert_eq!(orbit.len(), 3); - let unique: std::collections::HashSet = orbit.into_iter().collect(); - assert_eq!(unique.len(), 3); - // The orbit should be {qubit 0=X, qubit 1=X, qubit 2=X} — all leg 0. - let expected: std::collections::HashSet = ["XIIIII", "IXIIII", "IIXIII"] - .iter() - .map(|s| word(s)) - .collect(); - assert_eq!(unique, expected); - } - - #[test] - fn canonicalize_pauli_sum_merges_orbit_members() { - let g = TranslationGroup::chain_1d(4); - let mut basis: Vec = vec![word("XIII"), word("IXII"), word("IIXI"), word("IIIX")]; - let mut coeffs: Vec = vec![1.0, 2.0, 3.0, 4.0]; - canonicalize_pauli_sum(&mut basis, &mut coeffs, &g); - // All four collapse to one rep with coeff 1+2+3+4 = 10. - assert_eq!(basis.len(), 1); - assert!((coeffs[0] - 10.0).abs() < 1e-12); - } - - #[test] - fn canonicalize_pauli_sum_keeps_distinct_orbits() { - let g = TranslationGroup::chain_1d(4); - // Two distinct orbits: {XIII, ...} (size 4) and {ZIII, ...} (size 4). - let mut basis: Vec = vec![word("XIII"), word("IXII"), word("ZIII"), word("IZII")]; - let mut coeffs: Vec = vec![1.0, 1.0, 2.0, 2.0]; - canonicalize_pauli_sum(&mut basis, &mut coeffs, &g); - assert_eq!(basis.len(), 2); - // Coefficients should be {2.0, 4.0} in some order. - let mut cs = coeffs.clone(); - cs.sort_by(|a, b| a.partial_cmp(b).unwrap()); - assert!((cs[0] - 2.0).abs() < 1e-12); - assert!((cs[1] - 4.0).abs() < 1e-12); - } - - #[test] - fn canonicalize_with_shift_round_trip() { - // For each cyclic shift of "IIXY" by `a` positions, the shift - // counter returned should reproduce the original word when - // applied to the canonical rep. - let g = TranslationGroup::chain_1d(4); - for src in ["IIXY", "IXYI", "XYII", "YIIX"] { - let w = word(src); - let (rep, cnt) = g.canonicalize_with_shift(&w); - // Apply gen 0 `cnt[0]` times to rep, should equal w. - let mut cur = rep; - for _ in 0..cnt[0] { - cur = g.apply_generator(&cur, 0); - } - assert_eq!(cur, w, "shift {cnt:?} doesn't reproduce {src}"); - } - } - - #[test] - fn character_trivial_sector_is_one() { - let g = TranslationGroup::chain_1d(4); - // k=0 mode → character is always 1. - for cnt in [vec![0u32], vec![1u32], vec![2u32], vec![3u32]] { - let chi = g.character(&[0], &cnt); - assert!((chi - Complex::new(1.0, 0.0)).norm() < 1e-12); - } - } - - #[test] - fn character_obeys_unit_modulus() { - let g = TranslationGroup::chain_1d(4); - for k in 0..4 { - for a in 0..4 { - let chi = g.character(&[k], &[a as u32]); - assert!( - (chi.norm() - 1.0).abs() < 1e-12, - "|χ_{k}(T^{a})| should be 1, got {}", - chi.norm() - ); - } - } - } - - #[test] - fn momentum_zero_complex_merge_matches_real_merge() { - // k=0 sector: complex merge with all-real input should give - // real-valued orbit-rep coefficients equal to the plain - // canonicalize_pauli_sum result. - let g = TranslationGroup::chain_1d(4); - let basis: Vec = vec![word("XIII"), word("IXII"), word("IIXI"), word("IIIX")]; - let real_coeffs = vec![1.0, 2.0, 3.0, 4.0]; - - let mut basis_real = basis.clone(); - let mut coeffs_real = real_coeffs.clone(); - canonicalize_pauli_sum(&mut basis_real, &mut coeffs_real, &g); - - let mut basis_c = basis.clone(); - let mut coeffs_c: Vec> = - real_coeffs.iter().map(|&v| Complex::new(v, 0.0)).collect(); - canonicalize_pauli_sum_complex(&mut basis_c, &mut coeffs_c, &g, &[0]); - - // Plain merge sums all coefficients onto the single orbit-rep: - // 1+2+3+4 = 10. Complex merge does the same with a 1/|G| - // prefactor, so we expect 10/4 = 2.5 on the rep. - assert_eq!(basis_real.len(), 1); - assert_eq!(basis_c.len(), 1); - assert!((coeffs_real[0] - 10.0).abs() < 1e-12); - assert!((coeffs_c[0].re - 2.5).abs() < 1e-12); - assert!(coeffs_c[0].im.abs() < 1e-12); - } - - #[test] - fn momentum_eigenstate_check_passes() { - // O = Σ_j e^{ikj} Z_j for k = 2π/4 (mode 1) is a momentum-k - // eigenstate. check_momentum_sector should accept. - let g = TranslationGroup::chain_1d(4); - let basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; - let k_mode: i32 = 1; - // Sector condition: c_{T^a p} = e^{-2πi k a / N} c_p. - // Picking c_{Z_0} = 1: c_{Z_a} = e^{-2πi · 1 · a / 4} = (-i)^a. - let coeffs: Vec> = (0..4_i32) - .map(|a| Complex::from_polar(1.0, -2.0 * PI * (k_mode as f64) * (a as f64) / 4.0)) - .collect(); - let res = check_momentum_sector(&basis, &coeffs, &g, &[k_mode], 1e-10); - assert!( - res.is_ok(), - "valid k-eigenstate failed sector check: {res:?}" - ); - } - - #[test] - fn momentum_eigenstate_check_fails_for_wrong_sector() { - // Same eigenstate as above, but check against the wrong momentum. - let g = TranslationGroup::chain_1d(4); - let basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; - let coeffs: Vec> = (0..4_i32) - .map(|a| Complex::from_polar(1.0, -2.0 * PI * 1.0 * (a as f64) / 4.0)) - .collect(); - // Check against k=0 (constant) — should fail. - let res = check_momentum_sector(&basis, &coeffs, &g, &[0], 1e-10); - assert!(res.is_err(), "k=1 eigenstate wrongly passed as k=0 sector"); - } - - #[test] - fn momentum_eigenstate_round_trip_merge_preserves_rep_coeff() { - // Merge a k=1 eigenstate; the orbit-rep coefficient should be - // unchanged (= 1.0 for our chosen normalization, picking - // c_{Z_0} = 1). - let g = TranslationGroup::chain_1d(4); - let mut basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; - let mut coeffs: Vec> = (0..4_i32) - .map(|a| Complex::from_polar(1.0, -2.0 * PI * 1.0 * (a as f64) / 4.0)) - .collect(); - canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &g, &[1]); - assert_eq!(basis.len(), 1); - // The canonical rep of single-Z orbit is Z_0 (lex-min of - // {ZIII, IZII, IIZI, IIIZ} is IIIZ since 'I' < 'Z' lex-wise on - // the (xbits, zbits) tuple; let's just check we got a single - // entry with norm 1. - assert!( - (coeffs[0].norm() - 1.0).abs() < 1e-10, - "expected |c_rep|=1, got {}", - coeffs[0].norm() - ); - } - - /// Trotter-mode end-to-end check that `PauliSum::symmetry_merge` - /// matches plain Trotter evolution post-canonicalized. - /// - /// Setup: n=4 qubit chain, PBC, XY rotations on each bond. Initial - /// operator `O(0) = Σ_j Z_j` is translation-invariant. - /// - /// **dt must be tiny.** First-order Trotter on a chain with PBC is - /// only translation-equivariant up to `O(dt^2)` (gate-order - /// commutator errors are NOT themselves T-symmetric). The - /// "merge-after-each-step" trajectory and the "merge-at-end" - /// trajectory therefore diverge by an amount proportional to that - /// Trotter error. We test in the dt → 0 limit where the divergence - /// is below FP noise. - #[test] - fn pauli_sum_symmetry_merge_matches_plain_trotter() { - use crate::config::indexmap::ByteFxHashF64; - use crate::prelude::*; - - type Cfg = ByteFxHashF64<1>; - - let n: usize = 4; - // Tiny dt — Trotter per-step error scales as dt^2 and shows up - // as a translation-non-equivariant correction; we want it below - // FP noise at the tolerance we assert below (1e-7). - let dt = 1e-5_f64; - let n_steps = 2usize; - let group = TranslationGroup::chain_1d(n); - - // Total-Z initial: O(0) = Σ_j Z_j (translation-invariant). - let mut o_u: PauliSum = PauliSum::builder().n_qubits(n).build(); - let mut o_m: PauliSum = PauliSum::builder().n_qubits(n).build(); - for j in 0..n { - let mut s: Vec = vec!['I'; n]; - s[j] = 'Z'; - let st: String = s.into_iter().collect(); - o_u += (st.as_str(), 1.0); - o_m += (st.as_str(), 1.0); - } - assert_eq!(o_u.len(), n); - assert_eq!(o_m.len(), n); - - // Apply XY Trotter steps to both copies. With merging, call - // symmetry_merge_pauli_sum after each step. - for _ in 0..n_steps { - for j in 0..n { - let nxt = (j + 1) % n; - o_u.rxx(j, nxt, dt); - o_u.ryy(j, nxt, dt); - o_m.rxx(j, nxt, dt); - o_m.ryy(j, nxt, dt); - } - symmetry_merge_pauli_sum(&mut o_m, &group); - } - - // Canonicalize the un-merged result once at the end. - symmetry_merge_pauli_sum(&mut o_u, &group); - - // Compare as (word → coeff) maps, FP tolerance. - let u: FxHashMap<_, f64> = o_u.iter().map(|(w, c)| (*w, *c)).collect(); - let m: FxHashMap<_, f64> = o_m.iter().map(|(w, c)| (*w, *c)).collect(); - assert_eq!( - u.len(), - m.len(), - "post-merge basis sizes differ: u={} vs m={}", - u.len(), - m.len() - ); - let mut max_diff = 0.0_f64; - for (w, &cu) in &u { - let cm = *m.get(w).unwrap_or_else(|| { - panic!("rep present in u but not in m: {:?}", w); - }); - max_diff = max_diff.max((cu - cm).abs()); - } - // At dt = 1e-5 over 2 steps, accumulated Trotter - // commutator-induced T-eq error is ~2·dt^2·|H|^2 ≈ 1e-9; we - // assert 1e-7 to leave safety margin. - assert!( - max_diff < 1e-7, - "Trotter with-merging diverged from without-merging: max |Δc| = {max_diff:e}" - ); - } -} diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs new file mode 100644 index 00000000..0cefbc7c --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use ppvm_pauli_word::word::PauliWord; +use ppvm_traits::{HashFinalize, PauliStorage, PauliWordTrait}; +use std::hash::BuildHasher; + +/// A finite abelian symmetry group acting on qubit positions by +/// permutations. +/// +/// Build via the convenience constructors [`Self::chain_1d`], +/// [`Self::torus_2d`], [`Self::torus_3d`], [`Self::ladder`], or +/// [`Self::from_generators`] for an arbitrary list of generator +/// permutations. +/// +/// `perms[g]` is the permutation that **generator `g`** applies to qubit +/// indices: a qubit at position `q` moves to position `perms[g][q]` +/// under one application of generator `g`. `orders[g]` is the cyclic +/// order of generator `g` (i.e. applying it `orders[g]` times returns +/// the identity). The full group is the direct product of the cyclic +/// subgroups, with size `Π orders[g]`. +/// +/// Only the **generators** are stored; the algorithm in +/// [`Self::canonicalize`] walks the group via mixed-radix increments. +#[derive(Debug, Clone)] +pub struct TranslationGroup { + /// Number of qubits the group acts on. + n_qubits: usize, + /// One permutation per generator. `perms[g][q]` is the position + /// that qubit `q` maps to under one application of generator `g`. + pub(super) perms: Vec>, + /// Cyclic order of each generator. + pub(super) orders: Vec, +} + +impl TranslationGroup { + /// Construct from explicit generator permutations and orders. + /// + /// Each `perm` must be a permutation of `0..n_qubits`. Each `order` + /// must satisfy `perm^order == identity`. + pub fn from_generators(n_qubits: usize, perms: Vec>, orders: Vec) -> Self { + assert_eq!(perms.len(), orders.len(), "perms and orders must match"); + for (g, perm) in perms.iter().enumerate() { + assert_eq!( + perm.len(), + n_qubits, + "generator {g} permutation has length {} != n_qubits {n_qubits}", + perm.len() + ); + let mut seen = vec![false; n_qubits]; + for &p in perm { + assert!( + (p as usize) < n_qubits, + "generator {g} maps to out-of-range position {p}" + ); + assert!( + !seen[p as usize], + "generator {g} is not a permutation (duplicate target {p})" + ); + seen[p as usize] = true; + } + } + Self { + n_qubits, + perms, + orders, + } + } + + /// 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 = (0..n).map(|q| ((q + 1) % n) as u32).collect(); + Self::from_generators(n, vec![perm], vec![n as u32]) + } + + /// 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 = (0..n) + .map(|q| { + let (i, j) = (q % lx, q / lx); + (j * lx + (i + 1) % lx) as u32 + }) + .collect(); + let perm_y: Vec = (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]) + } + + /// 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 = (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 = (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 = (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], + ) + } + + /// 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 = (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]) + } + + /// Number of qubits the group acts on. + pub fn n_qubits(&self) -> usize { + self.n_qubits + } + + /// Number of generators (rank of the group as an abelian product). + pub fn n_generators(&self) -> usize { + self.perms.len() + } + + /// Total group order: `Π orders[g]`. + pub fn order(&self) -> usize { + self.orders.iter().map(|&o| o as usize).product() + } + + /// Permutation associated with the `g`-th generator (one application). + pub fn generator_perm(&self, g: usize) -> &[u32] { + &self.perms[g] + } + + /// Cyclic order of the `g`-th generator. + pub fn generator_order(&self, g: usize) -> u32 { + self.orders[g] + } + + /// Apply a single generator's permutation to a Pauli word, returning + /// the resulting word. + /// + /// For each qubit `q` of the input, the corresponding `(xbit, zbit)` + /// pair is placed at position `perm[q]` of the output. + pub(super) fn apply_generator( + &self, + w: &PauliWord, + g: usize, + ) -> PauliWord + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + let perm = &self.perms[g]; + let mut out: PauliWord = PauliWord::new(self.n_qubits); + for (q, &pq) in perm.iter().enumerate().take(self.n_qubits) { + let xb = w.get_xbit(q); + let zb = w.get_zbit(q); + if xb { + out.set_xbit(pq as usize, true); + } + if zb { + out.set_zbit(pq as usize, true); + } + } + out.rehash(); + out + } + + /// Lex-min canonical representative of `w`'s translation orbit + /// under this group. Walks the full group via mixed-radix counters, + /// keeping the smallest word seen. + /// + /// Total cost: `O(|G| × n_qubits)` per call. + pub fn canonicalize(&self, w: &PauliWord) -> PauliWord + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + debug_assert_eq!( + w.n_qubits(), + self.n_qubits, + "word and group must agree on n_qubits" + ); + if self.perms.is_empty() { + return *w; + } + // Mixed-radix counter `(c[0], c[1], …)` ranges over + // `0..orders[0] × 0..orders[1] × …`. We track the "current" + // word obtained by applying generator `g` once each time + // `c[g]` increments; rolling over `c[g]` means we apply + // generator `g` exactly `orders[g]` times (= identity), so + // `cur` returns to the orbit member that had `c[g..]` as its + // tail and `0` in slots 0..g. + // + // The simplest correct implementation just enumerates: for each + // group element index, build the corresponding word from scratch + // by applying the right number of each generator. + let mut best = *w; + let order = self.order(); + let mut idx = 0usize; + while idx < order { + // Decode `idx` to mixed-radix counter `c` + let mut rem = idx; + let mut counters: Vec = Vec::with_capacity(self.perms.len()); + for &o in &self.orders { + counters.push((rem as u32) % o); + rem /= o as usize; + } + // Construct the group element's permutation by composing + // `generator g` applied `c[g]` times, for each g. + // We do this lazily by iterating over qubits. + let mut cur = *w; + for (g, &c) in counters.iter().enumerate() { + for _ in 0..c { + cur = self.apply_generator(&cur, g); + } + } + if cur < best { + best = cur; + } + idx += 1; + } + best + } + + /// Lex-min canonical representative `r` of `w` together with the + /// **mixed-radix counter** `c = (c_0, c_1, …)` of the group element + /// `g` such that `g·r = w`. + /// + /// In other words: if `r = self.canonicalize(w)`, this returns + /// `(r, c)` where applying generator `i` exactly `c[i]` times in + /// sequence to `r` produces `w`. The counter is used to compute + /// momentum phases by the phase-aware merge routines. + /// + /// Same `O(|G| × n_qubits)` cost as `canonicalize`. + pub fn canonicalize_with_shift( + &self, + w: &PauliWord, + ) -> (PauliWord, Vec) + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + debug_assert_eq!(w.n_qubits(), self.n_qubits); + if self.perms.is_empty() { + return (*w, Vec::new()); + } + let mut best = *w; + let mut best_counter: Vec = vec![0; self.perms.len()]; + let order = self.order(); + for idx in 0..order { + // Decode `idx` to mixed-radix counter. + let mut rem = idx; + let mut counter: Vec = Vec::with_capacity(self.perms.len()); + for &o in &self.orders { + counter.push((rem as u32) % o); + rem /= o as usize; + } + // Build the candidate by applying generator `g` exactly + // `counter[g]` times. + let mut cur = *w; + for (g, &c) in counter.iter().enumerate() { + for _ in 0..c { + cur = self.apply_generator(&cur, g); + } + } + if cur < best { + best = cur; + // We need the counter such that g·best = w. The loop + // above computed cur = g·w with counter, so w = g^{-1}·cur. + // For abelian cyclic groups, g^{-1} = g^{order-1}, i.e. + // the counter `(orders[g] - counter[g]) mod orders[g]`. + best_counter = counter + .iter() + .zip(self.orders.iter()) + .map(|(&c, &o)| (o - c) % o) + .collect(); + } + } + (best, best_counter) + } + + /// Iterate over all group elements applied to `w`. Yields `|G|` + /// Pauli words (including `w` itself for the identity element). + pub fn orbit<'a, A, S, const R: bool>( + &'a self, + w: &'a PauliWord, + ) -> impl Iterator> + 'a + where + A: PauliStorage + 'a, + S: BuildHasher + Clone + Default + HashFinalize + 'a, + { + let order = self.order(); + (0..order).map(move |idx| { + let mut rem = idx; + let mut cur = *w; + for (g, &o) in self.orders.iter().enumerate() { + let c = (rem as u32) % o; + rem /= o as usize; + for _ in 0..c { + cur = self.apply_generator(&cur, g); + } + } + cur + }) + } +} diff --git a/crates/ppvm-pauli-sum/src/symmetry/merge.rs b/crates/ppvm-pauli-sum/src/symmetry/merge.rs new file mode 100644 index 00000000..b6e38471 --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/merge.rs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use crate::sum::PauliSum; +use fxhash::FxHashMap; +use ppvm_pauli_word::word::PauliWord; +use ppvm_traits::Config; +use ppvm_traits::{HashFinalize, PauliStorage}; +use std::hash::BuildHasher; + +use super::group::TranslationGroup; + +/// Replace `(basis, coeffs)` in-place with the orbit-representative +/// form: each Pauli word becomes its canonical rep, and coefficients +/// of words that collapse to the same rep are summed. +/// +/// Output length ≤ input length. Entries whose summed coefficient +/// equals zero exactly are *not* removed — caller should run a final +/// `drop_tol` prune if desired. +/// +/// For dynamics that commute with `group` and initial states that are +/// `group`-invariant (i.e. in the trivial momentum sector), this +/// preserves all `G`-invariant expectation values. +pub fn canonicalize_pauli_sum( + basis: &mut Vec>, + coeffs: &mut Vec, + group: &TranslationGroup, +) where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!( + basis.len(), + coeffs.len(), + "basis and coeffs length mismatch" + ); + let mut merged: FxHashMap, f64> = + FxHashMap::with_capacity_and_hasher(basis.len(), Default::default()); + for (w, &c) in basis.iter().zip(coeffs.iter()) { + let rep = group.canonicalize(w); + *merged.entry(rep).or_insert(0.0) += c; + } + basis.clear(); + coeffs.clear(); + basis.reserve(merged.len()); + coeffs.reserve(merged.len()); + for (w, c) in merged { + basis.push(w); + coeffs.push(c); + } +} + +/// Symmetry-merge a [`PauliSum`] in place: each Pauli word becomes its +/// canonical orbit representative, and entries collapsing to the same +/// rep accumulate coefficients. +/// +/// This is the Trotter-mode counterpart to [`canonicalize_pauli_sum`] +/// (which operates on the `Vec, Vec` representation used by +/// `ppvm-lindblad`'s adaptive evolution). Same semantics: preserves all +/// `G`-invariant expectation values when the dynamics commutes with +/// `group` and the initial state is `group`-invariant. +/// +/// Generic over the [`Config`] but constrained to PauliWord-backed +/// representations (i.e. not the loss-aware variant) since +/// canonicalization needs raw `(xbit, zbit)` access. +pub fn symmetry_merge_pauli_sum( + psum: &mut PauliSum, + group: &TranslationGroup, +) where + T: Config>, + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + psum.map_add(|word, coeff| (group.canonicalize(word), coeff.clone())); +} diff --git a/crates/ppvm-pauli-sum/src/symmetry/mod.rs b/crates/ppvm-pauli-sum/src/symmetry/mod.rs new file mode 100644 index 00000000..750acb72 --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/mod.rs @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Lattice translation symmetry groups for operator-space Pauli evolution. +//! +//! A [`TranslationGroup`] represents a finite abelian group `G` acting on +//! qubit positions by permutations. Given such a group, every Pauli word +//! belongs to a translation orbit, and operator dynamics that commute +//! with `G` can be tracked using **one canonical representative per +//! orbit** instead of all `|G|` orbit members — reducing per-step memory +//! and compute by a factor up to `|G|`. +//! +//! Following Teng, Chang, Rudolph, and Holmes (arXiv:2512.12094), this +//! module implements **plain (real-coefficient) merging** of Pauli sums +//! into orbit-representative form — see [`canonicalize_pauli_sum`] and +//! [`symmetry_merge_pauli_sum`]. This handles observables in the trivial +//! (`k=0`) symmetry sector, e.g. sums of single-Z operators over the +//! lattice. +//! +//! **Non-trivial momentum sectors (`k ≠ 0`)** are handled by +//! [`canonicalize_pauli_sum_complex`], which folds with the character +//! phase `χ_k(g)` of each translation. On the Python side, an operator in +//! sector `k` is carried as a *real pair* (real + imaginary components, two +//! real `PauliSum`s) and merged via `PauliSum.momentum_merge`, which reuses +//! this routine — letting gate-based Trotter evolution stay symmetry- +//! compressed in any momentum sector with real coefficients throughout. +//! +//! ## Data model +//! +//! A `TranslationGroup` is specified by a list of generator permutations +//! and their cyclic orders. The group order is the product of the orders. +//! For instance, a 2D `L × L` torus has two generators (translation in +//! x and y) each of order `L`. +//! +//! ## Canonicalization +//! +//! [`TranslationGroup::canonicalize`] returns the **lex-minimum** Pauli +//! word reachable from the input via group action. The ordering is the +//! standard `Ord` impl on `PauliWord` (compare `xbits`, then `zbits`). +//! All orbit members canonicalize to the same representative; orbits are +//! disjoint by construction, so the rep uniquely identifies the orbit. +//! +//! ## Merging +//! +//! [`canonicalize_pauli_sum`] takes parallel `Vec` / `Vec` +//! buffers (the representation used by ppvm-lindblad's adaptive +//! evolution) and replaces each Pauli by its canonical rep, summing +//! coefficients for collisions. The output is an orbit-rep basis with +//! coefficients equal to the sum of the input coefficients over each +//! orbit's members. For dynamics that commute with `G` and initial +//! states that are also `G`-invariant, this preserves the expectation +//! value of any `G`-invariant observable (Theorem 1 of arXiv:2512.12094). +//! +//! See the dedicated tests for correctness against full-basis evolution +//! on small systems with no truncation. + +mod group; +mod merge; +mod momentum; + +pub use group::TranslationGroup; +pub use merge::{canonicalize_pauli_sum, symmetry_merge_pauli_sum}; +pub use momentum::{SectorCheckError, canonicalize_pauli_sum_complex, check_momentum_sector}; + +#[cfg(test)] +mod tests; diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs new file mode 100644 index 00000000..87f9f109 --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use fxhash::FxHashMap; +use num::Complex; +use ppvm_pauli_word::word::PauliWord; +use ppvm_traits::{HashFinalize, PauliStorage}; +use std::f64::consts::PI; +use std::hash::BuildHasher; + +use super::group::TranslationGroup; + +impl TranslationGroup { + /// Momentum-sector character `χ_k(g) = exp(i Σ_g 2π · k[g] · counter[g] / orders[g])` + /// where `k[g] ∈ ℤ` is the integer momentum mode along generator `g` + /// (the corresponding wavenumber is `2π · k[g] / orders[g]`). + /// + /// `k.len()` must equal `self.n_generators()`. The character of the + /// identity element (`counter = [0, …]`) is `1`. For the trivial + /// (`k = [0, …]`) sector all characters are `1` — phase-aware merging + /// reduces to plain merging. + pub fn character(&self, k_modes: &[i32], counter: &[u32]) -> Complex { + debug_assert_eq!(k_modes.len(), self.perms.len()); + debug_assert_eq!(counter.len(), self.perms.len()); + let mut phase = 0.0_f64; + for ((&k, &c), &o) in k_modes.iter().zip(counter.iter()).zip(self.orders.iter()) { + phase += 2.0 * PI * (k as f64) * (c as f64) / (o as f64); + } + Complex::from_polar(1.0, phase) + } +} + +/// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form +/// **projected onto momentum sector `k_modes`**. +/// +/// Each Pauli `p` is replaced by its canonical rep `r`; the contribution +/// is `(1/|G|) · χ_k(g) · c_p` where `g` is the group element such that +/// `g · r = p` and `χ_k(g) = exp(2πi · Σ_g k_modes[g] · counter[g] / orders[g])`. +/// +/// If the input was already a momentum-`k_modes` eigenstate (i.e. the +/// coefficients satisfy `c_{g·p} = χ_k(g)⁻¹ · c_p` for every orbit), +/// the output is the orbit-rep coefficients of that state unchanged. +/// Otherwise the merge discards the components in other sectors — +/// use [`check_momentum_sector`] beforehand to validate. +/// +/// For the `k_modes = [0, 0, …]` (trivial) sector this reduces to plain +/// [`canonicalize_pauli_sum`] (real coefficients work, but on complex +/// input the result is complex with vanishing imaginary part). +pub fn canonicalize_pauli_sum_complex( + basis: &mut Vec>, + coeffs: &mut Vec>, + group: &TranslationGroup, + k_modes: &[i32], +) where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!( + basis.len(), + coeffs.len(), + "basis and coeffs length mismatch" + ); + assert_eq!( + k_modes.len(), + group.n_generators(), + "k_modes length {} != number of generators {}", + k_modes.len(), + group.n_generators() + ); + let inv_g: f64 = 1.0 / (group.order() as f64); + let mut merged: FxHashMap, Complex> = + FxHashMap::with_capacity_and_hasher(basis.len(), Default::default()); + for (w, &c) in basis.iter().zip(coeffs.iter()) { + let (rep, cnt) = group.canonicalize_with_shift(w); + let chi = group.character(k_modes, &cnt); + let contrib = inv_g * chi * c; + *merged.entry(rep).or_insert(Complex::new(0.0, 0.0)) += contrib; + } + basis.clear(); + coeffs.clear(); + basis.reserve(merged.len()); + coeffs.reserve(merged.len()); + for (w, c) in merged { + basis.push(w); + coeffs.push(c); + } +} + +/// Verify that a `(basis, complex_coeffs)` Pauli sum lies entirely in +/// the momentum sector `k_modes` under `group`. +/// +/// Concretely: for every orbit represented in the basis, all members +/// must satisfy `c_{g·r} = χ_k(g)⁻¹ · c_r` for some choice of orbit-rep +/// coefficient `c_r`. +/// +/// 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( + basis: &[PauliWord], + coeffs: &[Complex], + group: &TranslationGroup, + k_modes: &[i32], + tol: f64, +) -> Result<(), SectorCheckError> +where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!(basis.len(), coeffs.len()); + assert_eq!(k_modes.len(), group.n_generators()); + + // Group entries by orbit rep, picking the first-seen member as + // reference and checking later members against it. + let mut reference: FxHashMap, (Complex, Vec)> = + FxHashMap::default(); + for (p, &c) in basis.iter().zip(coeffs.iter()) { + let (rep, cnt) = group.canonicalize_with_shift(p); + let chi = group.character(k_modes, &cnt); + // expected c_p given the rep coefficient c_r: + // c_p = χ_k(g)⁻¹ · c_r, where p = g·r + // equivalently, c_r = χ_k(g) · c_p (a rearrangement). + let implied_rep_coeff = chi * c; + if let Some((rep_coeff, _ref_cnt)) = reference.get(&rep) { + if (implied_rep_coeff - rep_coeff).norm() > tol * rep_coeff.norm().max(1.0) { + return Err(SectorCheckError { + rep, + expected: *rep_coeff, + got_implied: implied_rep_coeff, + offending_pauli: *p, + offending_coeff: c, + shift: cnt.clone(), + }); + } + } else { + reference.insert(rep, (implied_rep_coeff, cnt)); + } + } + Ok(()) +} + +/// Detail report for a failed [`check_momentum_sector`]. +pub struct SectorCheckError { + /// Canonical orbit representative for which the check failed. + pub rep: PauliWord, + /// Coefficient that the *first* basis entry implied for `rep`. + pub expected: Complex, + /// Coefficient that `offending_pauli` implies for `rep` under the + /// purported momentum sector. + pub got_implied: Complex, + /// The basis entry whose coefficient is inconsistent with the + /// expected `rep` value. + pub offending_pauli: PauliWord, + /// Original coefficient of `offending_pauli` in the input basis. + pub offending_coeff: Complex, + /// Counter encoding the group element `g` such that + /// `g · rep == offending_pauli`. + pub shift: Vec, +} + +impl std::fmt::Debug for SectorCheckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "SectorCheckError {{ rep: , expected: {:?}, got_implied: {:?}, \ + offending: , offending_coeff: {:?}, shift: {:?} }}", + self.expected, self.got_implied, self.offending_coeff, self.shift, + ) + } +} + +impl std::fmt::Display for SectorCheckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "input not in target momentum sector: orbit rep expected c={:?}, but \ + orbit member (shift {:?}, coeff {:?}) implies c={:?}", + self.expected, self.shift, self.offending_coeff, self.got_implied, + ) + } +} diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs new file mode 100644 index 00000000..be8fbe4b --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use fxhash::FxHashMap; +use num::Complex; +use ppvm_pauli_word::word::PauliWord; +use std::f64::consts::PI; + +type W = PauliWord<[u8; 1], fxhash::FxBuildHasher, true>; + +fn word(s: &str) -> W { + W::from(s) +} + +#[test] +fn chain_1d_canonicalizes_via_cyclic_shift() { + let g = TranslationGroup::chain_1d(4); + // All cyclic shifts of "IIXY" should canonicalize to the same rep. + let candidates = ["IIXY", "IXYI", "XYII", "YIIX"]; + let canon: Vec = candidates + .iter() + .map(|s| g.canonicalize(&word(s))) + .collect(); + for c in &canon[1..] { + assert_eq!( + *c, canon[0], + "all cyclic shifts must canonicalize to same rep" + ); + } +} + +#[test] +fn chain_1d_canonicalize_is_lex_min() { + let g = TranslationGroup::chain_1d(4); + let canon = g.canonicalize(&word("YIIX")); + let orbit: Vec = g.orbit(&word("YIIX")).collect(); + let min = orbit.iter().min().unwrap(); + assert_eq!(canon, *min); +} + +#[test] +fn orbit_has_correct_size_for_chain() { + let g = TranslationGroup::chain_1d(4); + // "XIII" has orbit of size 4 (full chain). + let orbit: Vec = g.orbit(&word("XIII")).collect(); + assert_eq!(orbit.len(), 4); + // "XIXI" has orbit of size 2 (period-2 invariant); 4 elements + // total in the orbit iterator, but only 2 unique. + let orbit: Vec = g.orbit(&word("XIXI")).collect(); + assert_eq!(orbit.len(), 4); // iterator yields |G|, including duplicates + let unique: std::collections::HashSet = orbit.into_iter().collect(); + assert_eq!(unique.len(), 2); +} + +#[test] +fn torus_2d_canonicalize() { + // 3x2 torus, 6 qubits. + let g = TranslationGroup::torus_2d(3, 2); + assert_eq!(g.n_qubits(), 6); + assert_eq!(g.order(), 6); + // X at (0,0) — orbit is all 6 single-X positions. + let w = word("XIIIII"); + let orbit: Vec = g.orbit(&w).collect(); + let unique: std::collections::HashSet = orbit.into_iter().collect(); + assert_eq!(unique.len(), 6); + // All canonicalize to the same rep. + let canon = g.canonicalize(&w); + for u in &unique { + assert_eq!(g.canonicalize(u), canon); + } +} + +#[test] +fn ladder_canonicalize() { + // 2-leg ladder, L=3 → 6 qubits, group order 3 (no swap of legs). + let g = TranslationGroup::ladder(3, 2); + assert_eq!(g.n_qubits(), 6); + assert_eq!(g.order(), 3); + // X on leg 0 site 0: orbit = {(0,0), (0,1), (0,2)}, NOT including leg 1 sites. + let w = word("XIIIII"); // qubit 0 = X + let orbit: Vec = g.orbit(&w).collect(); + assert_eq!(orbit.len(), 3); + let unique: std::collections::HashSet = orbit.into_iter().collect(); + assert_eq!(unique.len(), 3); + // The orbit should be {qubit 0=X, qubit 1=X, qubit 2=X} — all leg 0. + let expected: std::collections::HashSet = ["XIIIII", "IXIIII", "IIXIII"] + .iter() + .map(|s| word(s)) + .collect(); + assert_eq!(unique, expected); +} + +#[test] +fn canonicalize_pauli_sum_merges_orbit_members() { + let g = TranslationGroup::chain_1d(4); + let mut basis: Vec = vec![word("XIII"), word("IXII"), word("IIXI"), word("IIIX")]; + let mut coeffs: Vec = vec![1.0, 2.0, 3.0, 4.0]; + canonicalize_pauli_sum(&mut basis, &mut coeffs, &g); + // All four collapse to one rep with coeff 1+2+3+4 = 10. + assert_eq!(basis.len(), 1); + assert!((coeffs[0] - 10.0).abs() < 1e-12); +} + +#[test] +fn canonicalize_pauli_sum_keeps_distinct_orbits() { + let g = TranslationGroup::chain_1d(4); + // Two distinct orbits: {XIII, ...} (size 4) and {ZIII, ...} (size 4). + let mut basis: Vec = vec![word("XIII"), word("IXII"), word("ZIII"), word("IZII")]; + let mut coeffs: Vec = vec![1.0, 1.0, 2.0, 2.0]; + canonicalize_pauli_sum(&mut basis, &mut coeffs, &g); + assert_eq!(basis.len(), 2); + // Coefficients should be {2.0, 4.0} in some order. + let mut cs = coeffs.clone(); + cs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert!((cs[0] - 2.0).abs() < 1e-12); + assert!((cs[1] - 4.0).abs() < 1e-12); +} + +#[test] +fn canonicalize_with_shift_round_trip() { + // For each cyclic shift of "IIXY" by `a` positions, the shift + // counter returned should reproduce the original word when + // applied to the canonical rep. + let g = TranslationGroup::chain_1d(4); + for src in ["IIXY", "IXYI", "XYII", "YIIX"] { + let w = word(src); + let (rep, cnt) = g.canonicalize_with_shift(&w); + // Apply gen 0 `cnt[0]` times to rep, should equal w. + let mut cur = rep; + for _ in 0..cnt[0] { + cur = g.apply_generator(&cur, 0); + } + assert_eq!(cur, w, "shift {cnt:?} doesn't reproduce {src}"); + } +} + +#[test] +fn character_trivial_sector_is_one() { + let g = TranslationGroup::chain_1d(4); + // k=0 mode → character is always 1. + for cnt in [vec![0u32], vec![1u32], vec![2u32], vec![3u32]] { + let chi = g.character(&[0], &cnt); + assert!((chi - Complex::new(1.0, 0.0)).norm() < 1e-12); + } +} + +#[test] +fn character_obeys_unit_modulus() { + let g = TranslationGroup::chain_1d(4); + for k in 0..4 { + for a in 0..4 { + let chi = g.character(&[k], &[a as u32]); + assert!( + (chi.norm() - 1.0).abs() < 1e-12, + "|χ_{k}(T^{a})| should be 1, got {}", + chi.norm() + ); + } + } +} + +#[test] +fn momentum_zero_complex_merge_matches_real_merge() { + // k=0 sector: complex merge with all-real input should give + // real-valued orbit-rep coefficients equal to the plain + // canonicalize_pauli_sum result. + let g = TranslationGroup::chain_1d(4); + let basis: Vec = vec![word("XIII"), word("IXII"), word("IIXI"), word("IIIX")]; + let real_coeffs = vec![1.0, 2.0, 3.0, 4.0]; + + let mut basis_real = basis.clone(); + let mut coeffs_real = real_coeffs.clone(); + canonicalize_pauli_sum(&mut basis_real, &mut coeffs_real, &g); + + let mut basis_c = basis.clone(); + let mut coeffs_c: Vec> = + real_coeffs.iter().map(|&v| Complex::new(v, 0.0)).collect(); + canonicalize_pauli_sum_complex(&mut basis_c, &mut coeffs_c, &g, &[0]); + + // Plain merge sums all coefficients onto the single orbit-rep: + // 1+2+3+4 = 10. Complex merge does the same with a 1/|G| + // prefactor, so we expect 10/4 = 2.5 on the rep. + assert_eq!(basis_real.len(), 1); + assert_eq!(basis_c.len(), 1); + assert!((coeffs_real[0] - 10.0).abs() < 1e-12); + assert!((coeffs_c[0].re - 2.5).abs() < 1e-12); + assert!(coeffs_c[0].im.abs() < 1e-12); +} + +#[test] +fn momentum_eigenstate_check_passes() { + // O = Σ_j e^{ikj} Z_j for k = 2π/4 (mode 1) is a momentum-k + // eigenstate. check_momentum_sector should accept. + let g = TranslationGroup::chain_1d(4); + let basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; + let k_mode: i32 = 1; + // Sector condition: c_{T^a p} = e^{-2πi k a / N} c_p. + // Picking c_{Z_0} = 1: c_{Z_a} = e^{-2πi · 1 · a / 4} = (-i)^a. + let coeffs: Vec> = (0..4_i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * (k_mode as f64) * (a as f64) / 4.0)) + .collect(); + let res = check_momentum_sector(&basis, &coeffs, &g, &[k_mode], 1e-10); + assert!( + res.is_ok(), + "valid k-eigenstate failed sector check: {res:?}" + ); +} + +#[test] +fn momentum_eigenstate_check_fails_for_wrong_sector() { + // Same eigenstate as above, but check against the wrong momentum. + let g = TranslationGroup::chain_1d(4); + let basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; + let coeffs: Vec> = (0..4_i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * 1.0 * (a as f64) / 4.0)) + .collect(); + // Check against k=0 (constant) — should fail. + let res = check_momentum_sector(&basis, &coeffs, &g, &[0], 1e-10); + assert!(res.is_err(), "k=1 eigenstate wrongly passed as k=0 sector"); +} + +#[test] +fn momentum_eigenstate_round_trip_merge_preserves_rep_coeff() { + // Merge a k=1 eigenstate; the orbit-rep coefficient should be + // unchanged (= 1.0 for our chosen normalization, picking + // c_{Z_0} = 1). + let g = TranslationGroup::chain_1d(4); + let mut basis: Vec = vec![word("ZIII"), word("IZII"), word("IIZI"), word("IIIZ")]; + let mut coeffs: Vec> = (0..4_i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * 1.0 * (a as f64) / 4.0)) + .collect(); + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &g, &[1]); + assert_eq!(basis.len(), 1); + // The canonical rep of single-Z orbit is Z_0 (lex-min of + // {ZIII, IZII, IIZI, IIIZ} is IIIZ since 'I' < 'Z' lex-wise on + // the (xbits, zbits) tuple; let's just check we got a single + // entry with norm 1. + assert!( + (coeffs[0].norm() - 1.0).abs() < 1e-10, + "expected |c_rep|=1, got {}", + coeffs[0].norm() + ); +} + +/// Trotter-mode end-to-end check that `PauliSum::symmetry_merge` +/// matches plain Trotter evolution post-canonicalized. +/// +/// Setup: n=4 qubit chain, PBC, XY rotations on each bond. Initial +/// operator `O(0) = Σ_j Z_j` is translation-invariant. +/// +/// **dt must be tiny.** First-order Trotter on a chain with PBC is +/// only translation-equivariant up to `O(dt^2)` (gate-order +/// commutator errors are NOT themselves T-symmetric). The +/// "merge-after-each-step" trajectory and the "merge-at-end" +/// trajectory therefore diverge by an amount proportional to that +/// Trotter error. We test in the dt → 0 limit where the divergence +/// is below FP noise. +#[test] +fn pauli_sum_symmetry_merge_matches_plain_trotter() { + use crate::config::indexmap::ByteFxHashF64; + use crate::prelude::*; + + type Cfg = ByteFxHashF64<1>; + + let n: usize = 4; + // Tiny dt — Trotter per-step error scales as dt^2 and shows up + // as a translation-non-equivariant correction; we want it below + // FP noise at the tolerance we assert below (1e-7). + let dt = 1e-5_f64; + let n_steps = 2usize; + let group = TranslationGroup::chain_1d(n); + + // Total-Z initial: O(0) = Σ_j Z_j (translation-invariant). + let mut o_u: PauliSum = PauliSum::builder().n_qubits(n).build(); + let mut o_m: PauliSum = PauliSum::builder().n_qubits(n).build(); + for j in 0..n { + let mut s: Vec = vec!['I'; n]; + s[j] = 'Z'; + let st: String = s.into_iter().collect(); + o_u += (st.as_str(), 1.0); + o_m += (st.as_str(), 1.0); + } + assert_eq!(o_u.len(), n); + assert_eq!(o_m.len(), n); + + // Apply XY Trotter steps to both copies. With merging, call + // symmetry_merge_pauli_sum after each step. + for _ in 0..n_steps { + for j in 0..n { + let nxt = (j + 1) % n; + o_u.rxx(j, nxt, dt); + o_u.ryy(j, nxt, dt); + o_m.rxx(j, nxt, dt); + o_m.ryy(j, nxt, dt); + } + symmetry_merge_pauli_sum(&mut o_m, &group); + } + + // Canonicalize the un-merged result once at the end. + symmetry_merge_pauli_sum(&mut o_u, &group); + + // Compare as (word → coeff) maps, FP tolerance. + let u: FxHashMap<_, f64> = o_u.iter().map(|(w, c)| (*w, *c)).collect(); + let m: FxHashMap<_, f64> = o_m.iter().map(|(w, c)| (*w, *c)).collect(); + assert_eq!( + u.len(), + m.len(), + "post-merge basis sizes differ: u={} vs m={}", + u.len(), + m.len() + ); + let mut max_diff = 0.0_f64; + for (w, &cu) in &u { + let cm = *m.get(w).unwrap_or_else(|| { + panic!("rep present in u but not in m: {:?}", w); + }); + max_diff = max_diff.max((cu - cm).abs()); + } + // At dt = 1e-5 over 2 steps, accumulated Trotter + // commutator-induced T-eq error is ~2·dt^2·|H|^2 ≈ 1e-9; we + // assert 1e-7 to leave safety margin. + assert!( + max_diff < 1e-7, + "Trotter with-merging diverged from without-merging: max |Δc| = {max_diff:e}" + ); +} diff --git a/crates/ppvm-pauli-sum/tests/symmetry_api.rs b/crates/ppvm-pauli-sum/tests/symmetry_api.rs new file mode 100644 index 00000000..ddfdc86f --- /dev/null +++ b/crates/ppvm-pauli-sum/tests/symmetry_api.rs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use num::Complex; +use ppvm_pauli_sum::symmetry::{ + TranslationGroup, canonicalize_pauli_sum, canonicalize_pauli_sum_complex, check_momentum_sector, +}; +use ppvm_pauli_word::word::PauliWord; + +type W = PauliWord<[u8; 1], fxhash::FxBuildHasher, true>; + +#[test] +fn public_symmetry_imports_remain_available() { + let group = TranslationGroup::chain_1d(2); + + let mut real_basis: Vec = vec![W::from("XI"), W::from("IX")]; + let mut real_coeffs = vec![1.0, 1.0]; + canonicalize_pauli_sum(&mut real_basis, &mut real_coeffs, &group); + assert_eq!(real_basis.len(), 1); + + let mut complex_basis: Vec = vec![W::from("ZI"), W::from("IZ")]; + let mut complex_coeffs = vec![Complex::new(1.0, 0.0); 2]; + assert!(check_momentum_sector(&complex_basis, &complex_coeffs, &group, &[0], 1e-12,).is_ok()); + canonicalize_pauli_sum_complex(&mut complex_basis, &mut complex_coeffs, &group, &[0]); + assert_eq!(complex_basis.len(), 1); +} From 990dca77ec3033b0c24da8259e99e10ca90ef108 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:32:47 +0200 Subject: [PATCH 05/14] fix(pauli-sum): validate translation groups Co-authored-by: Cursor --- crates/ppvm-pauli-sum/src/symmetry/group.rs | 143 ++++++++++++++++++-- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 44 ++++++ 2 files changed, 172 insertions(+), 15 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index 0cefbc7c..2b0306d1 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -5,6 +5,55 @@ use ppvm_pauli_word::word::PauliWord; use ppvm_traits::{HashFinalize, PauliStorage, PauliWordTrait}; use std::hash::BuildHasher; +fn gcd(mut a: usize, mut b: usize) -> usize { + while b != 0 { + (a, b) = (b, a % b); + } + a +} + +fn checked_lcm(a: usize, b: usize, context: &str) -> usize { + a.checked_div(gcd(a, b)) + .and_then(|q| q.checked_mul(b)) + .unwrap_or_else(|| panic!("{context} overflow")) +} + +fn permutation_order(perm: &[u32], generator: usize) -> u32 { + let mut seen = vec![false; perm.len()]; + let mut order = 1usize; + for start in 0..perm.len() { + if seen[start] { + continue; + } + let mut length = 0usize; + let mut q = start; + loop { + assert!(!seen[q], "generator {generator} contains a malformed cycle"); + seen[q] = true; + length += 1; + q = perm[q] as usize; + if q == start { + break; + } + } + order = checked_lcm(order, length, "permutation order"); + } + u32::try_from(order).unwrap_or_else(|_| { + panic!("generator {generator} exact permutation order does not fit in u32") + }) +} + +fn permutations_commute(left: &[u32], right: &[u32]) -> bool { + (0..left.len()).all(|q| left[right[q] as usize] == right[left[q] as usize]) +} + +pub(super) fn checked_group_order(orders: &[u32]) -> usize { + orders.iter().enumerate().fold(1usize, |acc, (g, &value)| { + acc.checked_mul(value as usize) + .unwrap_or_else(|| panic!("group order overflows usize at generator {g}")) + }) +} + /// A finite abelian symmetry group acting on qubit positions by /// permutations. /// @@ -31,6 +80,9 @@ pub struct TranslationGroup { pub(super) perms: Vec>, /// Cyclic order of each generator. pub(super) orders: Vec, + order: usize, + #[allow(dead_code)] // consumed by momentum character code in Task 3 + phase_modulus: usize, } impl TranslationGroup { @@ -60,49 +112,101 @@ impl TranslationGroup { seen[p as usize] = true; } } + for (g, &declared) in orders.iter().enumerate() { + assert!(declared != 0, "generator {g} order must be nonzero"); + let exact = permutation_order(&perms[g], g); + assert_eq!( + declared, exact, + "generator {g} declared order {declared} != exact permutation order {exact}", + ); + } + for left in 0..perms.len() { + for right in left + 1..perms.len() { + assert!( + permutations_commute(&perms[left], &perms[right]), + "generators {left} and {right} do not commute", + ); + } + } + let order = checked_group_order(&orders); + let phase_modulus = orders.iter().fold(1usize, |acc, &value| { + checked_lcm(acc, value as usize, "character phase modulus") + }); Self { n_qubits, perms, orders, + order, + phase_modulus, } } /// 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 = (0..n).map(|q| ((q + 1) % n) as u32).collect(); - Self::from_generators(n, vec![perm], vec![n as u32]) + assert!(n > 0, "chain_1d: n must be positive"); + let order = + u32::try_from(n).unwrap_or_else(|_| panic!("chain_1d: n={n} does not fit in u32")); + let perm: Vec = (0..n) + .map(|q| { + u32::try_from((q + 1) % n).expect("chain_1d: target index does not fit in u32") + }) + .collect(); + Self::from_generators(n, vec![perm], vec![order]) } /// 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; + assert!(lx > 0, "torus_2d: lx must be positive"); + assert!(ly > 0, "torus_2d: ly must be positive"); + let n = lx + .checked_mul(ly) + .unwrap_or_else(|| panic!("torus_2d: lx * ly overflow")); + let lx_u32 = + u32::try_from(lx).unwrap_or_else(|_| panic!("torus_2d: lx={lx} does not fit in u32")); + let ly_u32 = + u32::try_from(ly).unwrap_or_else(|_| panic!("torus_2d: ly={ly} does not fit in u32")); let perm_x: Vec = (0..n) .map(|q| { let (i, j) = (q % lx, q / lx); - (j * lx + (i + 1) % lx) as u32 + u32::try_from(j * lx + (i + 1) % lx) + .expect("torus_2d: x-shift target index does not fit in u32") }) .collect(); let perm_y: Vec = (0..n) .map(|q| { let (i, j) = (q % lx, q / lx); - (((j + 1) % ly) * lx + i) as u32 + u32::try_from(((j + 1) % ly) * lx + i) + .expect("torus_2d: y-shift target index does not fit in u32") }) .collect(); - Self::from_generators(n, vec![perm_x, perm_y], vec![lx as u32, ly as u32]) + Self::from_generators(n, vec![perm_x, perm_y], vec![lx_u32, ly_u32]) } /// 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; + assert!(lx > 0, "torus_3d: lx must be positive"); + assert!(ly > 0, "torus_3d: ly must be positive"); + assert!(lz > 0, "torus_3d: lz must be positive"); + let n = lx + .checked_mul(ly) + .and_then(|v| v.checked_mul(lz)) + .unwrap_or_else(|| panic!("torus_3d: lx * ly * lz overflow")); + let lx_u32 = + u32::try_from(lx).unwrap_or_else(|_| panic!("torus_3d: lx={lx} does not fit in u32")); + let ly_u32 = + u32::try_from(ly).unwrap_or_else(|_| panic!("torus_3d: ly={ly} does not fit in u32")); + let lz_u32 = + u32::try_from(lz).unwrap_or_else(|_| panic!("torus_3d: lz={lz} does not fit in u32")); let perm_x: Vec = (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 + u32::try_from(k * lx * ly + j * lx + (i + 1) % lx) + .expect("torus_3d: x-shift target index does not fit in u32") }) .collect(); let perm_y: Vec = (0..n) @@ -110,7 +214,8 @@ impl TranslationGroup { let i = q % lx; let j = (q / lx) % ly; let k = q / (lx * ly); - (k * lx * ly + ((j + 1) % ly) * lx + i) as u32 + u32::try_from(k * lx * ly + ((j + 1) % ly) * lx + i) + .expect("torus_3d: y-shift target index does not fit in u32") }) .collect(); let perm_z: Vec = (0..n) @@ -118,13 +223,14 @@ impl TranslationGroup { let i = q % lx; let j = (q / lx) % ly; let k = q / (lx * ly); - (((k + 1) % lz) * lx * ly + j * lx + i) as u32 + u32::try_from(((k + 1) % lz) * lx * ly + j * lx + i) + .expect("torus_3d: z-shift target index does not fit in u32") }) .collect(); Self::from_generators( n, vec![perm_x, perm_y, perm_z], - vec![lx as u32, ly as u32, lz as u32], + vec![lx_u32, ly_u32, lz_u32], ) } @@ -134,15 +240,22 @@ impl TranslationGroup { /// `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; + assert!(l > 0, "ladder: l must be positive"); + assert!(n_legs > 0, "ladder: n_legs must be positive"); + let n = l + .checked_mul(n_legs) + .unwrap_or_else(|| panic!("ladder: l * n_legs overflow")); + let l_u32 = + u32::try_from(l).unwrap_or_else(|_| panic!("ladder: l={l} does not fit in u32")); let perm: Vec = (0..n) .map(|q| { let leg = q / l; let j = q % l; - (leg * l + (j + 1) % l) as u32 + u32::try_from(leg * l + (j + 1) % l) + .expect("ladder: shift target index does not fit in u32") }) .collect(); - Self::from_generators(n, vec![perm], vec![l as u32]) + Self::from_generators(n, vec![perm], vec![l_u32]) } /// Number of qubits the group acts on. @@ -157,7 +270,7 @@ impl TranslationGroup { /// Total group order: `Π orders[g]`. pub fn order(&self) -> usize { - self.orders.iter().map(|&o| o as usize).product() + self.order } /// Permutation associated with the `g`-th generator (one application). diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index be8fbe4b..6ffe0d59 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -325,3 +325,47 @@ fn pauli_sum_symmetry_merge_matches_plain_trotter() { "Trotter with-merging diverged from without-merging: max |Δc| = {max_diff:e}" ); } + +#[test] +#[should_panic(expected = "generator 0 order must be nonzero")] +fn rejects_zero_generator_order() { + TranslationGroup::from_generators(2, vec![vec![1, 0]], vec![0]); +} + +#[test] +#[should_panic(expected = "declared order 4 != exact permutation order 2")] +fn rejects_inflated_generator_order() { + TranslationGroup::from_generators(2, vec![vec![1, 0]], vec![4]); +} + +#[test] +#[should_panic(expected = "generators 0 and 1 do not commute")] +fn rejects_noncommuting_generators() { + let swap_01 = vec![1, 0, 2]; + let swap_12 = vec![0, 2, 1]; + TranslationGroup::from_generators(3, vec![swap_01, swap_12], vec![2, 2]); +} + +#[test] +fn rejects_zero_lattice_dimensions() { + assert!(std::panic::catch_unwind(|| TranslationGroup::chain_1d(0)).is_err()); + assert!(std::panic::catch_unwind(|| TranslationGroup::torus_2d(0, 2)).is_err()); + assert!(std::panic::catch_unwind(|| TranslationGroup::torus_3d(2, 0, 2)).is_err()); + assert!(std::panic::catch_unwind(|| TranslationGroup::ladder(2, 0)).is_err()); +} + +#[test] +fn rejects_dimension_product_overflow_before_allocation() { + assert!(std::panic::catch_unwind(|| TranslationGroup::torus_2d(usize::MAX, 2)).is_err()); + assert!(std::panic::catch_unwind(|| TranslationGroup::ladder(usize::MAX, 2)).is_err()); +} + +#[test] +fn rejects_group_order_overflow() { + let orders = if usize::BITS == 64 { + vec![u32::MAX, u32::MAX, u32::MAX] + } else { + vec![u32::MAX, u32::MAX] + }; + assert!(std::panic::catch_unwind(|| { super::group::checked_group_order(&orders) }).is_err()); +} From 9ce8852b561b3032f226d4c90edf0eaf74f915ee Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:34:33 +0200 Subject: [PATCH 06/14] refactor(pauli-sum): centralize symmetry traversal Co-authored-by: Cursor --- crates/ppvm-pauli-sum/src/symmetry/group.rs | 166 ++++++++++---------- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 44 ++++++ 2 files changed, 127 insertions(+), 83 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index 2b0306d1..75741e3e 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -313,6 +313,27 @@ impl TranslationGroup { out } + pub(super) fn orbit_with_counters<'a, A, S, const R: bool>( + &'a self, + word: &'a PauliWord, + ) -> GroupOrbit<'a, A, S, R> + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + assert_eq!( + word.n_qubits(), + self.n_qubits, + "word and group must agree on n_qubits" + ); + GroupOrbit { + group: self, + current: *word, + counter: vec![0; self.orders.len()], + remaining: self.order, + } + } + /// Lex-min canonical representative of `w`'s translation orbit /// under this group. Walks the full group via mixed-radix counters, /// keeping the smallest word seen. @@ -323,49 +344,17 @@ impl TranslationGroup { A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { - debug_assert_eq!( - w.n_qubits(), - self.n_qubits, - "word and group must agree on n_qubits" - ); if self.perms.is_empty() { return *w; } - // Mixed-radix counter `(c[0], c[1], …)` ranges over - // `0..orders[0] × 0..orders[1] × …`. We track the "current" - // word obtained by applying generator `g` once each time - // `c[g]` increments; rolling over `c[g]` means we apply - // generator `g` exactly `orders[g]` times (= identity), so - // `cur` returns to the orbit member that had `c[g..]` as its - // tail and `0` in slots 0..g. - // - // The simplest correct implementation just enumerates: for each - // group element index, build the corresponding word from scratch - // by applying the right number of each generator. - let mut best = *w; - let order = self.order(); - let mut idx = 0usize; - while idx < order { - // Decode `idx` to mixed-radix counter `c` - let mut rem = idx; - let mut counters: Vec = Vec::with_capacity(self.perms.len()); - for &o in &self.orders { - counters.push((rem as u32) % o); - rem /= o as usize; - } - // Construct the group element's permutation by composing - // `generator g` applied `c[g]` times, for each g. - // We do this lazily by iterating over qubits. - let mut cur = *w; - for (g, &c) in counters.iter().enumerate() { - for _ in 0..c { - cur = self.apply_generator(&cur, g); - } + let mut traversal = self.orbit_with_counters(w); + let (mut best, _) = traversal + .next() + .expect("a finite group contains the identity"); + for (candidate, _) in traversal { + if candidate < best { + best = candidate; } - if cur < best { - best = cur; - } - idx += 1; } best } @@ -388,43 +377,25 @@ impl TranslationGroup { A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { - debug_assert_eq!(w.n_qubits(), self.n_qubits); if self.perms.is_empty() { return (*w, Vec::new()); } - let mut best = *w; - let mut best_counter: Vec = vec![0; self.perms.len()]; - let order = self.order(); - for idx in 0..order { - // Decode `idx` to mixed-radix counter. - let mut rem = idx; - let mut counter: Vec = Vec::with_capacity(self.perms.len()); - for &o in &self.orders { - counter.push((rem as u32) % o); - rem /= o as usize; - } - // Build the candidate by applying generator `g` exactly - // `counter[g]` times. - let mut cur = *w; - for (g, &c) in counter.iter().enumerate() { - for _ in 0..c { - cur = self.apply_generator(&cur, g); - } - } - if cur < best { - best = cur; - // We need the counter such that g·best = w. The loop - // above computed cur = g·w with counter, so w = g^{-1}·cur. - // For abelian cyclic groups, g^{-1} = g^{order-1}, i.e. - // the counter `(orders[g] - counter[g]) mod orders[g]`. - best_counter = counter - .iter() - .zip(self.orders.iter()) - .map(|(&c, &o)| (o - c) % o) - .collect(); + let mut traversal = self.orbit_with_counters(w); + let (mut best, mut counter_from_word) = traversal + .next() + .expect("a finite group contains the identity"); + for (candidate, counter) in traversal { + if candidate < best { + best = candidate; + counter_from_word = counter; } } - (best, best_counter) + let counter_to_word = counter_from_word + .iter() + .zip(self.orders.iter()) + .map(|(&counter, &order)| (order - counter) % order) + .collect(); + (best, counter_to_word) } /// Iterate over all group elements applied to `w`. Yields `|G|` @@ -437,18 +408,47 @@ impl TranslationGroup { A: PauliStorage + 'a, S: BuildHasher + Clone + Default + HashFinalize + 'a, { - let order = self.order(); - (0..order).map(move |idx| { - let mut rem = idx; - let mut cur = *w; - for (g, &o) in self.orders.iter().enumerate() { - let c = (rem as u32) % o; - rem /= o as usize; - for _ in 0..c { - cur = self.apply_generator(&cur, g); - } + self.orbit_with_counters(w).map(|(candidate, _)| candidate) + } +} + +pub(super) struct GroupOrbit<'a, A, S, const R: bool> +where + A: PauliStorage, +{ + group: &'a TranslationGroup, + current: PauliWord, + counter: Vec, + remaining: usize, +} + +impl Iterator for GroupOrbit<'_, A, S, R> +where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + type Item = (PauliWord, Vec); + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let item = (self.current, self.counter.clone()); + self.remaining -= 1; + if self.remaining == 0 { + return Some(item); + } + for g in 0..self.group.orders.len() { + if self.group.orders[g] == 1 { + continue; } - cur - }) + self.current = self.group.apply_generator(&self.current, g); + self.counter[g] += 1; + if self.counter[g] < self.group.orders[g] { + break; + } + self.counter[g] = 0; + } + Some(item) } } diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 6ffe0d59..615e6c78 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -360,6 +360,50 @@ fn rejects_dimension_product_overflow_before_allocation() { assert!(std::panic::catch_unwind(|| TranslationGroup::ladder(usize::MAX, 2)).is_err()); } +#[test] +fn odometer_yields_expected_counter_order() { + let group = TranslationGroup::torus_2d(2, 3); + let counters: Vec> = group + .orbit_with_counters(&word("XIIIII")) + .map(|(_, counter)| counter) + .collect(); + assert_eq!( + counters, + vec![ + vec![0, 0], + vec![1, 0], + vec![0, 1], + vec![1, 1], + vec![0, 2], + vec![1, 2], + ], + ); +} + +#[test] +fn traversal_matches_brute_force_composition() { + let group = TranslationGroup::torus_2d(2, 3); + let source = word("XYZIII"); + for (candidate, counter) in group.orbit_with_counters(&source) { + let mut brute = source; + for (g, &count) in counter.iter().enumerate() { + for _ in 0..count { + brute = group.apply_generator(&brute, g); + } + } + assert_eq!(candidate, brute); + } +} + +#[test] +fn public_word_width_checks_are_not_debug_only() { + let group = TranslationGroup::chain_1d(4); + let short = word("XI"); + assert!(std::panic::catch_unwind(|| group.canonicalize(&short)).is_err()); + assert!(std::panic::catch_unwind(|| group.canonicalize_with_shift(&short)).is_err()); + assert!(std::panic::catch_unwind(|| group.orbit(&short)).is_err()); +} + #[test] fn rejects_group_order_overflow() { let orders = if usize::BITS == 64 { From 56f469ae007088d7e05e11be4701ad075c8e4411 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:36:28 +0200 Subject: [PATCH 07/14] fix(pauli-sum): compute symmetry characters exactly Co-authored-by: Cursor --- crates/ppvm-pauli-sum/src/symmetry/group.rs | 7 +++- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 35 +++++++++++++++---- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 23 ++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index 75741e3e..dc95bd4d 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -81,7 +81,6 @@ pub struct TranslationGroup { /// Cyclic order of each generator. pub(super) orders: Vec, order: usize, - #[allow(dead_code)] // consumed by momentum character code in Task 3 phase_modulus: usize, } @@ -283,6 +282,12 @@ impl TranslationGroup { self.orders[g] } + /// Least common multiple of generator orders; denominator for exact + /// character phase arithmetic. + pub(super) fn phase_modulus(&self) -> usize { + self.phase_modulus + } + /// Apply a single generator's permutation to a Pauli word, returning /// the resulting word. /// diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index 87f9f109..bb5c93ea 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -11,6 +11,33 @@ use std::hash::BuildHasher; use super::group::TranslationGroup; impl TranslationGroup { + /// Integer numerator of the character phase before division by + /// [`Self::phase_modulus`]: `Σ_g (k[g] · counter[g] / orders[g]) mod 1` + /// expressed as an integer in `[0, phase_modulus)`. + pub(super) fn character_numerator(&self, k_modes: &[i32], counter: &[u32]) -> usize { + assert_eq!( + k_modes.len(), + self.n_generators(), + "k_modes length mismatch" + ); + assert_eq!( + counter.len(), + self.n_generators(), + "counter length mismatch" + ); + let modulus = self.phase_modulus() as u128; + let mut numerator = 0u128; + for g in 0..self.n_generators() { + let order = self.generator_order(g); + let k = (k_modes[g] as i64).rem_euclid(order as i64) as u128; + let count = (counter[g] % order) as u128; + let reduced = (k * count) % order as u128; + let factor = self.phase_modulus() as u128 / order as u128; + numerator = (numerator + reduced * factor) % modulus; + } + numerator as usize + } + /// Momentum-sector character `χ_k(g) = exp(i Σ_g 2π · k[g] · counter[g] / orders[g])` /// where `k[g] ∈ ℤ` is the integer momentum mode along generator `g` /// (the corresponding wavenumber is `2π · k[g] / orders[g]`). @@ -20,12 +47,8 @@ impl TranslationGroup { /// (`k = [0, …]`) sector all characters are `1` — phase-aware merging /// reduces to plain merging. pub fn character(&self, k_modes: &[i32], counter: &[u32]) -> Complex { - debug_assert_eq!(k_modes.len(), self.perms.len()); - debug_assert_eq!(counter.len(), self.perms.len()); - let mut phase = 0.0_f64; - for ((&k, &c), &o) in k_modes.iter().zip(counter.iter()).zip(self.orders.iter()) { - phase += 2.0 * PI * (k as f64) * (c as f64) / (o as f64); - } + let numerator = self.character_numerator(k_modes, counter); + let phase = 2.0 * PI * numerator as f64 / self.phase_modulus() as f64; Complex::from_polar(1.0, phase) } } diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 615e6c78..e8983e1a 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -160,6 +160,29 @@ fn character_obeys_unit_modulus() { } } +#[test] +fn character_numerator_normalizes_negative_modes() { + let group = TranslationGroup::chain_1d(4); + assert_eq!(group.character_numerator(&[-1], &[1]), 3); + assert_eq!(group.character_numerator(&[3], &[1]), 3); + assert!((group.character(&[-1], &[1]) - Complex::new(0.0, -1.0)).norm() < 1e-12); +} + +#[test] +fn exact_character_detects_cross_generator_kernel() { + let swap = vec![1, 0]; + let group = TranslationGroup::from_generators(2, vec![swap.clone(), swap], vec![2, 2]); + assert_ne!(group.character_numerator(&[1, 0], &[1, 1]), 0); + assert_eq!(group.character_numerator(&[1, 1], &[1, 1]), 0); +} + +#[test] +fn character_checks_slice_lengths_in_release_builds() { + let group = TranslationGroup::chain_1d(4); + assert!(std::panic::catch_unwind(|| group.character(&[], &[0])).is_err()); + assert!(std::panic::catch_unwind(|| group.character(&[0], &[])).is_err()); +} + #[test] fn momentum_zero_complex_merge_matches_real_merge() { // k=0 sector: complex merge with all-real input should give From dc9720d5672b4ba7d0da0805ea732eebccd19fbe Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:38:35 +0200 Subject: [PATCH 08/14] fix(pauli-sum): normalize momentum projection by orbit Co-authored-by: Cursor --- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 73 ++++++++++++++----- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 57 +++++++++++++-- 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index bb5c93ea..0b30589c 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 -use fxhash::FxHashMap; +use fxhash::{FxHashMap, FxHashSet}; use num::Complex; use ppvm_pauli_word::word::PauliWord; use ppvm_traits::{HashFinalize, PauliStorage}; @@ -56,19 +56,25 @@ impl TranslationGroup { /// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form /// **projected onto momentum sector `k_modes`**. /// -/// Each Pauli `p` is replaced by its canonical rep `r`; the contribution -/// is `(1/|G|) · χ_k(g) · c_p` where `g` is the group element such that -/// `g · r = p` and `χ_k(g) = exp(2πi · Σ_g k_modes[g] · counter[g] / orders[g])`. +/// For each represented orbit, coefficients on orbit members are averaged +/// with the momentum character weight: +/// `(1/|orbit|) · Σ_{p ∈ orbit} χ_k(g_p) · c_p` where `g_p` is the group +/// element such that `g_p · rep = p`. +/// +/// Orbits whose stabilizer is incompatible with `k_modes` (distinct orbit +/// members carry different character numerators) project to zero and are +/// omitted from the output. /// /// If the input was already a momentum-`k_modes` eigenstate (i.e. the /// coefficients satisfy `c_{g·p} = χ_k(g)⁻¹ · c_p` for every orbit), /// the output is the orbit-rep coefficients of that state unchanged. -/// Otherwise the merge discards the components in other sectors — +/// Otherwise the projection discards the components in other sectors — /// use [`check_momentum_sector`] beforehand to validate. /// -/// For the `k_modes = [0, 0, …]` (trivial) sector this reduces to plain -/// [`canonicalize_pauli_sum`] (real coefficients work, but on complex -/// input the result is complex with vanishing imaginary part). +/// For the `k_modes = [0, 0, …]` (trivial) sector all characters are `1`, +/// so projection reduces to averaging orbit members onto each rep (not +/// plain [`canonicalize_pauli_sum`], which sums without the orbit-size +/// normalization). pub fn canonicalize_pauli_sum_complex( basis: &mut Vec>, coeffs: &mut Vec>, @@ -90,20 +96,49 @@ pub fn canonicalize_pauli_sum_complex( k_modes.len(), group.n_generators() ); - let inv_g: f64 = 1.0 / (group.order() as f64); - let mut merged: FxHashMap, Complex> = - FxHashMap::with_capacity_and_hasher(basis.len(), Default::default()); - for (w, &c) in basis.iter().zip(coeffs.iter()) { - let (rep, cnt) = group.canonicalize_with_shift(w); - let chi = group.character(k_modes, &cnt); - let contrib = inv_g * chi * c; - *merged.entry(rep).or_insert(Complex::new(0.0, 0.0)) += contrib; + let mut input: FxHashMap, Complex> = FxHashMap::default(); + for (word, &coeff) in basis.iter().zip(coeffs.iter()) { + *input.entry(*word).or_insert(Complex::new(0.0, 0.0)) += coeff; + } + let reps: FxHashSet<_> = input.keys().map(|word| group.canonicalize(word)).collect(); + let mut projected = FxHashMap::default(); + + for rep in reps { + let mut members: FxHashMap<_, (Vec, usize)> = FxHashMap::default(); + let mut compatible = true; + for (member, counter) in group.orbit_with_counters(&rep) { + let numerator = group.character_numerator(k_modes, &counter); + match members.entry(member) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((counter, numerator)); + } + std::collections::hash_map::Entry::Occupied(entry) => { + if entry.get().1 != numerator { + compatible = false; + break; + } + } + } + } + if !compatible { + continue; + } + let orbit_size = members.len() as f64; + let mut rep_coeff = Complex::new(0.0, 0.0); + for (member, (counter, _)) in members { + let coeff = input + .get(&member) + .copied() + .unwrap_or(Complex::new(0.0, 0.0)); + rep_coeff += group.character(k_modes, &counter) * coeff / orbit_size; + } + projected.insert(rep, rep_coeff); } basis.clear(); coeffs.clear(); - basis.reserve(merged.len()); - coeffs.reserve(merged.len()); - for (w, c) in merged { + basis.reserve(projected.len()); + coeffs.reserve(projected.len()); + for (w, c) in projected { basis.push(w); coeffs.push(c); } diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index e8983e1a..cdd25344 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -184,10 +184,55 @@ fn character_checks_slice_lengths_in_release_builds() { } #[test] -fn momentum_zero_complex_merge_matches_real_merge() { - // k=0 sector: complex merge with all-real input should give - // real-valued orbit-rep coefficients equal to the plain - // canonicalize_pauli_sum result. +fn period_two_k_zero_round_trip_preserves_rep_coefficient() { + let group = TranslationGroup::chain_1d(4); + let mut basis = vec![word("XIXI"), word("IXIX")]; + let mut coeffs = vec![Complex::new(1.0, 0.0); 2]; + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[0]); + assert_eq!(basis.len(), 1); + assert!((coeffs[0] - Complex::new(1.0, 0.0)).norm() < 1e-12); +} + +#[test] +fn period_two_compatible_k_two_round_trip_preserves_rep_coefficient() { + let group = TranslationGroup::chain_1d(4); + let rep = group.canonicalize(&word("XIXI")); + let mut members: FxHashMap> = FxHashMap::default(); + for (member, counter) in group.orbit_with_counters(&rep) { + members + .entry(member) + .or_insert_with(|| group.character(&[2], &counter).conj()); + } + let (mut basis, mut coeffs): (Vec, Vec>) = members.into_iter().unzip(); + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[2]); + assert_eq!(basis, vec![rep]); + assert!((coeffs[0] - Complex::new(1.0, 0.0)).norm() < 1e-12); +} + +#[test] +fn incompatible_stabilizer_projects_orbit_to_zero() { + let group = TranslationGroup::chain_1d(4); + let mut basis = vec![word("XXXX")]; + let mut coeffs = vec![Complex::new(1.0, 0.0)]; + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[1]); + assert!(basis.is_empty()); + assert!(coeffs.is_empty()); +} + +#[test] +fn partial_period_two_orbit_is_averaged_with_missing_member_zero() { + let group = TranslationGroup::chain_1d(4); + let mut basis = vec![word("XIXI")]; + let mut coeffs = vec![Complex::new(1.0, 0.0)]; + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[0]); + assert_eq!(basis.len(), 1); + assert!((coeffs[0] - Complex::new(0.5, 0.0)).norm() < 1e-12); +} + +#[test] +fn momentum_zero_complex_projection_is_orbit_average() { + // k=0 sector: complex projection averages orbit members onto the rep; + // plain canonicalize_pauli_sum sums all coefficients onto the rep. let g = TranslationGroup::chain_1d(4); let basis: Vec = vec![word("XIII"), word("IXII"), word("IIXI"), word("IIIX")]; let real_coeffs = vec![1.0, 2.0, 3.0, 4.0]; @@ -202,8 +247,8 @@ fn momentum_zero_complex_merge_matches_real_merge() { canonicalize_pauli_sum_complex(&mut basis_c, &mut coeffs_c, &g, &[0]); // Plain merge sums all coefficients onto the single orbit-rep: - // 1+2+3+4 = 10. Complex merge does the same with a 1/|G| - // prefactor, so we expect 10/4 = 2.5 on the rep. + // 1+2+3+4 = 10. Complex k=0 projection averages over the orbit + // (size 4), so we expect 10/4 = 2.5 on the rep. assert_eq!(basis_real.len(), 1); assert_eq!(basis_c.len(), 1); assert!((coeffs_real[0] - 10.0).abs() < 1e-12); From d5a5faf1604089415f1e6d2a0cf61e9e8369442f Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:40:30 +0200 Subject: [PATCH 09/14] fix(pauli-sum): validate complete momentum sectors Co-authored-by: Cursor --- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 184 +++++++++++++----- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 47 +++++ 2 files changed, 180 insertions(+), 51 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index 0b30589c..4d304385 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -171,72 +171,154 @@ where assert_eq!(basis.len(), coeffs.len()); assert_eq!(k_modes.len(), group.n_generators()); - // Group entries by orbit rep, picking the first-seen member as - // reference and checking later members against it. - let mut reference: FxHashMap, (Complex, Vec)> = - FxHashMap::default(); - for (p, &c) in basis.iter().zip(coeffs.iter()) { - let (rep, cnt) = group.canonicalize_with_shift(p); - let chi = group.character(k_modes, &cnt); - // expected c_p given the rep coefficient c_r: - // c_p = χ_k(g)⁻¹ · c_r, where p = g·r - // equivalently, c_r = χ_k(g) · c_p (a rearrangement). - let implied_rep_coeff = chi * c; - if let Some((rep_coeff, _ref_cnt)) = reference.get(&rep) { - if (implied_rep_coeff - rep_coeff).norm() > tol * rep_coeff.norm().max(1.0) { - return Err(SectorCheckError { + if !tol.is_finite() || tol < 0.0 { + return Err(SectorCheckError::InvalidTolerance { tol }); + } + let mut input: FxHashMap, Complex> = FxHashMap::default(); + for (pauli, &coeff) in basis.iter().zip(coeffs.iter()) { + if !coeff.re.is_finite() || !coeff.im.is_finite() { + return Err(SectorCheckError::NonFiniteCoefficient { + pauli: *pauli, + coeff, + }); + } + *input.entry(*pauli).or_insert(Complex::new(0.0, 0.0)) += coeff; + } + input.retain(|_, coeff| *coeff != Complex::new(0.0, 0.0)); + let reps: FxHashSet<_> = input + .keys() + .map(|pauli| group.canonicalize(pauli)) + .collect(); + + for rep in reps { + let mut members: FxHashMap<_, (Vec, usize)> = FxHashMap::default(); + for (member, counter) in group.orbit_with_counters(&rep) { + let numerator = group.character_numerator(k_modes, &counter); + match members.entry(member) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((counter, numerator)); + } + std::collections::hash_map::Entry::Occupied(entry) => { + if entry.get().1 != numerator { + return Err(SectorCheckError::IncompatibleStabilizer { + rep, + shift: counter, + }); + } + } + } + } + + let (reference_word, (reference_counter, _)) = members + .iter() + .find(|(member, _)| input.contains_key(*member)) + .expect("represented orbit has a nonzero member"); + let rep_coeff = group.character(k_modes, reference_counter) * input[reference_word]; + + for (member, (counter, _)) in members { + let expected = group.character(k_modes, &counter).conj() * rep_coeff; + let actual = input + .get(&member) + .copied() + .unwrap_or(Complex::new(0.0, 0.0)); + if (actual - expected).norm() > tol * rep_coeff.norm().max(1.0) { + return Err(SectorCheckError::CoefficientMismatch { rep, - expected: *rep_coeff, - got_implied: implied_rep_coeff, - offending_pauli: *p, - offending_coeff: c, - shift: cnt.clone(), + offending_pauli: member, + expected, + actual, + shift: counter, }); } - } else { - reference.insert(rep, (implied_rep_coeff, cnt)); } } Ok(()) } /// Detail report for a failed [`check_momentum_sector`]. -pub struct SectorCheckError { - /// Canonical orbit representative for which the check failed. - pub rep: PauliWord, - /// Coefficient that the *first* basis entry implied for `rep`. - pub expected: Complex, - /// Coefficient that `offending_pauli` implies for `rep` under the - /// purported momentum sector. - pub got_implied: Complex, - /// The basis entry whose coefficient is inconsistent with the - /// expected `rep` value. - pub offending_pauli: PauliWord, - /// Original coefficient of `offending_pauli` in the input basis. - pub offending_coeff: Complex, - /// Counter encoding the group element `g` such that - /// `g · rep == offending_pauli`. - pub shift: Vec, +pub enum SectorCheckError { + InvalidTolerance { + tol: f64, + }, + NonFiniteCoefficient { + pauli: PauliWord, + coeff: Complex, + }, + CoefficientMismatch { + rep: PauliWord, + offending_pauli: PauliWord, + expected: Complex, + actual: Complex, + shift: Vec, + }, + IncompatibleStabilizer { + rep: PauliWord, + shift: Vec, + }, } -impl std::fmt::Debug for SectorCheckError { +impl std::fmt::Debug for SectorCheckError +where + S: BuildHasher + Clone + Default + HashFinalize, +{ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "SectorCheckError {{ rep: , expected: {:?}, got_implied: {:?}, \ - offending: , offending_coeff: {:?}, shift: {:?} }}", - self.expected, self.got_implied, self.offending_coeff, self.shift, - ) + match self { + Self::InvalidTolerance { tol } => { + write!(f, "SectorCheckError::InvalidTolerance {{ tol: {tol:?} }}") + } + Self::NonFiniteCoefficient { pauli, coeff } => write!( + f, + "SectorCheckError::NonFiniteCoefficient {{ pauli: {pauli}, coeff: {coeff:?} }}" + ), + Self::CoefficientMismatch { + rep, + offending_pauli, + expected, + actual, + shift, + } => write!( + f, + "SectorCheckError::CoefficientMismatch {{ rep: {rep}, offending_pauli: \ + {offending_pauli}, expected: {expected:?}, actual: {actual:?}, shift: \ + {shift:?} }}" + ), + Self::IncompatibleStabilizer { rep, shift } => write!( + f, + "SectorCheckError::IncompatibleStabilizer {{ rep: {rep}, shift: {shift:?} }}" + ), + } } } -impl std::fmt::Display for SectorCheckError { +impl std::fmt::Display for SectorCheckError +where + S: BuildHasher + Clone + Default + HashFinalize, +{ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "input not in target momentum sector: orbit rep expected c={:?}, but \ - orbit member (shift {:?}, coeff {:?}) implies c={:?}", - self.expected, self.shift, self.offending_coeff, self.got_implied, - ) + match self { + Self::InvalidTolerance { tol } => write!( + f, + "invalid tolerance {tol:?}: must be finite and non-negative" + ), + Self::NonFiniteCoefficient { pauli, coeff } => { + write!(f, "non-finite coefficient {coeff:?} on Pauli word {pauli}") + } + 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:?}" + ), + Self::IncompatibleStabilizer { rep, shift } => write!( + f, + "stabilizer incompatible with momentum sector: orbit rep {rep} has conflicting \ + character numerators (shift {shift:?})" + ), + } } } diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index cdd25344..17102bcd 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -256,6 +256,53 @@ fn momentum_zero_complex_projection_is_orbit_average() { assert!(coeffs_c[0].im.abs() < 1e-12); } +#[test] +fn sector_check_rejects_missing_orbit_members() { + let group = TranslationGroup::chain_1d(4); + let basis = vec![word("ZIII")]; + let coeffs = vec![Complex::new(1.0, 0.0)]; + assert!(matches!( + check_momentum_sector(&basis, &coeffs, &group, &[0], 1e-12), + Err(SectorCheckError::CoefficientMismatch { .. }) + )); +} + +#[test] +fn sector_check_rejects_incompatible_stabilizer() { + let group = TranslationGroup::chain_1d(4); + let basis = vec![word("XXXX")]; + let coeffs = vec![Complex::new(1.0, 0.0)]; + assert!(matches!( + check_momentum_sector(&basis, &coeffs, &group, &[1], 1e-12), + Err(SectorCheckError::IncompatibleStabilizer { .. }) + )); +} + +#[test] +fn sector_check_rejects_invalid_numeric_inputs() { + let group = TranslationGroup::chain_1d(2); + let basis = vec![word("ZI")]; + assert!(matches!( + check_momentum_sector(&basis, &[Complex::new(1.0, 0.0)], &group, &[0], f64::NAN), + Err(SectorCheckError::InvalidTolerance { .. }) + )); + assert!(matches!( + check_momentum_sector(&basis, &[Complex::new(f64::NAN, 0.0)], &group, &[0], 1e-12), + Err(SectorCheckError::NonFiniteCoefficient { .. }) + )); +} + +#[test] +fn sector_error_display_names_the_words() { + let group = TranslationGroup::chain_1d(2); + let basis = vec![word("ZI")]; + let coeffs = vec![Complex::new(1.0, 0.0)]; + let message = check_momentum_sector(&basis, &coeffs, &group, &[0], 1e-12) + .unwrap_err() + .to_string(); + assert!(message.contains("ZI") || message.contains("IZ")); +} + #[test] fn momentum_eigenstate_check_passes() { // O = Σ_j e^{ikj} Z_j for k = 2π/4 (mode 1) is a momentum-k From d2c377f00f421ba054241bd534d140d4b4d95f90 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:44:14 +0200 Subject: [PATCH 10/14] docs(pauli-sum): clarify symmetry projection semantics Co-authored-by: Cursor --- crates/ppvm-pauli-sum/src/symmetry/group.rs | 31 +++++++++++++------ crates/ppvm-pauli-sum/src/symmetry/mod.rs | 14 +++++++-- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 17 ++++++---- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index dc95bd4d..d9f5fb5c 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -64,10 +64,11 @@ pub(super) fn checked_group_order(orders: &[u32]) -> usize { /// /// `perms[g]` is the permutation that **generator `g`** applies to qubit /// indices: a qubit at position `q` moves to position `perms[g][q]` -/// under one application of generator `g`. `orders[g]` is the cyclic -/// order of generator `g` (i.e. applying it `orders[g]` times returns -/// the identity). The full group is the direct product of the cyclic -/// subgroups, with size `Π orders[g]`. +/// under one application of generator `g`. `orders[g]` is the exact +/// cyclic order of generator `g`. The abstract group is the direct +/// product of these cyclic groups, with order `Π orders[g]`. Its combined +/// permutation action may have a kernel, so distinct group elements can +/// act identically. /// /// Only the **generators** are stored; the algorithm in /// [`Self::canonicalize`] walks the group via mixed-radix increments. @@ -88,7 +89,9 @@ impl TranslationGroup { /// Construct from explicit generator permutations and orders. /// /// Each `perm` must be a permutation of `0..n_qubits`. Each `order` - /// must satisfy `perm^order == identity`. + /// must be the permutation's exact cyclic order, not merely a + /// multiple for which `perm^order == identity`. Generators must + /// commute, but their combined action may still have a kernel. pub fn from_generators(n_qubits: usize, perms: Vec>, orders: Vec) -> Self { assert_eq!(perms.len(), orders.len(), "perms and orders must match"); for (g, perm) in perms.iter().enumerate() { @@ -267,7 +270,10 @@ impl TranslationGroup { self.perms.len() } - /// Total group order: `Π orders[g]`. + /// Abstract product-group order: `Π orders[g]`. + /// + /// This can exceed the number of distinct permutations in the action + /// when the combined action has a kernel. pub fn order(&self) -> usize { self.order } @@ -370,7 +376,10 @@ impl TranslationGroup { /// /// In other words: if `r = self.canonicalize(w)`, this returns /// `(r, c)` where applying generator `i` exactly `c[i]` times in - /// sequence to `r` produces `w`. The counter is used to compute + /// sequence to `r` produces `w`. It returns the first valid counter + /// selected by the deterministic mixed-radix traversal. Counters are + /// not unique when `r` has a non-trivial stabilizer (or when the + /// combined action has a kernel). The counter is used to compute /// momentum phases by the phase-aware merge routines. /// /// Same `O(|G| × n_qubits)` cost as `canonicalize`. @@ -403,8 +412,12 @@ impl TranslationGroup { (best, counter_to_word) } - /// Iterate over all group elements applied to `w`. Yields `|G|` - /// Pauli words (including `w` itself for the identity element). + /// Iterate over all abstract group elements applied to `w`. Yields + /// [`Self::order`] Pauli words (including `w` itself for the identity + /// element). + /// + /// Words may repeat when `w` has a stabilizer or the combined action + /// has a kernel; this is not an iterator over distinct orbit members. pub fn orbit<'a, A, S, const R: bool>( &'a self, w: &'a PauliWord, diff --git a/crates/ppvm-pauli-sum/src/symmetry/mod.rs b/crates/ppvm-pauli-sum/src/symmetry/mod.rs index 750acb72..30d6d4c0 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/mod.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/mod.rs @@ -28,9 +28,13 @@ //! ## Data model //! //! A `TranslationGroup` is specified by a list of generator permutations -//! and their cyclic orders. The group order is the product of the orders. -//! For instance, a 2D `L × L` torus has two generators (translation in -//! x and y) each of order `L`. +//! and their exact cyclic orders. [`TranslationGroup::order`] is the +//! abstract product of those orders. The combined permutation action need +//! not be faithful: different mixed-radix counters can induce the same +//! permutation, so the action may have a kernel and +//! [`TranslationGroup::orbit`] may repeat words. For instance, a 2D +//! `L × L` torus has two generators (translation in x and y) each of +//! order `L`. //! //! ## Canonicalization //! @@ -50,6 +54,10 @@ //! orbit's members. For dynamics that commute with `G` and initial //! states that are also `G`-invariant, this preserves the expectation //! value of any `G`-invariant observable (Theorem 1 of arXiv:2512.12094). +//! This real-coefficient merge sums collisions. By contrast, complex +//! projection into the trivial (`k=0`) momentum sector averages over the +//! distinct members of each orbit. Non-trivial sectors incompatible with +//! an orbit stabilizer project that orbit to zero. //! //! See the dedicated tests for correctness against full-basis evolution //! on small systems with no truncation. diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index 4d304385..6ecdd3ef 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -56,8 +56,8 @@ impl TranslationGroup { /// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form /// **projected onto momentum sector `k_modes`**. /// -/// For each represented orbit, coefficients on orbit members are averaged -/// with the momentum character weight: +/// For each represented orbit, coefficients on its **distinct** orbit +/// members are averaged with the momentum character weight: /// `(1/|orbit|) · Σ_{p ∈ orbit} χ_k(g_p) · c_p` where `g_p` is the group /// element such that `g_p · rep = p`. /// @@ -72,9 +72,9 @@ impl TranslationGroup { /// use [`check_momentum_sector`] beforehand to validate. /// /// For the `k_modes = [0, 0, …]` (trivial) sector all characters are `1`, -/// so projection reduces to averaging orbit members onto each rep (not -/// plain [`canonicalize_pauli_sum`], which sums without the orbit-size -/// normalization). +/// so projection averages the distinct orbit members onto each rep. This +/// differs from plain [`canonicalize_pauli_sum`], whose real-coefficient +/// merging sums collisions without orbit-size normalization. pub fn canonicalize_pauli_sum_complex( basis: &mut Vec>, coeffs: &mut Vec>, @@ -149,7 +149,12 @@ pub fn canonicalize_pauli_sum_complex( /// /// Concretely: for every orbit represented in the basis, all members /// must satisfy `c_{g·r} = χ_k(g)⁻¹ · c_r` for some choice of orbit-rep -/// coefficient `c_r`. +/// coefficient `c_r`. Orbit members absent from `basis` are treated as +/// having coefficient zero, rather than being ignored. +/// +/// An orbit with a stabilizer incompatible with `k_modes` cannot carry +/// that sector and fails with [`SectorCheckError::IncompatibleStabilizer`]; +/// the corresponding momentum projection would be zero. /// /// Returns `Ok(())` on pass; `Err(SectorCheckError)` on fail with the /// offending orbit-rep, expected coefficient, and actual coefficient. From 96723df4c6b055eb2f43e4af71359b34ebff69e3 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:50:23 +0200 Subject: [PATCH 11/14] style: fix clippy warnings blocking workspace verification Co-authored-by: Cursor --- crates/ppvm-pauli-sum/benches/truncation-weight.rs | 2 +- crates/ppvm-pauli-sum/examples/hash_quality.rs | 10 ++-------- crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs | 1 + crates/ppvm-tableau-sum/examples/truncation-scaling.rs | 3 ++- crates/ppvm-tableau/examples/profile_measure_all.rs | 4 ++-- .../ppvm-tableau/examples/profile_measure_all_flame.rs | 1 + crates/ppvm-tableau/src/data.rs | 6 +++--- 7 files changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/ppvm-pauli-sum/benches/truncation-weight.rs b/crates/ppvm-pauli-sum/benches/truncation-weight.rs index 0177a3a0..a363fccb 100644 --- a/crates/ppvm-pauli-sum/benches/truncation-weight.rs +++ b/crates/ppvm-pauli-sum/benches/truncation-weight.rs @@ -64,7 +64,7 @@ where .strategy(strat) .build(); for (w, c) in terms { - state += (w.clone(), *c); + state += (*w, *c); } state } diff --git a/crates/ppvm-pauli-sum/examples/hash_quality.rs b/crates/ppvm-pauli-sum/examples/hash_quality.rs index bd434c93..655710ca 100644 --- a/crates/ppvm-pauli-sum/examples/hash_quality.rs +++ b/crates/ppvm-pauli-sum/examples/hash_quality.rs @@ -13,7 +13,7 @@ //! * compare both for `[u8;8]` and `[u8;16]` storage use std::collections::HashMap; -use std::hash::{BuildHasher, Hash, Hasher}; +use std::hash::{BuildHasher, Hash}; use ppvm_pauli_sum::prelude::*; use ppvm_pauli_sum::strategy::CoefficientThreshold; @@ -70,13 +70,7 @@ where H: BuildHasher + Default, { let hasher = H::default(); - let hashes: Vec = keys - .map(|k| { - let mut h = hasher.build_hasher(); - k.hash(&mut h); - h.finish() - }) - .collect(); + let hashes: Vec = keys.map(|k| hasher.hash_one(&k)).collect(); let n = hashes.len(); let mut counts: HashMap = HashMap::new(); diff --git a/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs b/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs index 1a8ed284..8ca493ed 100644 --- a/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs +++ b/crates/ppvm-tableau-sum/examples/msd-noisy-compare.rs @@ -253,6 +253,7 @@ fn l1_distance_stats(a: &[[f64; 3]], b: &[[f64; 3]]) -> (f64, f64) { /// Run one sweep at fixed noise rate `p`. Builds the pure baseline and an /// alt-seed pure run (for the shot-noise floor), then sweeps `cutoffs` on /// the sum backend. Prints a comparison table. +#[allow(clippy::too_many_arguments)] fn run_sweep( label: &str, n_qubits: usize, diff --git a/crates/ppvm-tableau-sum/examples/truncation-scaling.rs b/crates/ppvm-tableau-sum/examples/truncation-scaling.rs index 8e1f4df9..5bd9c3dc 100644 --- a/crates/ppvm-tableau-sum/examples/truncation-scaling.rs +++ b/crates/ppvm-tableau-sum/examples/truncation-scaling.rs @@ -89,7 +89,7 @@ fn apply_layer( tab.depolarize1(q, p_depolarize); } - let pairs: &[(usize, usize)] = if layer_idx % 2 == 0 { + let pairs: &[(usize, usize)] = if layer_idx.is_multiple_of(2) { &[(0, 1), (2, 3)] } else { &[(1, 2)] @@ -188,6 +188,7 @@ fn pure_trajectory_shots( .collect() } +#[allow(clippy::too_many_arguments)] fn run_main_sweep( n_qubits: usize, depth: usize, diff --git a/crates/ppvm-tableau/examples/profile_measure_all.rs b/crates/ppvm-tableau/examples/profile_measure_all.rs index 9aa135c6..80f33925 100644 --- a/crates/ppvm-tableau/examples/profile_measure_all.rs +++ b/crates/ppvm-tableau/examples/profile_measure_all.rs @@ -174,10 +174,10 @@ fn main() { let mut per_qubit_runs: Vec> = vec![Vec::with_capacity(n_runs); n]; for _ in 0..n_runs { let mut t = base.fork(Some(42)); - for q in 0..n { + for (q, runs) in per_qubit_runs.iter_mut().enumerate().take(n) { let start = Instant::now(); let _ = LossyMeasure::measure(&mut t, q); - per_qubit_runs[q].push(start.elapsed()); + runs.push(start.elapsed()); } } let per_qubit_medians: Vec = per_qubit_runs.into_iter().map(median).collect(); diff --git a/crates/ppvm-tableau/examples/profile_measure_all_flame.rs b/crates/ppvm-tableau/examples/profile_measure_all_flame.rs index 9014377b..5fa09a4f 100644 --- a/crates/ppvm-tableau/examples/profile_measure_all_flame.rs +++ b/crates/ppvm-tableau/examples/profile_measure_all_flame.rs @@ -21,6 +21,7 @@ //! - `compute_decomposition` cost //! - `update_tableau_according_to_outcome` cost //! - HashMap traffic in the case-a path +//! //! And a small adjacent subtree for `fork` (expect ~1% based on the //! instrumented run). //! diff --git a/crates/ppvm-tableau/src/data.rs b/crates/ppvm-tableau/src/data.rs index 7b8d008d..4708a6a9 100644 --- a/crates/ppvm-tableau/src/data.rs +++ b/crates/ppvm-tableau/src/data.rs @@ -1565,7 +1565,7 @@ mod tests { tab.cnot(0, 1); tab.ry(2, 0.7); // non-Clifford: branches the coefficient vector assert!( - tab.coefficients.iter().count() > 1, + tab.coefficients.len() > 1, "rotation should branch the coefficient vector" ); @@ -1575,8 +1575,8 @@ mod tests { snapshot_tableau(&tab.tableau), snapshot_tableau(&fresh.tableau) ); - let coeffs: Vec<_> = tab.coefficients.iter().copied().collect(); - let fresh_coeffs: Vec<_> = fresh.coefficients.iter().copied().collect(); + let coeffs = tab.coefficients.to_vec(); + let fresh_coeffs = fresh.coefficients.to_vec(); assert_eq!(coeffs, fresh_coeffs); } From fdfcd55c8fe6b712e33cf872270a15f16f2a47c4 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 22 Jul 2026 16:53:20 +0200 Subject: [PATCH 12/14] docs(pauli-sum): fix intra-doc link in momentum rustdoc Co-authored-by: Cursor --- crates/ppvm-pauli-sum/src/symmetry/momentum.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index 6ecdd3ef..ab164e67 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -73,7 +73,7 @@ impl TranslationGroup { /// /// For the `k_modes = [0, 0, …]` (trivial) sector all characters are `1`, /// so projection averages the distinct orbit members onto each rep. This -/// differs from plain [`canonicalize_pauli_sum`], whose real-coefficient +/// differs from plain [`super::canonicalize_pauli_sum`], whose real-coefficient /// merging sums collisions without orbit-size normalization. pub fn canonicalize_pauli_sum_complex( basis: &mut Vec>, From 0cf1eeff1c8091939772df1ce2defc28165f5110 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 23 Jul 2026 09:38:40 +0200 Subject: [PATCH 13/14] Fix some issues --- crates/ppvm-pauli-sum/src/symmetry/group.rs | 17 +++++++----- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 14 ++++++---- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index d9f5fb5c..cda4ae7c 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -54,6 +54,14 @@ pub(super) fn checked_group_order(orders: &[u32]) -> usize { }) } +pub(super) fn validate_site_count(n: usize, context: &str) { + let max_index = n + .checked_sub(1) + .unwrap_or_else(|| panic!("{context}: site count must be positive")); + u32::try_from(max_index) + .unwrap_or_else(|_| panic!("{context}: site count {n} exceeds the u32-addressable range")); +} + /// A finite abelian symmetry group acting on qubit positions by /// permutations. /// @@ -165,6 +173,7 @@ impl TranslationGroup { let n = lx .checked_mul(ly) .unwrap_or_else(|| panic!("torus_2d: lx * ly overflow")); + validate_site_count(n, "torus_2d"); let lx_u32 = u32::try_from(lx).unwrap_or_else(|_| panic!("torus_2d: lx={lx} does not fit in u32")); let ly_u32 = @@ -196,6 +205,7 @@ impl TranslationGroup { .checked_mul(ly) .and_then(|v| v.checked_mul(lz)) .unwrap_or_else(|| panic!("torus_3d: lx * ly * lz overflow")); + validate_site_count(n, "torus_3d"); let lx_u32 = u32::try_from(lx).unwrap_or_else(|_| panic!("torus_3d: lx={lx} does not fit in u32")); let ly_u32 = @@ -247,6 +257,7 @@ impl TranslationGroup { let n = l .checked_mul(n_legs) .unwrap_or_else(|| panic!("ladder: l * n_legs overflow")); + validate_site_count(n, "ladder"); let l_u32 = u32::try_from(l).unwrap_or_else(|_| panic!("ladder: l={l} does not fit in u32")); let perm: Vec = (0..n) @@ -355,9 +366,6 @@ impl TranslationGroup { A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { - if self.perms.is_empty() { - return *w; - } let mut traversal = self.orbit_with_counters(w); let (mut best, _) = traversal .next() @@ -391,9 +399,6 @@ impl TranslationGroup { A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { - if self.perms.is_empty() { - return (*w, Vec::new()); - } let mut traversal = self.orbit_with_counters(w); let (mut best, mut counter_from_word) = traversal .next() diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index ab164e67..ad29facd 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -44,8 +44,7 @@ impl TranslationGroup { /// /// `k.len()` must equal `self.n_generators()`. The character of the /// identity element (`counter = [0, …]`) is `1`. For the trivial - /// (`k = [0, …]`) sector all characters are `1` — phase-aware merging - /// reduces to plain merging. + /// (`k = [0, …]`) sector all characters are `1`. pub fn character(&self, k_modes: &[i32], counter: &[u32]) -> Complex { let numerator = self.character_numerator(k_modes, counter); let phase = 2.0 * PI * numerator as f64 / self.phase_modulus() as f64; @@ -61,9 +60,9 @@ impl TranslationGroup { /// `(1/|orbit|) · Σ_{p ∈ orbit} χ_k(g_p) · c_p` where `g_p` is the group /// element such that `g_p · rep = p`. /// -/// Orbits whose stabilizer is incompatible with `k_modes` (distinct orbit -/// members carry different character numerators) project to zero and are -/// omitted from the output. +/// Orbits whose stabilizer is incompatible with `k_modes` (the same orbit +/// member is reached with different character numerators) project to zero +/// and are omitted from the output. /// /// If the input was already a momentum-`k_modes` eigenstate (i.e. the /// coefficients satisfy `c_{g·p} = χ_k(g)⁻¹ · c_p` for every orbit), @@ -189,6 +188,11 @@ where } *input.entry(*pauli).or_insert(Complex::new(0.0, 0.0)) += coeff; } + for (&pauli, &coeff) in &input { + if !coeff.re.is_finite() || !coeff.im.is_finite() { + return Err(SectorCheckError::NonFiniteCoefficient { pauli, coeff }); + } + } input.retain(|_, coeff| *coeff != Complex::new(0.0, 0.0)); let reps: FxHashSet<_> = input .keys() diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 17102bcd..64c1deec 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -292,6 +292,17 @@ fn sector_check_rejects_invalid_numeric_inputs() { )); } +#[test] +fn sector_check_rejects_nonfinite_coalesced_coefficient() { + let group = TranslationGroup::chain_1d(1); + let basis = vec![word("X"), word("X")]; + let coeffs = vec![Complex::new(f64::MAX, 0.0); 2]; + assert!(matches!( + check_momentum_sector(&basis, &coeffs, &group, &[0], 1e-12), + Err(SectorCheckError::NonFiniteCoefficient { .. }) + )); +} + #[test] fn sector_error_display_names_the_words() { let group = TranslationGroup::chain_1d(2); @@ -475,6 +486,13 @@ fn rejects_dimension_product_overflow_before_allocation() { assert!(std::panic::catch_unwind(|| TranslationGroup::ladder(usize::MAX, 2)).is_err()); } +#[test] +#[cfg(target_pointer_width = "64")] +#[should_panic(expected = "site count")] +fn rejects_site_count_outside_u32_addressable_range() { + super::group::validate_site_count(u32::MAX as usize + 2, "test"); +} + #[test] fn odometer_yields_expected_counter_order() { let group = TranslationGroup::torus_2d(2, 3); @@ -519,6 +537,14 @@ fn public_word_width_checks_are_not_debug_only() { assert!(std::panic::catch_unwind(|| group.orbit(&short)).is_err()); } +#[test] +fn trivial_group_checks_word_width() { + let group = TranslationGroup::from_generators(4, vec![], vec![]); + let short = word("XI"); + assert!(std::panic::catch_unwind(|| group.canonicalize(&short)).is_err()); + assert!(std::panic::catch_unwind(|| group.canonicalize_with_shift(&short)).is_err()); +} + #[test] fn rejects_group_order_overflow() { let orders = if usize::BITS == 64 { From e16be73352a8fea3c22ba918072fc18858aac195 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 23 Jul 2026 09:39:40 +0200 Subject: [PATCH 14/14] Remove design docs --- ...026-07-22-pr180-symmetry-implementation.md | 1071 ----------------- .../specs/2026-07-22-pr180-symmetry-design.md | 262 ---- 2 files changed, 1333 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md delete mode 100644 docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md diff --git a/docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md b/docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md deleted file mode 100644 index 379c3f0c..00000000 --- a/docs/superpowers/plans/2026-07-22-pr180-symmetry-implementation.md +++ /dev/null @@ -1,1071 +0,0 @@ -# PR 180 Translation-Symmetry Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Split PR 180's translation-symmetry implementation into focused modules and correct group validation, traversal, momentum projection, and sector checking without changing existing public import paths. - -**Architecture:** Keep `ppvm_pauli_sum::symmetry` as the public facade. Put group construction and a shared odometer traversal in `group.rs`, unnormalized real merging in `merge.rs`, and exact-character momentum operations in `momentum.rs`; `mod.rs` re-exports the existing API. Treat each declared generator order as exact while allowing relations between different generators, and use exact modular character numerators to handle stabilizers. - -**Tech Stack:** Rust 2024, `PauliWord`, `PauliSum`, `fxhash`, `num::Complex`, Cargo workspace tests. - ---- - -## File map - -- Delete: `crates/ppvm-pauli-sum/src/symmetry.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/mod.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/group.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/merge.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` -- Create: `crates/ppvm-pauli-sum/tests/symmetry_api.rs` -- Preserve: `crates/ppvm-pauli-sum/src/lib.rs` (`pub mod symmetry;` remains unchanged) - -Every new Rust file starts with the repository's SPDX header. - -## Task 1: Split the module without changing behavior - -**Files:** - -- Delete: `crates/ppvm-pauli-sum/src/symmetry.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/mod.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/group.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/merge.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` -- Create: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` -- Create: `crates/ppvm-pauli-sum/tests/symmetry_api.rs` - -- [ ] **Step 1: Establish the behavioral baseline** - -Run: - -```bash -cargo test -p ppvm-pauli-sum symmetry::tests -``` - -Expected: all 15 existing symmetry unit tests pass. - -- [ ] **Step 2: Add an external public-surface smoke test** - -Create `crates/ppvm-pauli-sum/tests/symmetry_api.rs`: - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -use num::Complex; -use ppvm_pauli_sum::symmetry::{ - TranslationGroup, canonicalize_pauli_sum, canonicalize_pauli_sum_complex, - check_momentum_sector, -}; -use ppvm_pauli_word::word::PauliWord; - -type W = PauliWord<[u8; 1], fxhash::FxBuildHasher, true>; - -#[test] -fn public_symmetry_imports_remain_available() { - let group = TranslationGroup::chain_1d(2); - - let mut real_basis: Vec = vec![W::from("XI"), W::from("IX")]; - let mut real_coeffs = vec![1.0, 1.0]; - canonicalize_pauli_sum(&mut real_basis, &mut real_coeffs, &group); - assert_eq!(real_basis.len(), 1); - - let mut complex_basis: Vec = vec![W::from("ZI"), W::from("IZ")]; - let mut complex_coeffs = vec![Complex::new(1.0, 0.0); 2]; - assert!(check_momentum_sector( - &complex_basis, - &complex_coeffs, - &group, - &[0], - 1e-12, - ) - .is_ok()); - canonicalize_pauli_sum_complex( - &mut complex_basis, - &mut complex_coeffs, - &group, - &[0], - ); - assert_eq!(complex_basis.len(), 1); -} -``` - -- [ ] **Step 3: Move symbols to their semantic owners** - -Use this exact routing, making no body or documentation changes yet: - -| Destination | Symbols/content | -| --- | --- | -| `mod.rs` | Existing module-level docs, module declarations, public re-exports | -| `group.rs` | `TranslationGroup` and its complete current inherent implementation | -| `merge.rs` | `canonicalize_pauli_sum`, `symmetry_merge_pauli_sum` | -| `momentum.rs` | `character` inherent impl, `canonicalize_pauli_sum_complex`, `check_momentum_sector`, `SectorCheckError` and formatting impls | -| `tests.rs` | Existing `tests` module contents without the outer `mod tests { ... }` wrapper | - -Start `tests.rs` with the imports that were previously inherited from the -monolithic parent module: - -```rust -use super::*; -use fxhash::FxHashMap; -use num::Complex; -use ppvm_pauli_word::word::PauliWord; -use std::f64::consts::PI; -``` - -`mod.rs` must end with this facade: - -```rust -mod group; -mod merge; -mod momentum; - -pub use group::TranslationGroup; -pub use merge::{canonicalize_pauli_sum, symmetry_merge_pauli_sum}; -pub use momentum::{ - SectorCheckError, canonicalize_pauli_sum_complex, check_momentum_sector, -}; - -#[cfg(test)] -mod tests; -``` - -Move `character` out of the main `TranslationGroup` impl into this additional -impl in `momentum.rs`: - -```rust -impl TranslationGroup { - pub fn character(&self, k_modes: &[i32], counter: &[u32]) -> Complex { - // Move the existing body verbatim in this task. - } -} -``` - -- [ ] **Step 4: Verify the behavior-neutral split** - -Run: - -```bash -cargo fmt --all -cargo test -p ppvm-pauli-sum symmetry -cargo test -p ppvm-pauli-sum --test symmetry_api -``` - -Expected: the 15 unit tests and the public-surface integration test pass. - -- [ ] **Step 5: Commit the structural split** - -```bash -git add crates/ppvm-pauli-sum/src/symmetry.rs \ - crates/ppvm-pauli-sum/src/symmetry \ - crates/ppvm-pauli-sum/tests/symmetry_api.rs -git commit -m "refactor(pauli-sum): split translation symmetry module" -``` - -## Task 2: Enforce the translation-group construction contract - -**Files:** - -- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` - -- [ ] **Step 1: Add failing constructor-validation tests** - -Append these tests to `tests.rs`: - -```rust -#[test] -#[should_panic(expected = "generator 0 order must be nonzero")] -fn rejects_zero_generator_order() { - TranslationGroup::from_generators(2, vec![vec![1, 0]], vec![0]); -} - -#[test] -#[should_panic(expected = "declared order 4 != exact permutation order 2")] -fn rejects_inflated_generator_order() { - TranslationGroup::from_generators(2, vec![vec![1, 0]], vec![4]); -} - -#[test] -#[should_panic(expected = "generators 0 and 1 do not commute")] -fn rejects_noncommuting_generators() { - let swap_01 = vec![1, 0, 2]; - let swap_12 = vec![0, 2, 1]; - TranslationGroup::from_generators(3, vec![swap_01, swap_12], vec![2, 2]); -} - -#[test] -fn rejects_zero_lattice_dimensions() { - assert!(std::panic::catch_unwind(|| TranslationGroup::chain_1d(0)).is_err()); - assert!(std::panic::catch_unwind(|| TranslationGroup::torus_2d(0, 2)).is_err()); - assert!(std::panic::catch_unwind(|| TranslationGroup::torus_3d(2, 0, 2)).is_err()); - assert!(std::panic::catch_unwind(|| TranslationGroup::ladder(2, 0)).is_err()); -} - -#[test] -fn rejects_dimension_product_overflow_before_allocation() { - assert!( - std::panic::catch_unwind(|| TranslationGroup::torus_2d(usize::MAX, 2)).is_err() - ); - assert!( - std::panic::catch_unwind(|| TranslationGroup::ladder(usize::MAX, 2)).is_err() - ); -} - -#[test] -fn rejects_group_order_overflow() { - let orders = if usize::BITS == 64 { - vec![u32::MAX, u32::MAX, u32::MAX] - } else { - vec![u32::MAX, u32::MAX] - }; - assert!(std::panic::catch_unwind(|| { - super::group::checked_group_order(&orders) - }) - .is_err()); -} -``` - -- [ ] **Step 2: Run the new tests and confirm the old behavior fails** - -Run: - -```bash -cargo test -p ppvm-pauli-sum symmetry::tests::rejects_ -``` - -Expected: at least the zero-order, inflated-order, and noncommuting tests fail. - -- [ ] **Step 3: Add checked arithmetic and exact permutation-order helpers** - -Add these private helpers to `group.rs`: - -```rust -fn gcd(mut a: usize, mut b: usize) -> usize { - while b != 0 { - (a, b) = (b, a % b); - } - a -} - -fn checked_lcm(a: usize, b: usize, context: &str) -> usize { - a.checked_div(gcd(a, b)) - .and_then(|q| q.checked_mul(b)) - .unwrap_or_else(|| panic!("{context} overflow")) -} - -fn permutation_order(perm: &[u32], generator: usize) -> u32 { - let mut seen = vec![false; perm.len()]; - let mut order = 1usize; - for start in 0..perm.len() { - if seen[start] { - continue; - } - let mut length = 0usize; - let mut q = start; - loop { - assert!(!seen[q], "generator {generator} contains a malformed cycle"); - seen[q] = true; - length += 1; - q = perm[q] as usize; - if q == start { - break; - } - } - order = checked_lcm(order, length, "permutation order"); - } - u32::try_from(order).unwrap_or_else(|_| { - panic!("generator {generator} exact permutation order does not fit in u32") - }) -} - -fn permutations_commute(left: &[u32], right: &[u32]) -> bool { - (0..left.len()).all(|q| { - left[right[q] as usize] == right[left[q] as usize] - }) -} - -pub(super) fn checked_group_order(orders: &[u32]) -> usize { - orders.iter().enumerate().fold(1usize, |acc, (g, &value)| { - acc.checked_mul(value as usize) - .unwrap_or_else(|| panic!("group order overflows usize at generator {g}")) - }) -} -``` - -- [ ] **Step 4: Cache validated group metadata** - -Extend the private fields: - -```rust -pub struct TranslationGroup { - n_qubits: usize, - perms: Vec>, - orders: Vec, - order: usize, - phase_modulus: usize, -} -``` - -After validating each permutation's length/range/uniqueness, validate its exact -order and pairwise commutativity. Compute metadata with checked folds: - -```rust -for (g, &declared) in orders.iter().enumerate() { - assert!(declared != 0, "generator {g} order must be nonzero"); - let exact = permutation_order(&perms[g], g); - assert_eq!( - declared, exact, - "generator {g} declared order {declared} != exact permutation order {exact}", - ); -} -for left in 0..perms.len() { - for right in left + 1..perms.len() { - assert!( - permutations_commute(&perms[left], &perms[right]), - "generators {left} and {right} do not commute", - ); - } -} -let order = checked_group_order(&orders); -let phase_modulus = orders.iter().fold(1usize, |acc, &value| { - checked_lcm(acc, value as usize, "character phase modulus") -}); -``` - -Return cached `self.order` from `order()`. - -- [ ] **Step 5: Make lattice constructors fail before arithmetic/allocation** - -At the start of each constructor, assert every dimension is positive. Convert -each generator order with `u32::try_from`, form site counts with `checked_mul`, -and convert every generated target index with `u32::try_from`. Use messages that -name the constructor and offending dimension/product. - -- [ ] **Step 6: Run validation tests and the crate suite** - -```bash -cargo fmt --all -cargo test -p ppvm-pauli-sum symmetry::tests::rejects_ -cargo test -p ppvm-pauli-sum symmetry -``` - -Expected: all tests pass. - -- [ ] **Step 7: Commit group validation** - -```bash -git add crates/ppvm-pauli-sum/src/symmetry/group.rs \ - crates/ppvm-pauli-sum/src/symmetry/tests.rs -git commit -m "fix(pauli-sum): validate translation groups" -``` - -## Task 3: Replace duplicated reconstruction with one odometer traversal - -**Files:** - -- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` - -- [ ] **Step 1: Add failing traversal and release-check tests** - -```rust -#[test] -fn odometer_yields_expected_counter_order() { - let group = TranslationGroup::torus_2d(2, 3); - let counters: Vec> = group - .orbit_with_counters(&word("XIIIII")) - .map(|(_, counter)| counter) - .collect(); - assert_eq!( - counters, - vec![ - vec![0, 0], vec![1, 0], vec![0, 1], - vec![1, 1], vec![0, 2], vec![1, 2], - ], - ); -} - -#[test] -fn traversal_matches_brute_force_composition() { - let group = TranslationGroup::torus_2d(2, 3); - let source = word("XYZIII"); - for (candidate, counter) in group.orbit_with_counters(&source) { - let mut brute = source; - for (g, &count) in counter.iter().enumerate() { - for _ in 0..count { - brute = group.apply_generator(&brute, g); - } - } - assert_eq!(candidate, brute); - } -} - -#[test] -fn public_word_width_checks_are_not_debug_only() { - let group = TranslationGroup::chain_1d(4); - let short = word("XI"); - assert!(std::panic::catch_unwind(|| group.canonicalize(&short)).is_err()); - assert!(std::panic::catch_unwind(|| group.canonicalize_with_shift(&short)).is_err()); - assert!(std::panic::catch_unwind(|| group.orbit(&short)).is_err()); -} -``` - -- [ ] **Step 2: Run the tests and confirm the traversal API is absent** - -```bash -cargo test -p ppvm-pauli-sum symmetry::tests::odometer_yields_expected_counter_order -``` - -Expected: compilation fails because `orbit_with_counters` does not exist. - -- [ ] **Step 3: Implement the shared iterator** - -Add this iterator to `group.rs` and make `apply_generator` `pub(super)`: - -```rust -pub(super) struct GroupOrbit<'a, A, S, const R: bool> -where - A: PauliStorage, -{ - group: &'a TranslationGroup, - current: PauliWord, - counter: Vec, - remaining: usize, -} - -impl Iterator for GroupOrbit<'_, A, S, R> -where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, -{ - type Item = (PauliWord, Vec); - - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - let item = (self.current, self.counter.clone()); - self.remaining -= 1; - if self.remaining == 0 { - return Some(item); - } - for g in 0..self.group.orders.len() { - if self.group.orders[g] == 1 { - continue; - } - self.current = self.group.apply_generator(&self.current, g); - self.counter[g] += 1; - if self.counter[g] < self.group.orders[g] { - break; - } - self.counter[g] = 0; - } - Some(item) - } -} -``` - -Add the constructor, asserting width before returning the lazy iterator: - -```rust -pub(super) fn orbit_with_counters<'a, A, S, const R: bool>( - &'a self, - word: &'a PauliWord, -) -> GroupOrbit<'a, A, S, R> -where - A: PauliStorage, - S: BuildHasher + Clone + Default + HashFinalize, -{ - assert_eq!(word.n_qubits(), self.n_qubits, "word and group must agree on n_qubits"); - GroupOrbit { - group: self, - current: *word, - counter: vec![0; self.orders.len()], - remaining: self.order, - } -} -``` - -- [ ] **Step 4: Rewrite all three consumers** - -Use these bodies so all consumers share traversal and preserve the first -counter on equal representatives: - -```rust -// canonicalize -let mut traversal = self.orbit_with_counters(word); -let (mut best, _) = traversal.next().expect("a finite group contains the identity"); -for (candidate, _) in traversal { - if candidate < best { - best = candidate; - } -} -best - -// canonicalize_with_shift -let mut traversal = self.orbit_with_counters(word); -let (mut best, mut counter_from_word) = - traversal.next().expect("a finite group contains the identity"); -for (candidate, counter) in traversal { - if candidate < best { - best = candidate; - counter_from_word = counter; - } -} -let counter_to_word = counter_from_word - .iter() - .zip(self.orders.iter()) - .map(|(&counter, &order)| (order - counter) % order) - .collect(); -(best, counter_to_word) - -// orbit -self.orbit_with_counters(word) - .map(|(candidate, _)| candidate) -``` - -- [ ] **Step 5: Run traversal, symmetry, and public API tests** - -```bash -cargo fmt --all -cargo test -p ppvm-pauli-sum symmetry::tests::odometer_ -cargo test -p ppvm-pauli-sum symmetry::tests::traversal_matches_ -cargo test -p ppvm-pauli-sum symmetry -cargo test -p ppvm-pauli-sum --test symmetry_api -``` - -Expected: all tests pass. - -- [ ] **Step 6: Commit traversal centralization** - -```bash -git add crates/ppvm-pauli-sum/src/symmetry/group.rs \ - crates/ppvm-pauli-sum/src/symmetry/tests.rs -git commit -m "refactor(pauli-sum): centralize symmetry traversal" -``` - -## Task 4: Add exact character arithmetic - -**Files:** - -- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` - -- [ ] **Step 1: Add exact-character tests** - -```rust -#[test] -fn character_numerator_normalizes_negative_modes() { - let group = TranslationGroup::chain_1d(4); - assert_eq!(group.character_numerator(&[-1], &[1]), 3); - assert_eq!(group.character_numerator(&[3], &[1]), 3); - assert!((group.character(&[-1], &[1]) - Complex::new(0.0, -1.0)).norm() < 1e-12); -} - -#[test] -fn exact_character_detects_cross_generator_kernel() { - let swap = vec![1, 0]; - let group = TranslationGroup::from_generators( - 2, - vec![swap.clone(), swap], - vec![2, 2], - ); - assert_ne!(group.character_numerator(&[1, 0], &[1, 1]), 0); - assert_eq!(group.character_numerator(&[1, 1], &[1, 1]), 0); -} - -#[test] -fn character_checks_slice_lengths_in_release_builds() { - let group = TranslationGroup::chain_1d(4); - assert!(std::panic::catch_unwind(|| group.character(&[], &[0])).is_err()); - assert!(std::panic::catch_unwind(|| group.character(&[0], &[])).is_err()); -} -``` - -- [ ] **Step 2: Run the tests and confirm the exact helper is absent** - -```bash -cargo test -p ppvm-pauli-sum symmetry::tests::character_numerator_ -``` - -Expected: compilation fails because `character_numerator` does not exist. - -- [ ] **Step 3: Implement exact numerator calculation** - -Add a `pub(super)` getter for `phase_modulus` if momentum code needs it, then -implement this method in the `TranslationGroup` impl in `momentum.rs`: - -```rust -pub(super) fn character_numerator(&self, k_modes: &[i32], counter: &[u32]) -> usize { - assert_eq!(k_modes.len(), self.n_generators(), "k_modes length mismatch"); - assert_eq!(counter.len(), self.n_generators(), "counter length mismatch"); - let modulus = self.phase_modulus() as u128; - let mut numerator = 0u128; - for g in 0..self.n_generators() { - let order = self.generator_order(g); - let k = (k_modes[g] as i64).rem_euclid(order as i64) as u128; - let count = (counter[g] % order) as u128; - let reduced = (k * count) % order as u128; - let factor = self.phase_modulus() as u128 / order as u128; - numerator = (numerator + reduced * factor) % modulus; - } - numerator as usize -} -``` - -Rewrite `character` to convert only this exact numerator: - -```rust -let numerator = self.character_numerator(k_modes, counter); -let phase = 2.0 * PI * numerator as f64 / self.phase_modulus() as f64; -Complex::from_polar(1.0, phase) -``` - -- [ ] **Step 4: Run exact-character and full symmetry tests** - -```bash -cargo fmt --all -cargo test -p ppvm-pauli-sum symmetry::tests::character_ -cargo test -p ppvm-pauli-sum symmetry -``` - -Expected: all tests pass. - -- [ ] **Step 5: Commit exact character arithmetic** - -```bash -git add crates/ppvm-pauli-sum/src/symmetry/group.rs \ - crates/ppvm-pauli-sum/src/symmetry/momentum.rs \ - crates/ppvm-pauli-sum/src/symmetry/tests.rs -git commit -m "fix(pauli-sum): compute symmetry characters exactly" -``` - -## Task 5: Correct momentum projection for stabilizers - -**Files:** - -- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` - -- [ ] **Step 1: Add projection regressions** - -```rust -#[test] -fn period_two_k_zero_round_trip_preserves_rep_coefficient() { - let group = TranslationGroup::chain_1d(4); - let mut basis = vec![word("XIXI"), word("IXIX")]; - let mut coeffs = vec![Complex::new(1.0, 0.0); 2]; - canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[0]); - assert_eq!(basis.len(), 1); - assert!((coeffs[0] - Complex::new(1.0, 0.0)).norm() < 1e-12); -} - -#[test] -fn period_two_compatible_k_two_round_trip_preserves_rep_coefficient() { - let group = TranslationGroup::chain_1d(4); - let rep = group.canonicalize(&word("XIXI")); - let mut members: FxHashMap> = FxHashMap::default(); - for (member, counter) in group.orbit_with_counters(&rep) { - members.entry(member).or_insert_with(|| group.character(&[2], &counter).conj()); - } - let (mut basis, mut coeffs): (Vec, Vec>) = members.into_iter().unzip(); - canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[2]); - assert_eq!(basis, vec![rep]); - assert!((coeffs[0] - Complex::new(1.0, 0.0)).norm() < 1e-12); -} - -#[test] -fn incompatible_stabilizer_projects_orbit_to_zero() { - let group = TranslationGroup::chain_1d(4); - let mut basis = vec![word("XXXX")]; - let mut coeffs = vec![Complex::new(1.0, 0.0)]; - canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[1]); - assert!(basis.is_empty()); - assert!(coeffs.is_empty()); -} - -#[test] -fn partial_period_two_orbit_is_averaged_with_missing_member_zero() { - let group = TranslationGroup::chain_1d(4); - let mut basis = vec![word("XIXI")]; - let mut coeffs = vec![Complex::new(1.0, 0.0)]; - canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &[0]); - assert_eq!(basis.len(), 1); - assert!((coeffs[0] - Complex::new(0.5, 0.0)).norm() < 1e-12); -} -``` - -- [ ] **Step 2: Run the regressions and verify current normalization fails** - -```bash -cargo test -p ppvm-pauli-sum symmetry::tests::period_two_ -cargo test -p ppvm-pauli-sum symmetry::tests::incompatible_stabilizer_projects_ -``` - -Expected: the period-two and incompatible-stabilizer tests fail. - -- [ ] **Step 3: Implement one stabilizer-aware projection per represented orbit** - -Replace the fixed `inv_g` loop with this data flow: - -```rust -let mut input: FxHashMap, Complex> = FxHashMap::default(); -for (word, &coeff) in basis.iter().zip(coeffs.iter()) { - *input.entry(*word).or_insert(Complex::new(0.0, 0.0)) += coeff; -} -let reps: FxHashSet<_> = input.keys().map(|word| group.canonicalize(word)).collect(); -let mut projected = FxHashMap::default(); - -for rep in reps { - let mut members: FxHashMap<_, (Vec, usize)> = FxHashMap::default(); - let mut compatible = true; - for (member, counter) in group.orbit_with_counters(&rep) { - let numerator = group.character_numerator(k_modes, &counter); - match members.entry(member) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert((counter, numerator)); - } - std::collections::hash_map::Entry::Occupied(entry) => { - if entry.get().1 != numerator { - compatible = false; - break; - } - } - } - } - if !compatible { - continue; - } - let orbit_size = members.len() as f64; - let mut rep_coeff = Complex::new(0.0, 0.0); - for (member, (counter, _)) in members { - let coeff = input - .get(&member) - .copied() - .unwrap_or(Complex::new(0.0, 0.0)); - rep_coeff += group.character(k_modes, &counter) * coeff / orbit_size; - } - projected.insert(rep, rep_coeff); -} -``` - -Use `fxhash::FxHashSet`. Replace `basis` and `coeffs` from `projected` exactly -as the current function does from `merged`. Update the rustdoc to call `k=0` -an orbit average rather than a plain merge. Rename -`momentum_zero_complex_merge_matches_real_merge` to -`momentum_zero_complex_projection_is_orbit_average` and retain its assertions -that the real result is `10.0` while the complex result is `2.5`. - -- [ ] **Step 4: Run projection and existing momentum tests** - -```bash -cargo fmt --all -cargo test -p ppvm-pauli-sum symmetry::tests::period_two_ -cargo test -p ppvm-pauli-sum symmetry::tests::incompatible_stabilizer_projects_ -cargo test -p ppvm-pauli-sum symmetry::tests::momentum_ -``` - -Expected: all tests pass; the renamed `k=0` test still demonstrates real sum -`10.0` versus complex average `2.5`. - -- [ ] **Step 5: Commit projection correctness** - -```bash -git add crates/ppvm-pauli-sum/src/symmetry/momentum.rs \ - crates/ppvm-pauli-sum/src/symmetry/tests.rs -git commit -m "fix(pauli-sum): normalize momentum projection by orbit" -``` - -## Task 6: Make sector validation complete and diagnostic - -**Files:** - -- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/tests.rs` - -- [ ] **Step 1: Add validator regressions** - -```rust -#[test] -fn sector_check_rejects_missing_orbit_members() { - let group = TranslationGroup::chain_1d(4); - let basis = vec![word("ZIII")]; - let coeffs = vec![Complex::new(1.0, 0.0)]; - assert!(matches!( - check_momentum_sector(&basis, &coeffs, &group, &[0], 1e-12), - Err(SectorCheckError::CoefficientMismatch { .. }) - )); -} - -#[test] -fn sector_check_rejects_incompatible_stabilizer() { - let group = TranslationGroup::chain_1d(4); - let basis = vec![word("XXXX")]; - let coeffs = vec![Complex::new(1.0, 0.0)]; - assert!(matches!( - check_momentum_sector(&basis, &coeffs, &group, &[1], 1e-12), - Err(SectorCheckError::IncompatibleStabilizer { .. }) - )); -} - -#[test] -fn sector_check_rejects_invalid_numeric_inputs() { - let group = TranslationGroup::chain_1d(2); - let basis = vec![word("ZI")]; - assert!(matches!( - check_momentum_sector(&basis, &[Complex::new(1.0, 0.0)], &group, &[0], f64::NAN), - Err(SectorCheckError::InvalidTolerance { .. }) - )); - assert!(matches!( - check_momentum_sector(&basis, &[Complex::new(f64::NAN, 0.0)], &group, &[0], 1e-12), - Err(SectorCheckError::NonFiniteCoefficient { .. }) - )); -} - -#[test] -fn sector_error_display_names_the_words() { - let group = TranslationGroup::chain_1d(2); - let basis = vec![word("ZI")]; - let coeffs = vec![Complex::new(1.0, 0.0)]; - let message = check_momentum_sector(&basis, &coeffs, &group, &[0], 1e-12) - .unwrap_err() - .to_string(); - assert!(message.contains("ZI") || message.contains("IZ")); -} -``` - -- [ ] **Step 2: Run the regressions and confirm current validation accepts bad input** - -```bash -cargo test -p ppvm-pauli-sum symmetry::tests::sector_check_rejects_ -``` - -Expected: the missing-member, incompatible-stabilizer, and NaN tests fail. - -- [ ] **Step 3: Replace the error struct with explicit variants** - -```rust -pub enum SectorCheckError { - InvalidTolerance { tol: f64 }, - NonFiniteCoefficient { - pauli: PauliWord, - coeff: Complex, - }, - CoefficientMismatch { - rep: PauliWord, - offending_pauli: PauliWord, - expected: Complex, - actual: Complex, - shift: Vec, - }, - IncompatibleStabilizer { - rep: PauliWord, - shift: Vec, - }, -} -``` - -Implement `Debug` and `Display` with -`S: BuildHasher + Clone + Default + HashFinalize`; format `rep`, `pauli`, and -`offending_pauli` with `{}` rather than placeholders. - -- [ ] **Step 4: Validate the complete represented orbit** - -At function entry, return `InvalidTolerance` unless `tol.is_finite() && tol >= -0.0`, and return `NonFiniteCoefficient` for any non-finite component. Coalesce -duplicate words and remove exactly-zero totals. Build the set of canonical -representatives. For each representative: - -1. enumerate `orbit_with_counters` into a map from distinct word to its first - `(counter, exact_numerator)`; -2. return `IncompatibleStabilizer` if the same word reappears with a different - numerator; -3. select the first present nonzero member and infer - `rep_coeff = character(counter) * actual`; -4. for every distinct member compute - `expected = character(counter).conj() * rep_coeff`; -5. load absent members as `Complex::new(0.0, 0.0)` and return - `CoefficientMismatch` when - `(actual - expected).norm() > tol * rep_coeff.norm().max(1.0)`. - -Keep the existing assertions for basis/coeff and momentum-mode lengths. -Implement the core loop as follows after those assertions: - -```rust -if !tol.is_finite() || tol < 0.0 { - return Err(SectorCheckError::InvalidTolerance { tol }); -} -let mut input: FxHashMap, Complex> = FxHashMap::default(); -for (pauli, &coeff) in basis.iter().zip(coeffs.iter()) { - if !coeff.re.is_finite() || !coeff.im.is_finite() { - return Err(SectorCheckError::NonFiniteCoefficient { - pauli: *pauli, - coeff, - }); - } - *input.entry(*pauli).or_insert(Complex::new(0.0, 0.0)) += coeff; -} -input.retain(|_, coeff| *coeff != Complex::new(0.0, 0.0)); -let reps: FxHashSet<_> = input.keys().map(|pauli| group.canonicalize(pauli)).collect(); - -for rep in reps { - let mut members: FxHashMap<_, (Vec, usize)> = FxHashMap::default(); - for (member, counter) in group.orbit_with_counters(&rep) { - let numerator = group.character_numerator(k_modes, &counter); - match members.entry(member) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert((counter, numerator)); - } - std::collections::hash_map::Entry::Occupied(entry) => { - if entry.get().1 != numerator { - return Err(SectorCheckError::IncompatibleStabilizer { - rep, - shift: counter, - }); - } - } - } - } - - let (reference_word, (reference_counter, _)) = members - .iter() - .find(|(member, _)| input.contains_key(*member)) - .expect("represented orbit has a nonzero member"); - let rep_coeff = group.character(k_modes, reference_counter) * input[reference_word]; - - for (member, (counter, _)) in members { - let expected = group.character(k_modes, &counter).conj() * rep_coeff; - let actual = input - .get(&member) - .copied() - .unwrap_or(Complex::new(0.0, 0.0)); - if (actual - expected).norm() > tol * rep_coeff.norm().max(1.0) { - return Err(SectorCheckError::CoefficientMismatch { - rep, - offending_pauli: member, - expected, - actual, - shift: counter, - }); - } - } -} -Ok(()) -``` - -- [ ] **Step 5: Run validator, projection, and public API tests** - -```bash -cargo fmt --all -cargo test -p ppvm-pauli-sum symmetry::tests::sector_ -cargo test -p ppvm-pauli-sum symmetry::tests::momentum_ -cargo test -p ppvm-pauli-sum --test symmetry_api -``` - -Expected: all tests pass, including the pre-existing valid `k=1` eigenstate. - -- [ ] **Step 6: Commit complete validation** - -```bash -git add crates/ppvm-pauli-sum/src/symmetry/momentum.rs \ - crates/ppvm-pauli-sum/src/symmetry/tests.rs -git commit -m "fix(pauli-sum): validate complete momentum sectors" -``` - -## Task 7: Finish documentation and verify the branch - -**Files:** - -- Modify: `crates/ppvm-pauli-sum/src/symmetry/mod.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/group.rs` -- Modify: `crates/ppvm-pauli-sum/src/symmetry/momentum.rs` -- Verify: `crates/ppvm-pauli-sum/tests/symmetry_api.rs` - -- [ ] **Step 1: Align rustdoc with the implemented contracts** - -Update module and item docs to state all of the following explicitly: - -- generator orders are exact, but the combined action may have a kernel; -- `order()` is the abstract product order and `orbit()` may repeat words; -- `canonicalize_with_shift` returns the first valid counter and counters are not - unique in the presence of stabilizers; -- complex `k=0` projection averages a distinct orbit while real merging sums; -- incompatible stabilizer sectors project to zero; and -- `check_momentum_sector` treats absent orbit members as zero. - -- [ ] **Step 2: Run formatting, lint, tests, docs, and wasm verification** - -Run each command and stop on the first failure: - -```bash -cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -cargo doc --workspace --no-deps -cargo build --target wasm32-unknown-unknown --workspace \ - --exclude ppvm-python-native --exclude ppvm-cli --exclude ppvm-tui -``` - -Expected: every command exits successfully with no warnings or failed tests. - -- [ ] **Step 3: Inspect the final public diff** - -```bash -git diff origin/split/1-translation-symmetry...HEAD --stat -git diff origin/split/1-translation-symmetry...HEAD -- crates/ppvm-pauli-sum/src/lib.rs -git status --short -``` - -Expected: `lib.rs` is unchanged, the old `symmetry.rs` is replaced by the five -semantic module files, the integration test is present, and the worktree is -clean except for intentional documentation changes. - -- [ ] **Step 4: Commit final rustdoc changes if Step 1 produced a diff** - -```bash -git add crates/ppvm-pauli-sum/src/symmetry -git commit -m "docs(pauli-sum): clarify symmetry projection semantics" -``` - -If Step 1 was already completed in earlier implementation commits and `git -status --short` is empty, do not create an empty commit. - -## Task 8: Prepare the stacked-PR handoff - -**Files:** None on this branch. - -- [ ] **Step 1: Record the descendant rebase order in the handoff** - -Use this exact order after the implementation branch is merged into -`split/1-translation-symmetry`: - -```text -split/2-ctpp-core -split/3-symmetric-evolution -split/4-autotune-ledgers -kossakowski-dissipator (also based on split/3-symmetric-evolution) -``` - -- [ ] **Step 2: Identify PR 182 follow-up edits without applying them here** - -After rebasing PR 182, update its fixed-`1/|G|` comments in -`crates/ppvm-python-native/src/interface.rs`, preserve the Rust re-export paths, -and add a Python period-two regression to -`ppvm-python/test/test_momentum_merge.py`. Run: - -```bash -uv run --project ppvm-python --group dev pytest ppvm-python/test/test_momentum_merge.py -``` - -Expected: the rebased Python momentum tests pass. Do not copy those binding or -Python changes into this PR 180 implementation branch. - -- [ ] **Step 3: Prepare review-thread responses** - -Group duplicate Copilot discussions under the commits that fix generator -validation, zero dimensions, shared traversal, runtime shape checks, `k=0` -documentation, stabilizer normalization, and error diagnostics. Resolve a -thread only after its regression test and implementation are both present. diff --git a/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md b/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md deleted file mode 100644 index 94a32bb2..00000000 --- a/docs/superpowers/specs/2026-07-22-pr180-symmetry-design.md +++ /dev/null @@ -1,262 +0,0 @@ -# Translation-Symmetry Module Design - -## Status - -This design applies to the translation-symmetry functionality introduced by -PR 180. Implementation should occur on a branch based on -`split/1-translation-symmetry`; follow-up PRs remain responsible for their own -Lindbladian, Python-binding, and benchmarking changes. - -## Goals - -- Split `symmetry.rs` along semantic boundaries without changing the public - `ppvm_pauli_sum::symmetry::*` import surface. -- Make `TranslationGroup` reject malformed generator descriptions eagerly. -- Give real-space merging and momentum projection explicit, distinct - semantics. -- Correct momentum projection for words with nontrivial stabilizers. -- Make momentum-sector validation detect missing orbit members and - stabilizer-incompatible sectors. -- Centralize group traversal so canonicalization, shift recovery, and orbit - iteration cannot diverge. - -## Non-goals - -- Moving functionality from PRs 181–183 into PR 180. -- Adding Python bindings or changing Python APIs; those belong to PR 182. -- Splitting individual lattice constructors into separate files. -- Generalizing beyond finite abelian permutation actions. -- Introducing a new fallible public constructor API in this change. - -## Module structure - -Replace the single source file with the following module: - -```text -crates/ppvm-pauli-sum/src/symmetry/ -├── mod.rs # module documentation and public re-exports -├── group.rs # TranslationGroup, construction, traversal, canonicalization -├── merge.rs # unnormalized k=0 Vec and PauliSum merging -├── momentum.rs # characters, normalized projection, sector errors/checking -└── tests.rs # unit and regression tests for the complete module -``` - -`lib.rs` continues to declare `pub mod symmetry`. `mod.rs` re-exports the -existing public names, so consumers do not need to change imports. Internal -traversal helpers are `pub(super)` at most. - -`canonicalize_with_shift` remains in `group.rs`: although momentum code is its -current consumer, it is fundamentally an orbit operation. `character` belongs -in `momentum.rs` and may be implemented as an additional inherent -`TranslationGroup` implementation using the public generator accessors. - -## Group model and validation - -`TranslationGroup` represents an abstract product -`G = C_orders[0] × ... × C_orders[r-1]` together with a commuting permutation -action on qubits. Every declared `orders[g]` is the exact order of its generator -permutation, preserving the existing `generator_order()` contract. Relations -between different generators may still give the combined action a kernel: -different elements of `G` may produce the same qubit permutation or fix a -particular Pauli word. Consequently, `order()` returns the order of the abstract -parameter group, while a word's distinct orbit can be smaller. - -This definition is preferred over requiring a faithful action. Proving -faithfulness by enumerating every composed permutation costs -`O(|G| × n_qubits)` during construction and would reject useful descriptions -that the stabilizer-aware projection can handle correctly. Two alternatives -were considered and rejected: - -1. Require all composed generator products to be unique. This makes `order()` - equal the image-group order but introduces potentially prohibitive - construction work. -2. Support only the built-in lattice constructors. This avoids arbitrary-group - validation but removes an intentional public extension point. - -`from_generators` retains its current infallible signature and validates: - -- equal numbers of permutations and orders; -- nonzero orders; -- permutation length, range, and uniqueness; -- the exact permutation order equals the declared order for every generator; -- pairwise commutativity of the generator permutations; and -- checked multiplication of generator orders into a cached `usize` group - order. - -Exact permutation orders are computed in `O(n_qubits)` per generator from the -permutation's cycle lengths. The cycle-length least common multiple is checked -and compared with the declared `u32` order; validation must not apply a -generator `order` times. This rejects both an order that is too small and an -inflated multiple of the actual order. - -The built-in constructors additionally reject zero dimensions, use checked -dimension products, and reject values that cannot be represented by their -`u32` permutation/order storage. Assertions must have precise messages. A -future fallible constructor can be added separately if callers need to recover -from invalid input. - -All public operations perform release-mode shape validation. In particular, -word width must match `n_qubits`, and `character` requires one momentum mode -and one counter per generator. - -The group also caches `phase_modulus = lcm(orders)`, using `1` for the trivial -group with no generators. It fits in `usize` because it divides the already -checked product of the orders. Momentum code represents a character phase -exactly by the numerator - -```text -Σ_g ((k[g] rem_euclid orders[g]) * counter[g] mod orders[g]) - * (phase_modulus / orders[g]) mod phase_modulus. -``` - -Terms are evaluated with `u128` intermediates. A character is trivial exactly -when this numerator is zero; conversion to `Complex` happens only when a -numeric phase is required for a coefficient. - -## Group traversal - -`group.rs` owns one internal mixed-radix traversal primitive. It maintains the -current counter and Pauli word as an odometer. Incrementing a digit applies its -generator once; rollover applies the same generator once more, returning that -digit to the identity before carrying. Order-one generators are skipped. - -The traversal yields `(word, counter)` for every element of the abstract group, -including duplicate words caused by stabilizers. It uses `usize` for radix -arithmetic and casts only a checked per-digit remainder to `u32`. - -`canonicalize`, `canonicalize_with_shift`, and `orbit` all consume this -primitive. `canonicalize_with_shift` returns the inverse counter taking the -chosen representative back to the input word. When several counters describe -the same mapping, it returns the first in traversal order; momentum code must -not assume that this counter is unique. - -With order-one generators removed from carry processing, traversal performs an -amortized constant number of generator applications per group element, giving -the documented `O(|G| × n_qubits)` time and `O(r)` counter state. - -## Merge semantics - -The APIs intentionally expose two different coefficient conventions: - -- `canonicalize_pauli_sum` and `symmetry_merge_pauli_sum` perform an - **unnormalized merge**. Coefficients of input entries with the same orbit - representative are summed. -- `canonicalize_pauli_sum_complex` performs a **normalized momentum - projection**. Its output coefficient is the projected state's coefficient - on the chosen representative. - -The complex projection first coalesces duplicate input Pauli entries. For each -represented orbit it enumerates the abstract group action and determines: - -1. the distinct orbit members and their character phases; and -2. whether the requested character is trivial on the representative's - stabilizer. - -Stabilizer compatibility is decided with the exact character numerator, never -by comparing `Complex` values. If the character is nontrivial on the -stabilizer, the group projector vanishes on that orbit and the orbit is omitted -from the result. Otherwise every distinct orbit member has a well-defined phase, -and the projected representative coefficient is the average of the -phase-adjusted coefficients over the **distinct orbit**, with absent input -members contributing zero and normalization by the orbit size rather than by -`|G|`. -This is equivalent to the full `1/|G|` group projector because every distinct -orbit member occurs once per stabilizer element. - -For `k = 0`, the complex routine is an orbit average, not the unnormalized real -merge. Documentation and test names must state that distinction explicitly. - -## Momentum-sector validation - -`check_momentum_sector` validates the represented operator, not merely pairs of -entries that happen to be present. It therefore: - -1. rejects a non-finite or negative tolerance and any coefficient with a - non-finite real or imaginary component; -2. coalesces duplicate Pauli entries; -3. chooses each unvisited orbit representative and infers its coefficient from - the first present nonzero member; -4. enumerates all group elements acting on that representative; -5. verifies with exact character numerators that repeated occurrences of the - same word have compatible phases, thereby checking stabilizer compatibility; - and -6. compares every distinct orbit member's expected coefficient with its actual - coefficient, treating a missing entry as zero. - -Relative comparison continues to use `tol * max(|reference|, 1)`. Exact-zero -orbits produced by coalescing are ignored. - -`SectorCheckError` becomes a public enum with four variants: - -- `InvalidTolerance { tol }`; -- `NonFiniteCoefficient { pauli, coeff }`; -- `CoefficientMismatch { rep, offending_pauli, expected, actual, shift }`; and -- `IncompatibleStabilizer { rep, shift }`. - -Its `Debug` and `Display` implementations print the relevant Pauli words; the -necessary hasher bounds are placed on those formatting implementations. The -type name and function signature remain stable, and current stacked consumers -only format the error rather than constructing or matching it. - -## Tests - -The split must preserve all existing tests and add focused regressions for: - -- zero generator orders and zero lattice dimensions; -- incorrect, inflated, and noncommuting generator orders; -- checked group-order and lattice-dimension multiplication; -- public word-width, momentum-length, and counter-length checks; -- exact character numerators for negative modes and compatible/incompatible - stabilizers; -- odometer counter rollover and agreement of the shared traversal with - brute-force composition on small 1D, 2D, 3D, and ladder groups; -- a period-two word on a four-site chain round-tripping in `k=0`; -- the same orbit round-tripping in a compatible nonzero momentum sector; -- an incompatible momentum character vanishing on projection; -- rejection of a basis with missing orbit members; -- rejection of a nonzero coefficient with an incompatible stabilizer; -- rejection of negative/NaN tolerances and non-finite coefficients; -- acceptance of existing complete momentum eigenstates; and -- the documented normalization difference between real merge and complex - projection. - -Run `cargo fmt --check`, `cargo test -p ppvm-pauli-sum`, and -`cargo test --workspace` before updating PR 180. - -## Stacked-PR integration - -PR 180 owns the Rust core module split and all correctness changes above. Once -it is updated, rebase descendants in this order: - -```text -split/1-translation-symmetry -└── split/2-ctpp-core # PR 181 - └── split/3-symmetric-evolution # PR 182 - ├── split/4-autotune-ledgers # PR 183 - └── kossakowski-dissipator # PR 179 -``` - -PR 181 uses only the stable real-space API and should need no source changes. -PR 182 is the first momentum consumer and must update its Rust/Python comments, -wrappers, and tests that currently describe a fixed `1/|G|` prefactor. Add a -Python period-two regression there after rebasing. PRs 183 and 179 should then -be rebased without importing their features into PR 180. - -Review-thread replies should consolidate duplicates: generator validation, -`k=0` documentation, mixed-radix decoding, word-width checks, and zero-sized -constructors each have multiple Copilot threads. Resolve them only after the -corresponding implementation and regression tests are present. - -## Acceptance criteria - -- Existing public Rust import paths compile unchanged. -- Each new source file has one clear responsibility as described above. -- All malformed group definitions covered by the constructor contract fail - immediately with clear messages. -- Complex projection round-trips valid eigenstates with both full and reduced - orbits. -- Sector validation rejects missing members and incompatible stabilizers. -- Real merge remains unnormalized and behavior-compatible. -- The full Rust workspace passes after the split. -- Descendant PRs can be rebased in order without pulling their functionality - into PR 180.