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-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..cda4ae7c --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -0,0 +1,477 @@ +// 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; + +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}")) + }) +} + +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. +/// +/// 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 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. +#[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, + order: usize, + phase_modulus: usize, +} + +impl TranslationGroup { + /// Construct from explicit generator permutations and orders. + /// + /// Each `perm` must be a permutation of `0..n_qubits`. Each `order` + /// 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() { + 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; + } + } + 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 { + 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 { + 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")); + 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 = + 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); + 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); + 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_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 { + 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")); + 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 = + 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); + 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) + .map(|q| { + let i = q % lx; + let j = (q / lx) % ly; + let k = q / (lx * ly); + 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) + .map(|q| { + let i = q % lx; + let j = (q / lx) % ly; + let k = q / (lx * ly); + 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_u32, ly_u32, lz_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 { + 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")); + 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) + .map(|q| { + let leg = q / l; + let j = q % l; + 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_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() + } + + /// 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 + } + + /// 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] + } + + /// 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. + /// + /// 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 + } + + 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. + /// + /// Total cost: `O(|G| × n_qubits)` per call. + pub fn canonicalize(&self, w: &PauliWord) -> PauliWord + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + 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; + } + } + 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`. 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`. + pub fn canonicalize_with_shift( + &self, + w: &PauliWord, + ) -> (PauliWord, Vec) + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + 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; + } + } + 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 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, + ) -> impl Iterator> + 'a + where + A: PauliStorage + 'a, + S: BuildHasher + Clone + Default + HashFinalize + 'a, + { + 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; + } + 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/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..30d6d4c0 --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/mod.rs @@ -0,0 +1,74 @@ +// 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 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 +//! +//! [`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). +//! 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. + +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..ad29facd --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use fxhash::{FxHashMap, FxHashSet}; +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 { + /// 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]`). + /// + /// `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`. + 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; + Complex::from_polar(1.0, phase) + } +} + +/// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form +/// **projected onto momentum sector `k_modes`**. +/// +/// 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`. +/// +/// 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), +/// the output is the orbit-rep coefficients of that state unchanged. +/// Otherwise the projection discards the components in other sectors — +/// use [`check_momentum_sector`] beforehand to validate. +/// +/// 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 [`super::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>, + 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 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(projected.len()); + coeffs.reserve(projected.len()); + for (w, c) in projected { + 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`. 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. +/// +/// 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()); + + 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; + } + 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() + .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(()) +} + +/// Detail report for a failed [`check_momentum_sector`]. +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 +where + S: BuildHasher + Clone + Default + HashFinalize, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + 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 +where + S: BuildHasher + Clone + Default + HashFinalize, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + 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 new file mode 100644 index 00000000..64c1deec --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -0,0 +1,556 @@ +// 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 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 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]; + + 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 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); + assert!((coeffs_c[0].re - 2.5).abs() < 1e-12); + 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_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); + 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 + // 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}" + ); +} + +#[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] +#[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); + 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 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 { + 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()); +} 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); +} 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); }