From 66ed747fe9d435053accb8883ab82e10200a60b5 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Thu, 16 Jul 2026 13:10:55 +0100 Subject: [PATCH 1/6] feat(pauli-sum): translation-symmetry groups + orbit-representative merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TranslationGroup (finite abelian permutation groups: 1D chains, 2D/3D tori, multi-leg ladders, arbitrary generators) with lex-min orbit canonicalization, shift counters, and momentum-sector characters. Pauli-sum merging in both sectors: canonicalize_pauli_sum / symmetry_merge_pauli_sum (k=0, real coefficients) and canonicalize_pauli_sum_complex (k≠0, character-weighted projection, 1/|G| normalization), plus check_momentum_sector to validate an input before projecting. Following Teng, Chang, Rudolph & Holmes (arXiv:2512.12094). Split 1/4 of the CTPP work (see PR body for the stack); full development history on branch continuous-time-pauli-propagation. Co-Authored-By: Claude Fable 5 --- crates/ppvm-pauli-sum/src/lib.rs | 1 + crates/ppvm-pauli-sum/src/symmetry.rs | 949 ++++++++++++++++++++++++++ 2 files changed, 950 insertions(+) create mode 100644 crates/ppvm-pauli-sum/src/symmetry.rs diff --git a/crates/ppvm-pauli-sum/src/lib.rs b/crates/ppvm-pauli-sum/src/lib.rs index 7f509c5d0..097ba4adb 100644 --- a/crates/ppvm-pauli-sum/src/lib.rs +++ b/crates/ppvm-pauli-sum/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod strategy; pub mod sum; +pub mod symmetry; /// Drop-in replacement for the old `ppvm_runtime::prelude`. pub mod prelude { diff --git a/crates/ppvm-pauli-sum/src/symmetry.rs b/crates/ppvm-pauli-sum/src/symmetry.rs new file mode 100644 index 000000000..56ceabdea --- /dev/null +++ b/crates/ppvm-pauli-sum/src/symmetry.rs @@ -0,0 +1,949 @@ +// 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 ppvm_pauli_word::word::PauliWord; +use ppvm_traits::Config; +use ppvm_traits::{HashFinalize, PauliStorage, PauliWordTrait}; +use fxhash::FxHashMap; +use num::Complex; +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}" + ); + } +} From f3f4eadc465789a0fa540e9105a6e28d076e4bd0 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Thu, 16 Jul 2026 13:31:04 +0100 Subject: [PATCH 2/6] style: cargo fmt Co-Authored-By: Claude Fable 5 --- crates/ppvm-pauli-sum/src/symmetry.rs | 53 ++++++++++++++++----------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry.rs b/crates/ppvm-pauli-sum/src/symmetry.rs index 56ceabdea..685370442 100644 --- a/crates/ppvm-pauli-sum/src/symmetry.rs +++ b/crates/ppvm-pauli-sum/src/symmetry.rs @@ -55,11 +55,11 @@ //! 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 fxhash::FxHashMap; -use num::Complex; use std::f64::consts::PI; use std::hash::BuildHasher; @@ -96,11 +96,7 @@ impl TranslationGroup { /// /// 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 { + 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!( @@ -267,10 +263,7 @@ impl TranslationGroup { /// keeping the smallest word seen. /// /// Total cost: `O(|G| × n_qubits)` per call. - pub fn canonicalize( - &self, - w: &PauliWord, - ) -> PauliWord + pub fn canonicalize(&self, w: &PauliWord) -> PauliWord where A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, @@ -442,7 +435,11 @@ pub fn canonicalize_pauli_sum( A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { - assert_eq!(basis.len(), coeffs.len(), "basis and coeffs length mismatch"); + 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()) { @@ -484,7 +481,11 @@ pub fn canonicalize_pauli_sum_complex( A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { - assert_eq!(basis.len(), coeffs.len(), "basis and coeffs length mismatch"); + assert_eq!( + basis.len(), + coeffs.len(), + "basis and coeffs length mismatch" + ); assert_eq!( k_modes.len(), group.n_generators(), @@ -647,9 +648,15 @@ mod tests { 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(); + 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"); + assert_eq!( + *c, canon[0], + "all cyclic shifts must canonicalize to same rep" + ); } } @@ -707,8 +714,10 @@ mod tests { 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(); + let expected: std::collections::HashSet = ["XIIIII", "IXIIII", "IIXIII"] + .iter() + .map(|s| word(s)) + .collect(); assert_eq!(unique, expected); } @@ -822,7 +831,10 @@ mod tests { .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:?}"); + assert!( + res.is_ok(), + "valid k-eigenstate failed sector check: {res:?}" + ); } #[test] @@ -835,10 +847,7 @@ mod tests { .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" - ); + assert!(res.is_err(), "k=1 eigenstate wrongly passed as k=0 sector"); } #[test] From f1481b2dfec3b38ae178086768d0e74fc7113130 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Tue, 1 Sep 2026 16:33:34 +0200 Subject: [PATCH 3/6] Codex/pr180 symmetry design (#193) This implements the split suggested in https://github.com/QuEraComputing/ppvm/pull/180#issuecomment-5021043519 and fixes some issues that surfaced during the split or from copilot findings. Should be merged before #181 cc @AlexSchuckert --------- Co-authored-by: Cursor --- .../benches/truncation-weight.rs | 2 +- .../ppvm-pauli-sum/examples/hash_quality.rs | 10 +- crates/ppvm-pauli-sum/src/symmetry.rs | 958 ------------------ crates/ppvm-pauli-sum/src/symmetry/group.rs | 477 +++++++++ crates/ppvm-pauli-sum/src/symmetry/merge.rs | 75 ++ crates/ppvm-pauli-sum/src/symmetry/mod.rs | 74 ++ .../ppvm-pauli-sum/src/symmetry/momentum.rs | 333 ++++++ crates/ppvm-pauli-sum/src/symmetry/tests.rs | 556 ++++++++++ crates/ppvm-pauli-sum/tests/symmetry_api.rs | 26 + .../examples/msd-noisy-compare.rs | 1 + .../examples/truncation-scaling.rs | 3 +- .../examples/profile_measure_all.rs | 4 +- .../examples/profile_measure_all_flame.rs | 1 + crates/ppvm-tableau/src/data.rs | 6 +- 14 files changed, 1553 insertions(+), 973 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/benches/truncation-weight.rs b/crates/ppvm-pauli-sum/benches/truncation-weight.rs index 0177a3a0e..a363fccb5 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 bd434c930..655710ca9 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 685370442..000000000 --- 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 000000000..cda4ae7c7 --- /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 000000000..b6e384713 --- /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 000000000..30d6d4c01 --- /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 000000000..ad29facdc --- /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 000000000..64c1deec0 --- /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 000000000..ddfdc86f0 --- /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 1a8ed284a..8ca493ed4 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 8e1f4df9e..5bd9c3dce 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 9aa135c60..80f339257 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 9014377bd..5fa09a4f8 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 7b8d008db..4708a6a91 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 fa284301858879474cab37e91e4d64fb6eb35b10 Mon Sep 17 00:00:00 2001 From: Alexander Schuckert Date: Mon, 28 Sep 2026 16:45:17 +0300 Subject: [PATCH 4/6] Continuous-time Pauli propagation: real-space adaptive Lindbladian evolution (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **PR 2 of 4** splitting #178 (stacked on #180). Full history: branch [`continuous-time-pauli-propagation`](https://github.com/QuEraComputing/ppvm/tree/continuous-time-pauli-propagation). The core CTPP method: direct Heisenberg-picture evolution `O ← exp(dt·L*) O` of Pauli observables under a Lindbladian, on an adaptively truncated Pauli-string basis. Exact in `dt` within the working basis — no Trotter splitting; the only approximation is truncation. - **`ppvm-lindblad` crate**: `LindbladSpec` precompiles Hermitian-Pauli jumps (fast diagonal path) and general complex Pauli-sum jumps (σ±/amplitude damping via a precomputed L†L expansion). `pc_step` = two-hop leakage enrichment + predictor/corrector matrix-free exponential (cached-CSC columns fed to `quspin-expm`, MIT-licensed pin; Al-Mohy–Higham Taylor partition selection with a truncation-matched relaxed table). Truncation policy in one `PcStepConfig`: `max_basis` rank cap (primary dial), `admit_basis` working-set bound (displacement truncation), `drop_tol` prune, `tau_add` admission filter. - **Python**: `ppvm.Lindbladian` with a numpy-array hot path plus string-keyed convenience API; `mimalloc` as the extension's global allocator (~50% peak-RSS reduction on the allocation-heavy adaptive paths); two jupytext demos. - **Tests**: action/generator/leakage against dense-Liouvillian and Pauli-table references; adaptive-evolution convergence to a closed bilinear solution; `pc_step` pinned against `numpy.linalg.eig`; O(dt³) per-step scaling of the predictor-corrector. Benchmarks backing the design choices (190× vs Trotter at L=21 matched accuracy; external cross-validation vs Begušić & Chan, PRX Quantum 6, 020302 at L=41): ledgers arrive in PR 4 of the stack. The translation-symmetric (momentum-sector) variant follows in PR 3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 Co-authored-by: David Plankensteiner Co-authored-by: Cursor Co-authored-by: David Plankensteiner --- Cargo.lock | 1393 ++++++++++++++++- Cargo.toml | 1 + crates/ppvm-lindblad/Cargo.toml | 34 + crates/ppvm-lindblad/benches/drug_dipolar.rs | 265 ++++ crates/ppvm-lindblad/benches/kossakowski.rs | 173 ++ crates/ppvm-lindblad/examples/drug_profile.rs | 221 +++ crates/ppvm-lindblad/src/algebra.rs | 125 ++ crates/ppvm-lindblad/src/basis.rs | 331 ++++ crates/ppvm-lindblad/src/config.rs | 59 + crates/ppvm-lindblad/src/error.rs | 93 ++ crates/ppvm-lindblad/src/expm.rs | 145 ++ crates/ppvm-lindblad/src/kossakowski.rs | 333 ++++ crates/ppvm-lindblad/src/lib.rs | 67 + crates/ppvm-lindblad/src/mf_expm.rs | 496 ++++++ crates/ppvm-lindblad/src/scalar.rs | 43 + crates/ppvm-lindblad/src/sector.rs | 129 ++ crates/ppvm-lindblad/src/spec.rs | 297 ++++ crates/ppvm-lindblad/src/step.rs | 299 ++++ crates/ppvm-lindblad/src/tests.rs | 416 +++++ crates/ppvm-lindblad/src/truncate.rs | 156 ++ crates/ppvm-lindblad/src/word.rs | 166 ++ crates/ppvm-lindblad/tests/word_width.rs | 274 ++++ crates/ppvm-pauli-sum/Cargo.toml | 1 + crates/ppvm-pauli-sum/src/symmetry/group.rs | 790 +++++++++- crates/ppvm-pauli-sum/src/symmetry/mod.rs | 27 +- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 276 +++- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 546 +++++++ crates/ppvm-python-native/Cargo.toml | 7 + crates/ppvm-python-native/src/interface.rs | 104 ++ crates/ppvm-python-native/src/lib.rs | 24 + crates/ppvm-python-native/src/lindblad.rs | 532 +++++++ crates/ppvm-python-native/src/pauli_arr.rs | 138 ++ crates/ppvm-python-native/src/symmetry.rs | 291 ++++ ppvm-python/demo/lindblad_adaptive.py | 313 ++++ ppvm-python/demo/lindblad_pc_scaling.py | 185 +++ ppvm-python/pyproject.toml | 6 + ppvm-python/src/ppvm/__init__.py | 7 + ppvm-python/src/ppvm/_core.pyi | 148 +- ppvm-python/src/ppvm/lindblad.py | 508 ++++++ ppvm-python/src/ppvm/paulisum.py | 84 + ppvm-python/src/ppvm/symmetry.py | 158 ++ ppvm-python/test/lindblad/__init__.py | 0 .../test/lindblad/_dissipative_refs.py | 195 +++ ppvm-python/test/lindblad/_helpers.py | 356 +++++ .../test/lindblad/test_action_generator.py | 108 ++ ppvm-python/test/lindblad/test_adaptive_pc.py | 108 ++ ppvm-python/test/lindblad/test_kossakowski.py | 170 ++ .../test/lindblad/test_non_hermitian_jumps.py | 106 ++ .../test/lindblad/test_orbit_dissipative.py | 232 +++ .../test/lindblad/test_pc_step_orbit_rep.py | 320 ++++ .../test/lindblad/test_pc_step_rust.py | 123 ++ ppvm-python/test/lindblad/test_word_width.py | 86 + ppvm-python/test/test_momentum_merge.py | 245 +++ ppvm-python/test/test_symmetry_arrays.py | 242 +++ ppvm-python/test/test_symmetry_merge.py | 181 +++ ppvm-python/uv.lock | 7 + 56 files changed, 11997 insertions(+), 143 deletions(-) create mode 100644 crates/ppvm-lindblad/Cargo.toml create mode 100644 crates/ppvm-lindblad/benches/drug_dipolar.rs create mode 100644 crates/ppvm-lindblad/benches/kossakowski.rs create mode 100644 crates/ppvm-lindblad/examples/drug_profile.rs create mode 100644 crates/ppvm-lindblad/src/algebra.rs create mode 100644 crates/ppvm-lindblad/src/basis.rs create mode 100644 crates/ppvm-lindblad/src/config.rs create mode 100644 crates/ppvm-lindblad/src/error.rs create mode 100644 crates/ppvm-lindblad/src/expm.rs create mode 100644 crates/ppvm-lindblad/src/kossakowski.rs create mode 100644 crates/ppvm-lindblad/src/lib.rs create mode 100644 crates/ppvm-lindblad/src/mf_expm.rs create mode 100644 crates/ppvm-lindblad/src/scalar.rs create mode 100644 crates/ppvm-lindblad/src/sector.rs create mode 100644 crates/ppvm-lindblad/src/spec.rs create mode 100644 crates/ppvm-lindblad/src/step.rs create mode 100644 crates/ppvm-lindblad/src/tests.rs create mode 100644 crates/ppvm-lindblad/src/truncate.rs create mode 100644 crates/ppvm-lindblad/src/word.rs create mode 100644 crates/ppvm-lindblad/tests/word_width.rs create mode 100644 crates/ppvm-python-native/src/lindblad.rs create mode 100644 crates/ppvm-python-native/src/pauli_arr.rs create mode 100644 crates/ppvm-python-native/src/symmetry.rs create mode 100644 ppvm-python/demo/lindblad_adaptive.py create mode 100644 ppvm-python/demo/lindblad_pc_scaling.py create mode 100644 ppvm-python/src/ppvm/lindblad.py create mode 100644 ppvm-python/src/ppvm/symmetry.py create mode 100644 ppvm-python/test/lindblad/__init__.py create mode 100644 ppvm-python/test/lindblad/_dissipative_refs.py create mode 100644 ppvm-python/test/lindblad/_helpers.py create mode 100644 ppvm-python/test/lindblad/test_action_generator.py create mode 100644 ppvm-python/test/lindblad/test_adaptive_pc.py create mode 100644 ppvm-python/test/lindblad/test_kossakowski.py create mode 100644 ppvm-python/test/lindblad/test_non_hermitian_jumps.py create mode 100644 ppvm-python/test/lindblad/test_orbit_dissipative.py create mode 100644 ppvm-python/test/lindblad/test_pc_step_orbit_rep.py create mode 100644 ppvm-python/test/lindblad/test_pc_step_rust.py create mode 100644 ppvm-python/test/lindblad/test_word_width.py create mode 100644 ppvm-python/test/test_momentum_merge.py create mode 100644 ppvm-python/test/test_symmetry_arrays.py create mode 100644 ppvm-python/test/test_symmetry_merge.py diff --git a/Cargo.lock b/Cargo.lock index 84ea97efc..67e079cc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,25 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "arrayvec", + "bytes", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -129,12 +148,298 @@ dependencies = [ "yansi", ] +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bincode" version = "2.0.1" @@ -226,7 +531,16 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", ] [[package]] @@ -235,6 +549,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + [[package]] name = "bytemuck" version = "1.25.0" @@ -247,6 +567,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cassowary" version = "0.3.0" @@ -295,6 +621,18 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "chumsky" version = "0.10.1" @@ -381,7 +719,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -452,6 +790,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -461,6 +820,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -594,6 +959,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.21.3" @@ -625,7 +1000,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -638,7 +1013,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.118", ] [[package]] @@ -649,7 +1024,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -660,7 +1035,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -697,7 +1072,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -709,6 +1084,62 @@ dependencies = [ "thiserror", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "either" version = "1.15.0" @@ -721,6 +1152,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -774,7 +1225,29 @@ dependencies = [ name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] [[package]] name = "find-msvc-tools" @@ -782,6 +1255,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.8", + "rustc-hex", + "static_assertions", +] + [[package]] name = "fnv" version = "1.0.7" @@ -809,6 +1294,27 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -837,6 +1343,114 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glam" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" + +[[package]] +name = "glam" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" + +[[package]] +name = "glam" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" + +[[package]] +name = "glam" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" + +[[package]] +name = "glam" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" + +[[package]] +name = "glam" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" + +[[package]] +name = "glam" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" + +[[package]] +name = "glam" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" + +[[package]] +name = "glam" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" + +[[package]] +name = "glam" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" + +[[package]] +name = "glam" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + [[package]] name = "gxhash" version = "3.5.0" @@ -856,6 +1470,12 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -885,6 +1505,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -897,12 +1547,43 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -946,7 +1627,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -964,6 +1645,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -996,10 +1686,12 @@ checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" dependencies = [ "defmt", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link", ] [[package]] @@ -1010,7 +1702,22 @@ checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", ] [[package]] @@ -1023,6 +1730,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1035,6 +1757,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libmimalloc-sys" version = "0.1.49" @@ -1081,6 +1809,16 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1108,6 +1846,66 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nalgebra" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +dependencies = [ + "approx", + "glam 0.14.0", + "glam 0.15.2", + "glam 0.16.0", + "glam 0.17.3", + "glam 0.18.0", + "glam 0.19.0", + "glam 0.20.5", + "glam 0.21.3", + "glam 0.22.0", + "glam 0.23.0", + "glam 0.24.2", + "glam 0.25.0", + "glam 0.27.0", + "glam 0.28.0", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "num" version = "0.4.3" @@ -1138,9 +1936,16 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1179,6 +1984,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "numpy" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4" +dependencies = [ + "libc", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "pyo3-build-config", + "rustc-hash", ] [[package]] @@ -1218,6 +2040,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -1247,6 +2097,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + [[package]] name = "plotters" version = "0.3.7" @@ -1290,6 +2150,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1319,17 +2185,36 @@ dependencies = [ "ratatui", ] +[[package]] +name = "ppvm-lindblad" +version = "0.1.0" +dependencies = [ + "approx", + "criterion 0.7.0", + "fxhash", + "nalgebra", + "ndarray", + "num", + "ppvm-pauli-sum", + "ppvm-pauli-word", + "ppvm-traits", + "quspin-expm", + "quspin-types", + "rayon", +] + [[package]] name = "ppvm-pauli-sum" version = "0.1.0" dependencies = [ "approx", "bon", + "bytemuck", "criterion 0.7.0", "dashmap", "fxhash", "gxhash", - "indexmap", + "indexmap 2.11.4", "insta", "itertools 0.14.0", "num", @@ -1359,7 +2244,11 @@ name = "ppvm-python-native" version = "0.1.0" dependencies = [ "bnum", + "mimalloc", + "num", + "numpy", "paste", + "ppvm-lindblad", "ppvm-pauli-sum", "ppvm-stim", "ppvm-tableau", @@ -1455,7 +2344,7 @@ dependencies = [ "dashmap", "fxhash", "gxhash", - "indexmap", + "indexmap 2.11.4", "insta", "num", "rayon", @@ -1495,10 +2384,21 @@ dependencies = [ name = "prettyplease" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" dependencies = [ - "proc-macro2", - "syn", + "fixed-hash", + "impl-codec", + "uint", ] [[package]] @@ -1530,7 +2430,7 @@ dependencies = [ "bitflags 2.11.1", "num-traits", "rand 0.9.4", - "rand_chacha", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax 0.8.11", "rusty-fork", @@ -1591,7 +2491,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1603,7 +2503,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1621,6 +2521,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quspin-expm" +version = "0.1.0" +source = "git+https://github.com/QuSpin/QuSpin-rust?rev=a0ad6c9fe2e8063208f9ba1c6677150c993bb554#a0ad6c9fe2e8063208f9ba1c6677150c993bb554" +dependencies = [ + "ndarray", + "num-complex", + "quspin-types", + "rayon", +] + +[[package]] +name = "quspin-types" +version = "0.1.0" +source = "git+https://github.com/QuSpin/QuSpin-rust?rev=a0ad6c9fe2e8063208f9ba1c6677150c993bb554#a0ad6c9fe2e8063208f9ba1c6677150c993bb554" +dependencies = [ + "ndarray", + "num-complex", + "ruint", + "thiserror", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -1633,13 +2555,24 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1654,6 +2587,16 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1664,6 +2607,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -1709,6 +2661,12 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.11.0" @@ -1738,6 +2696,26 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "regex" version = "1.12.4" @@ -1784,6 +2762,81 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.8", + "rand 0.9.4", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.27", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1834,6 +2887,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1843,18 +2905,60 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.228" @@ -1882,7 +2986,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1898,6 +3002,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "time", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1935,6 +3059,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "similar" version = "2.7.0" @@ -2000,7 +3137,18 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -2014,6 +3162,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tap" version = "1.0.1" @@ -2065,7 +3224,37 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -2078,6 +3267,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2093,7 +3297,7 @@ version = "0.25.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0db3bae107c9522f86d361697dee1d7386a2ddcf659d5aea5159819a21a3c4a7" dependencies = [ - "indexmap", + "indexmap 2.11.4", "toml_datetime", "toml_parser", "winnow", @@ -2108,6 +3312,30 @@ dependencies = [ "winnow", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unarray" version = "0.1.4" @@ -2167,6 +3395,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2219,7 +3453,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2293,7 +3527,7 @@ dependencies = [ "eyre", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "vihaco-parser", ] @@ -2322,7 +3556,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2428,7 +3662,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2450,7 +3684,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2481,7 +3715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.11.4", "wasm-encoder", "wasmparser", ] @@ -2494,8 +3728,8 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", - "semver", + "indexmap 2.11.4", + "semver 1.0.27", ] [[package]] @@ -2508,6 +3742,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2539,12 +3783,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2670,9 +3967,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.11.4", "prettyplease", - "syn", + "syn 2.0.118", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2688,7 +3985,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2701,7 +3998,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap", + "indexmap 2.11.4", "log", "serde", "serde_derive", @@ -2720,9 +4017,9 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.11.4", "log", - "semver", + "semver 1.0.27", "serde", "serde_derive", "serde_json", @@ -2762,5 +4059,25 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] diff --git a/Cargo.toml b/Cargo.toml index 35cb8102a..b957023b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/ppvm-pauli-word", "crates/ppvm-pauli-sum", "crates/ppvm-sym", + "crates/ppvm-lindblad", "crates/ppvm-python-native", "crates/ppvm-tableau", "crates/ppvm-stim", "crates/stim-parser", diff --git a/crates/ppvm-lindblad/Cargo.toml b/crates/ppvm-lindblad/Cargo.toml new file mode 100644 index 000000000..25bd6dd32 --- /dev/null +++ b/crates/ppvm-lindblad/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "ppvm-lindblad" +version = "0.1.0" +edition = "2024" +description = "Direct Heisenberg-picture Lindbladian evolution on an adaptive Pauli-string basis." + +[dependencies] +fxhash = "0.2.1" +ndarray = "0.17" +num = "0.4.3" +ppvm-traits = { version = "0.1.0", path = "../ppvm-traits" } +ppvm-pauli-word = { version = "0.1.0", path = "../ppvm-pauli-word" } +ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } +rayon = "1.11" +# Matrix-exponential action (Al-Mohy & Higham). QuSpin-rust is MIT-licensed; +# the pinned rev is the commit that added the LICENSE file. +quspin-expm = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" } +# `QuSpinError` (the error type returned by the `LinearOperator` trait methods +# we implement in `mf_expm.rs`) is not re-exported from `quspin-expm`'s root, +# so we depend on `quspin-types` directly. Same git rev as `quspin-expm`. +quspin-types = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" } + +[dev-dependencies] +approx = "0.5.1" +criterion = "0.7.0" +nalgebra = "0.34" + +[[bench]] +name = "kossakowski" +harness = false + +[[bench]] +name = "drug_dipolar" +harness = false diff --git a/crates/ppvm-lindblad/benches/drug_dipolar.rs b/crates/ppvm-lindblad/benches/drug_dipolar.rs new file mode 100644 index 000000000..ba17caa65 --- /dev/null +++ b/crates/ppvm-lindblad/benches/drug_dipolar.rs @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Per-step cost of the Kossakowski-form dissipator on a *molecular dipolar +//! relaxation* workload (the ZULF-NMR drug-FID application), which stresses +//! the path differently from the superradiance chain in `kossakowski.rs`: +//! +//! - operators are **2-local rank-2 tensors** with ~4 Pauli terms each (one +//! channel per site pair, per spatial harmonic `m`), not single-site σ⁻; +//! - `K` is **block-diagonal** over the 5 spatial components `m ∈ −2..2`, +//! each block a dense `P×P` Gram matrix over the `P = C(N,2)` pairs; +//! - the basis strings are dense/high-weight, so most candidate pairs hit +//! the **both-sided sandwich** (12-product) path — the arm the 2026-07-16 +//! ledger flagged as remaining headroom. +//! +//! Both specs (eigenmode jumps vs Kossakowski) generate the identical action; +//! the benchmark measures representation cost on one full `pc_step`. + +use criterion::{Criterion, criterion_group, criterion_main}; +use num::Complex; +use ppvm_lindblad::{JumpInput, LindbladSpec, PcStepConfig, W_CHUNKS, Word, parse_pauli_string}; +use std::hint::black_box; + +const B: usize = 4096; +const GROW_STEPS: usize = 3; +const DT: f64 = 1e-3; +const N_M: usize = 5; + +/// Dipolar-pair geometry: the `(a, b)` site pairs, each pair's coupling +/// magnitude, and its unit separation vector. +type Geometry = (Vec<(usize, usize)>, Vec, Vec<[f64; 3]>); + +/// A Kossakowski dissipator as handed to `LindbladSpec::add_kossakowski`: +/// the operators `A_n` as Pauli lincombs, and the pair matrix `K`. +type KossakowskiModel = (Vec)>>, Vec>>); +// spatial harmonics m = -2..2 + +fn pstr(n: usize, sites: &[(usize, char)]) -> String { + let mut s = vec!['I'; n]; + for &(q, c) in sites { + s[q] = c; + } + s.into_iter().collect() +} + +/// Deterministic pseudo-random 3D unit direction + distance for pair (a,b), +/// so the model is reproducible without an RNG dependency in the bench. +fn hashf(mut x: u64) -> f64 { + x ^= x >> 33; + x = x.wrapping_mul(0xff51afd7ed558ccd); + x ^= x >> 33; + (x >> 11) as f64 / (1u64 << 53) as f64 +} + +/// Dipolar coupling `b` and unit vector for every pair, from placing spins on +/// a jittered chain (real molecules: `b ∝ 1/r³`, generic directions). +fn geometry(n: usize) -> Geometry { + let pos: Vec<[f64; 3]> = (0..n) + .map(|i| { + [ + i as f64 + 0.3 * hashf(i as u64 * 3 + 1), + 0.4 * hashf(i as u64 * 3 + 2), + 0.4 * hashf(i as u64 * 3 + 3), + ] + }) + .collect(); + let mut pairs = Vec::new(); + let mut bmag = Vec::new(); + let mut dir = Vec::new(); + for a in 0..n { + for b in (a + 1)..n { + let d = [ + pos[a][0] - pos[b][0], + pos[a][1] - pos[b][1], + pos[a][2] - pos[b][2], + ]; + let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + pairs.push((a, b)); + bmag.push(1.0 / (r * r * r)); + dir.push([d[0] / r, d[1] / r, d[2] / r]); + } + } + (pairs, bmag, dir) +} + +/// Real rank-2 spherical harmonics (up to normalization) of a unit vector, +/// ordered m = -2,-1,0,1,2 — the spatial factors that make `Γ` rank 5. +fn y2(u: &[f64; 3]) -> [f64; N_M] { + let (x, y, z) = (u[0], u[1], u[2]); + [ + x * y, + y * z, + (3.0 * z * z - 1.0) / 2.0, + x * z, + (x * x - y * y) / 2.0, + ] +} + +/// The 2-local rank-2 tensor operator on pair (a,b) for tensor component +/// `mt` — a representative 4-term Pauli lincomb matching the high-field +/// dressed-tensor forms (T^(2,±2): XX∓YY ± i(XY±YX), etc.). The exact +/// coefficients are immaterial to the cost profile; the term *count* and +/// 2-locality are what matter. +fn tensor_op(n: usize, a: usize, b: usize, mt: usize) -> Vec<(String, Complex)> { + let (i, j) = (Complex::new(0.0, 1.0), Complex::new(1.0, 0.0)); + match mt { + 0 | 4 => { + let s = if mt == 0 { i } else { -i }; // ±2 components + vec![ + (pstr(n, &[(a, 'X'), (b, 'X')]), j), + (pstr(n, &[(a, 'Y'), (b, 'Y')]), -j), + (pstr(n, &[(a, 'X'), (b, 'Y')]), s), + (pstr(n, &[(a, 'Y'), (b, 'X')]), s), + ] + } + 1 | 3 => { + let s = if mt == 1 { i } else { -i }; // ±1 components + vec![ + (pstr(n, &[(a, 'X'), (b, 'Z')]), j), + (pstr(n, &[(a, 'Y'), (b, 'Z')]), s), + (pstr(n, &[(a, 'Z'), (b, 'X')]), j), + (pstr(n, &[(a, 'Z'), (b, 'Y')]), s), + ] + } + _ => vec![ + // m = 0 + (pstr(n, &[(a, 'X'), (b, 'X')]), j), + (pstr(n, &[(a, 'Y'), (b, 'Y')]), j), + (pstr(n, &[(a, 'Z'), (b, 'Z')]), Complex::new(2.0, 0.0)), + ], + } +} + +fn hamiltonian_terms(n: usize, pairs: &[(usize, usize)], bmag: &[f64]) -> Vec<(String, f64)> { + let mut h = Vec::new(); + for (k, &(a, b)) in pairs.iter().enumerate() { + let jc = 0.1 * bmag[k]; // scalar J-coupling, XX+YY + h.push((pstr(n, &[(a, 'X'), (b, 'X')]), jc)); + h.push((pstr(n, &[(a, 'Y'), (b, 'Y')]), jc)); + } + h +} + +/// Kossakowski ops (`N_M` blocks of `P` pair tensors) and the block-diagonal +/// `K = blockdiag(Γ_m)`, `Γ_m[μν] = Σ_{m'} c_μ^{m'} c_ν^{m'}` with +/// `c_μ^{m'} = b_μ Y_2^{m'}(r̂_μ)` — a rank-5 Gram block, exactly as the drug +/// pickles decompose. +fn kossakowski_model(n: usize) -> KossakowskiModel { + let (pairs, bmag, dir) = geometry(n); + let p = pairs.len(); + let c: Vec<[f64; N_M]> = (0..p) + .map(|k| { + let y = y2(&dir[k]); + std::array::from_fn(|mp| bmag[k] * y[mp]) + }) + .collect(); + let mut ops = Vec::with_capacity(N_M * p); + for mt in 0..N_M { + for &(a, b) in &pairs { + ops.push(tensor_op(n, a, b, mt)); + } + } + let m_ops = N_M * p; + let mut k = vec![vec![Complex::new(0.0, 0.0); m_ops]; m_ops]; + for mt in 0..N_M { + let off = mt * p; + for mu in 0..p { + for nu in 0..p { + let g: f64 = (0..N_M).map(|mp| c[mu][mp] * c[nu][mp]).sum(); + k[off + mu][off + nu] = Complex::new(g, 0.0); + } + } + } + (ops, k) +} + +/// Eigenmode jumps of the block-diagonal `K` (the dense representation the +/// Kossakowski path replaces): per block, `L_ν = √γ_ν Σ_μ V_μν T_μ`. +fn eigenmode_jumps(ops: &[Vec<(String, Complex)>], k: &[Vec>]) -> Vec { + let m_ops = ops.len(); + let p = m_ops / N_M; + let mut jumps = Vec::new(); + for mt in 0..N_M { + let off = mt * p; + let block = nalgebra::DMatrix::from_fn(p, p, |a, b| k[off + a][off + b].re); + let eig = nalgebra::SymmetricEigen::new(block); + for nu in 0..p { + let g = eig.eigenvalues[nu]; + if g < 1e-12 { + continue; + } + let mut lin = Vec::new(); + for mu in 0..p { + let v = eig.eigenvectors[(mu, nu)]; + if v.abs() > 1e-14 { + for (s, cc) in &ops[off + mu] { + lin.push((s.clone(), cc * Complex::new(v, 0.0))); + } + } + } + jumps.push(JumpInput { + lincomb: lin, + rate: g, + }); + } + } + jumps +} + +/// Initial observable: γ-weighted transverse magnetization Σ_i X_i (the coil +/// quadrature), a sparse single-site sum like the drug FID initial operator. +fn observable(n: usize) -> (Vec, Vec) { + let mut basis = Vec::new(); + let mut coeffs = Vec::new(); + for a in 0..n { + basis.push( + parse_pauli_string::(&pstr(n, &[(a, 'X')]), n) + .unwrap() + .0, + ); + coeffs.push(1.0); + } + (basis, coeffs) +} + +fn bench_drug(c: &mut Criterion) { + let mut group = c.benchmark_group("pc_step_drug_dipolar"); + group.sample_size(10); + for n in [10usize, 20, 32] { + let (pairs, bmag, _) = geometry(n); + let h = hamiltonian_terms(n, &pairs, &bmag); + let (ops, k) = kossakowski_model(n); + + let spec_eig = ::new(n, &h, &eigenmode_jumps(&ops, &k)).unwrap(); + let mut spec_koss = ::new(n, &h, &[]).unwrap(); + spec_koss.add_kossakowski(&ops, &k).unwrap(); + + let cfg = PcStepConfig { + max_basis: B, + admit_basis: Some(3 * B), + ..Default::default() + }; + let (mut basis, mut coeffs) = observable(n); + for _ in 0..GROW_STEPS { + spec_koss + .pc_step(&mut basis, &mut coeffs, DT, &[], &cfg) + .unwrap(); + } + + for (label, spec) in [("eigenmode", &spec_eig), ("kossakowski", &spec_koss)] { + group.bench_function(format!("{label}_n{n}"), |bch| { + bch.iter(|| { + let mut b = basis.clone(); + let mut cf = coeffs.clone(); + spec.pc_step(&mut b, &mut cf, DT, &[], &cfg).unwrap(); + black_box(cf.len()) + }) + }); + } + } + group.finish(); +} + +criterion_group!(benches, bench_drug); +criterion_main!(benches); diff --git a/crates/ppvm-lindblad/benches/kossakowski.rs b/crates/ppvm-lindblad/benches/kossakowski.rs new file mode 100644 index 000000000..f47c08799 --- /dev/null +++ b/crates/ppvm-lindblad/benches/kossakowski.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Per-step cost of the Kossakowski-form dissipator vs the equivalent +//! eigenmode-jump representation, on the subwavelength superradiance chain +//! (free-space photon-mediated collective σ⁻ decay, d = 0.1 λ₀). +//! +//! Both specs generate the identical adjoint action; the difference is +//! representation cost: eigenmode jumps pay `N · (2N)²` Pauli products per +//! dissipator evaluation, Kossakowski pairs pay `4·nnz(Γ) = 4N²`. +//! +//! The benchmark grows a realistic working basis with a few capped +//! `pc_step` calls, then measures one full `pc_step` (two leakage passes + +//! predictor/corrector expm) from a cloned copy of that basis. + +use criterion::{Criterion, criterion_group, criterion_main}; +use num::Complex; +use ppvm_lindblad::{JumpInput, LindbladSpec, PcStepConfig, W_CHUNKS, Word, parse_pauli_string}; +use std::f64::consts::PI; +use std::hint::black_box; + +const G0: f64 = 1.0; +const D_OVER_LAM: f64 = 0.1; +const B: usize = 4096; +const GROW_STEPS: usize = 3; +const DT: f64 = 0.01; + +/// Free-space couplings `(J, Γ)` of a chain along x with spacing `d·λ₀`, +/// circular polarization `(1, i, 0)/√2`. +fn chain_couplings(n: usize) -> (Vec>, Vec>) { + let k0 = 2.0 * PI; + let mut j = vec![vec![0.0; n]; n]; + let mut gam = vec![vec![0.0; n]; n]; + for (a, (j_row, gam_row)) in j.iter_mut().zip(gam.iter_mut()).enumerate() { + for b in 0..n { + if a == b { + gam_row[b] = G0; + continue; + } + let r = (a as f64 - b as f64).abs() * D_OVER_LAM; + let kr = k0 * r; + let e = Complex::from_polar(1.0, kr); + let pref = e / (4.0 * PI * k0 * k0 * r * r * r); + // p†·G·p with p = (1, i, 0)/√2 and r̂ = x̂: + // p†·(kr²+ikr−1)·1·p = (kr²+ikr−1); p†·r̂r̂·p = 1/2. + let g = pref + * (Complex::new(kr * kr - 1.0, kr) - Complex::new(kr * kr - 3.0, 3.0 * kr) * 0.5); + j_row[b] = -3.0 * PI * G0 / k0 * g.re; + gam_row[b] = 6.0 * PI * G0 / k0 * g.im; + } + } + (j, gam) +} + +fn pstr(n: usize, sites: &[(usize, char)]) -> String { + let mut s = vec!['I'; n]; + for &(q, c) in sites { + s[q] = c; + } + s.into_iter().collect() +} + +fn hamiltonian_terms(n: usize, j: &[Vec]) -> Vec<(String, f64)> { + let mut h = Vec::new(); + for (a, j_row) in j.iter().enumerate() { + for (b, &j_ab) in j_row.iter().enumerate().skip(a + 1) { + if j_ab.abs() > 1e-14 { + h.push((pstr(n, &[(a, 'X'), (b, 'X')]), j_ab / 2.0)); + h.push((pstr(n, &[(a, 'Y'), (b, 'Y')]), j_ab / 2.0)); + } + } + } + h +} + +fn sigma_minus(site: usize, n: usize) -> Vec<(String, Complex)> { + vec![ + (pstr(n, &[(site, 'X')]), Complex::new(0.5, 0.0)), + (pstr(n, &[(site, 'Y')]), Complex::new(0.0, -0.5)), + ] +} + +/// Eigenmode jumps `L_ν = √γ_ν Σ_j V_jν σ⁻_j` from `Γ = V diag(γ) Vᵀ`. +fn eigenmode_jumps(n: usize, gam: &[Vec]) -> Vec { + let mat = nalgebra::DMatrix::from_fn(n, n, |a, b| gam[a][b]); + let eig = nalgebra::SymmetricEigen::new(mat); + let mut jumps = Vec::new(); + for nu in 0..n { + let g = eig.eigenvalues[nu]; + if g < 1e-12 { + continue; + } + let mut lin = Vec::new(); + for j in 0..n { + let v = eig.eigenvectors[(j, nu)]; + if v.abs() > 1e-14 { + for (p, c) in sigma_minus(j, n) { + lin.push((p, c * v)); + } + } + } + jumps.push(JumpInput { + lincomb: lin, + rate: g, + }); + } + jumps +} + +/// `O = Σ_nm Γ_nm σ⁺_n σ⁻_m` as a real Pauli sum. +fn observable(n: usize, gam: &[Vec]) -> (Vec, Vec) { + let mut basis = Vec::new(); + let mut coeffs = Vec::new(); + let mut push = |s: String, c: f64| { + basis.push(parse_pauli_string::(&s, n).unwrap().0); + coeffs.push(c); + }; + push(pstr(n, &[]), n as f64 * G0 / 2.0); + for (a, gam_row) in gam.iter().enumerate() { + push(pstr(n, &[(a, 'Z')]), G0 / 2.0); + for (b, &g_ab) in gam_row.iter().enumerate().skip(a + 1) { + push(pstr(n, &[(a, 'X'), (b, 'X')]), g_ab / 2.0); + push(pstr(n, &[(a, 'Y'), (b, 'Y')]), g_ab / 2.0); + } + } + (basis, coeffs) +} + +fn bench_kossakowski(c: &mut Criterion) { + let mut group = c.benchmark_group("pc_step_superradiance"); + group.sample_size(10); + for n in [10usize, 20, 30] { + let (j, gam) = chain_couplings(n); + let h = hamiltonian_terms(n, &j); + + let spec_eig = ::new(n, &h, &eigenmode_jumps(n, &gam)).unwrap(); + let mut spec_koss = ::new(n, &h, &[]).unwrap(); + let ops: Vec<_> = (0..n).map(|q| sigma_minus(q, n)).collect(); + let k: Vec>> = gam + .iter() + .map(|row| row.iter().map(|&v| Complex::new(v, 0.0)).collect()) + .collect(); + spec_koss.add_kossakowski(&ops, &k).unwrap(); + + // Grow a realistic capped working basis once (shared by both). + let cfg = PcStepConfig { + max_basis: B, + admit_basis: Some(3 * B), + ..Default::default() + }; + let (mut basis, mut coeffs) = observable(n, &gam); + for _ in 0..GROW_STEPS { + spec_koss + .pc_step(&mut basis, &mut coeffs, DT, &[], &cfg) + .unwrap(); + } + + for (label, spec) in [("eigenmode", &spec_eig), ("kossakowski", &spec_koss)] { + group.bench_function(format!("{label}_n{n}"), |bch| { + bch.iter(|| { + let mut b = basis.clone(); + let mut cf = coeffs.clone(); + spec.pc_step(&mut b, &mut cf, DT, &[], &cfg).unwrap(); + black_box(cf.len()) + }) + }); + } + } + group.finish(); +} + +criterion_group!(benches, bench_kossakowski); +criterion_main!(benches); diff --git a/crates/ppvm-lindblad/examples/drug_profile.rs b/crates/ppvm-lindblad/examples/drug_profile.rs new file mode 100644 index 000000000..c82415d61 --- /dev/null +++ b/crates/ppvm-lindblad/examples/drug_profile.rs @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Fast single-config profiler for the Kossakowski-form dissipator on the +//! molecular dipolar-relaxation (ZULF drug-FID) workload — the A/B harness +//! for the 2026-07-17-drug-kossakowski autotune campaign. +//! +//! Prints per-phase `pc_step_timed` breakdown (median of N steps) for the +//! Kossakowski path only. The eigenmode representation is *not* measured here +//! (its O(N³) dense-jump blowup is the thing this path removes — see the +//! `drug_dipolar` criterion bench for the documented representation ratio). +//! +//! Usage: `cargo run --release --example drug_profile -- [N] [B] [STEPS]` + +use num::Complex; +use ppvm_lindblad::{LindbladSpec, PcStepConfig, W_CHUNKS, Word, parse_pauli_string}; +use std::time::Instant; + +const N_M: usize = 5; + +/// Dipolar-pair geometry: the `(a, b)` site pairs, each pair's coupling +/// magnitude, and its unit separation vector. +type Geometry = (Vec<(usize, usize)>, Vec, Vec<[f64; 3]>); + +/// A Kossakowski dissipator as handed to `LindbladSpec::add_kossakowski`: +/// the operators `A_n` as Pauli lincombs, and the pair matrix `K`. +type KossakowskiModel = (Vec)>>, Vec>>); + +fn pstr(n: usize, sites: &[(usize, char)]) -> String { + let mut s = vec!['I'; n]; + for &(q, c) in sites { + s[q] = c; + } + s.into_iter().collect() +} + +fn hashf(mut x: u64) -> f64 { + x ^= x >> 33; + x = x.wrapping_mul(0xff51afd7ed558ccd); + x ^= x >> 33; + (x >> 11) as f64 / (1u64 << 53) as f64 +} + +fn geometry(n: usize) -> Geometry { + let pos: Vec<[f64; 3]> = (0..n) + .map(|i| { + [ + i as f64 + 0.3 * hashf(i as u64 * 3 + 1), + 0.4 * hashf(i as u64 * 3 + 2), + 0.4 * hashf(i as u64 * 3 + 3), + ] + }) + .collect(); + let (mut pairs, mut bmag, mut dir) = (Vec::new(), Vec::new(), Vec::new()); + for a in 0..n { + for b in (a + 1)..n { + let d = [ + pos[a][0] - pos[b][0], + pos[a][1] - pos[b][1], + pos[a][2] - pos[b][2], + ]; + let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + pairs.push((a, b)); + bmag.push(1.0 / (r * r * r)); + dir.push([d[0] / r, d[1] / r, d[2] / r]); + } + } + (pairs, bmag, dir) +} + +fn y2(u: &[f64; 3]) -> [f64; N_M] { + let (x, y, z) = (u[0], u[1], u[2]); + [ + x * y, + y * z, + (3.0 * z * z - 1.0) / 2.0, + x * z, + (x * x - y * y) / 2.0, + ] +} + +fn tensor_op(n: usize, a: usize, b: usize, mt: usize) -> Vec<(String, Complex)> { + let (i, j) = (Complex::new(0.0, 1.0), Complex::new(1.0, 0.0)); + match mt { + 0 | 4 => { + let s = if mt == 0 { i } else { -i }; + vec![ + (pstr(n, &[(a, 'X'), (b, 'X')]), j), + (pstr(n, &[(a, 'Y'), (b, 'Y')]), -j), + (pstr(n, &[(a, 'X'), (b, 'Y')]), s), + (pstr(n, &[(a, 'Y'), (b, 'X')]), s), + ] + } + 1 | 3 => { + let s = if mt == 1 { i } else { -i }; + vec![ + (pstr(n, &[(a, 'X'), (b, 'Z')]), j), + (pstr(n, &[(a, 'Y'), (b, 'Z')]), s), + (pstr(n, &[(a, 'Z'), (b, 'X')]), j), + (pstr(n, &[(a, 'Z'), (b, 'Y')]), s), + ] + } + _ => vec![ + (pstr(n, &[(a, 'X'), (b, 'X')]), j), + (pstr(n, &[(a, 'Y'), (b, 'Y')]), j), + (pstr(n, &[(a, 'Z'), (b, 'Z')]), Complex::new(2.0, 0.0)), + ], + } +} + +fn model(n: usize) -> (Vec<(String, f64)>, KossakowskiModel) { + let (pairs, bmag, dir) = geometry(n); + let p = pairs.len(); + let mut h = Vec::new(); + for (k, &(a, b)) in pairs.iter().enumerate() { + let jc = 0.1 * bmag[k]; + h.push((pstr(n, &[(a, 'X'), (b, 'X')]), jc)); + h.push((pstr(n, &[(a, 'Y'), (b, 'Y')]), jc)); + } + let c: Vec<[f64; N_M]> = (0..p) + .map(|k| { + let y = y2(&dir[k]); + std::array::from_fn(|mp| bmag[k] * y[mp]) + }) + .collect(); + let mut ops = Vec::with_capacity(N_M * p); + for mt in 0..N_M { + for &(a, b) in &pairs { + ops.push(tensor_op(n, a, b, mt)); + } + } + let m_ops = N_M * p; + let mut k = vec![vec![Complex::new(0.0, 0.0); m_ops]; m_ops]; + for mt in 0..N_M { + let off = mt * p; + for mu in 0..p { + for nu in 0..p { + let g: f64 = (0..N_M).map(|mp| c[mu][mp] * c[nu][mp]).sum(); + k[off + mu][off + nu] = Complex::new(g, 0.0); + } + } + } + (h, (ops, k)) +} + +fn observable(n: usize) -> (Vec, Vec) { + let mut basis = Vec::new(); + let mut coeffs = Vec::new(); + for a in 0..n { + basis.push( + parse_pauli_string::(&pstr(n, &[(a, 'X')]), n) + .unwrap() + .0, + ); + coeffs.push(1.0); + } + (basis, coeffs) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20); + let b: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(4096); + let steps: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(8); + let dt = 1e-3; + + let (h, (ops, k)) = model(n); + let mut spec = ::new(n, &h, &[]).unwrap(); + let t0 = Instant::now(); + spec.add_kossakowski(&ops, &k).unwrap(); + let build_ms = t0.elapsed().as_secs_f64() * 1e3; + + let cfg = PcStepConfig { + max_basis: b, + admit_basis: Some(3 * b), + ..Default::default() + }; + let (mut basis, mut coeffs) = observable(n); + // Grow into a realistic capped basis (not timed). + for _ in 0..3 { + spec.pc_step(&mut basis, &mut coeffs, dt, &[], &cfg) + .unwrap(); + } + + let mut totals = Vec::new(); + let (mut l1, mut e1, mut x1, mut l2, mut e2, mut x2) = (0u64, 0u64, 0u64, 0u64, 0u64, 0u64); + for _ in 0..steps { + let mut bb = basis.clone(); + let mut cf = coeffs.clone(); + let t = spec.pc_step_timed(&mut bb, &mut cf, dt, &[], &cfg).unwrap(); + totals.push(t.total_us()); + l1 += t.leakage1_us; + e1 += t.expand1_us; + x1 += t.expm1_us; + l2 += t.leakage2_us; + e2 += t.expand2_us; + x2 += t.expm2_us; + } + totals.sort_unstable(); + let med = totals[totals.len() / 2] as f64 / 1e3; + let s = steps as f64; + println!( + "N={n} B={b} pairs={} ops={} nnz(K)={}", + n * (n - 1) / 2, + ops.len(), + N_M * (n * (n - 1) / 2) * (n * (n - 1) / 2) + ); + println!(" add_kossakowski build: {build_ms:.0} ms"); + println!(" median total/step: {med:.1} ms (over {steps} steps)"); + println!( + " phase avg (ms): leak1 {:.1} expm1 {:.1} leak2 {:.1} expm2 {:.1} expand {:.1}", + l1 as f64 / s / 1e3, + x1 as f64 / s / 1e3, + l2 as f64 / s / 1e3, + x2 as f64 / s / 1e3, + (e1 + e2) as f64 / s / 1e3, + ); + let diss = (l1 + l2) as f64; + let tot = (l1 + e1 + x1 + l2 + e2 + x2) as f64; + println!(" leakage(action) share: {:.0}%", 100.0 * diss / tot); +} diff --git a/crates/ppvm-lindblad/src/algebra.rs b/crates/ppvm-lindblad/src/algebra.rs new file mode 100644 index 000000000..5fd3f4750 --- /dev/null +++ b/crates/ppvm-lindblad/src/algebra.rs @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Hot-path Pauli product / commutator on raw word chunks. +//! +//! Phase encoding: Pauli product `P·Q = ω · R` where `ω = i^phase` and +//! `phase ∈ {0,1,2,3}` ↔ `ω ∈ {1, i, -1, -i}`. The per-byte XOR/AND +//! formulas are the same ones used by +//! [`ppvm_pauli_word::phase::PhasedPauliWord`]'s `MulAssign`. This module +//! keeps a copy that returns the unpacked `(word, phase)` pair without +//! constructing a phased wrapper. + +use crate::word::{Chunk, W_CHUNKS, Word}; +use fxhash::FxHashMap; +use num::Complex; +use ppvm_traits::PauliWordTrait; + +/// Magnitude below which an expanded Pauli coefficient is treated as +/// cancellation noise and dropped. +pub(crate) const COEFF_DROP_TOL: f64 = 1e-14; + +/// One Pauli term in a complex linear combination (a single summand of +/// `L = Σ_a λ_a P_a`, or of a precomputed product such as `L†L`). +#[derive(Clone)] +pub(crate) struct PauliTerm { + pub(crate) word: Word, + pub(crate) coeff: Complex, +} + +/// Expand `A†B = (Σ_a λ_a P_a)† (Σ_b μ_b P_b) = Σ_{a,b} λ_a* μ_b P_a P_b` +/// as a Pauli linear combination, dropping FP-noise zeros. For `A = B` +/// (the jump-operator `L†L`) the coefficients are real; in general they +/// are complex. +pub(crate) fn precompute_adag_b( + a_terms: &[PauliTerm], + b_terms: &[PauliTerm], +) -> Vec> { + let zero = Complex::new(0.0, 0.0); + let mut acc: FxHashMap, Complex> = FxHashMap::default(); + for a in a_terms { + for b in b_terms { + let (word, phase) = pauli_mul(&a.word, &b.word); + let coeff = a.coeff.conj() * b.coeff * phase_factor(phase); + *acc.entry(word).or_insert(zero) += coeff; + } + } + acc.into_iter() + .filter(|(_, c)| c.norm() > COEFF_DROP_TOL) + .map(|(word, coeff)| PauliTerm { word, coeff }) + .collect() +} + +/// Union of the supports (`xbits | zbits`) of every term, as raw chunks. +pub(crate) fn support_mask(terms: &[PauliTerm]) -> [Chunk; C] { + let mut mask = [0 as Chunk; C]; + for t in terms { + for (i, slot) in mask.iter_mut().enumerate() { + *slot |= t.word.xbits.data[i] | t.word.zbits.data[i]; + } + } + mask +} + +#[inline(always)] +pub(crate) fn phase_factor(phase: u8) -> Complex { + match phase & 3 { + 0 => Complex::new(1.0, 0.0), + 1 => Complex::new(0.0, 1.0), + 2 => Complex::new(-1.0, 0.0), + _ => Complex::new(0.0, -1.0), + } +} + +/// `true` if Pauli words `a` and `b` anti-commute. +/// +/// Two Pauli strings anti-commute iff +/// `popcount(a.x & b.z) + popcount(a.z & b.x)` is odd. +#[inline(always)] +pub(crate) fn anti_commutes(a: &Word, b: &Word) -> bool { + let mut bits: u32 = 0; + for i in 0..C { + bits += (a.xbits.data[i] & b.zbits.data[i]).count_ones(); + bits += (a.zbits.data[i] & b.xbits.data[i]).count_ones(); + } + bits & 1 == 1 +} + +/// Commutator product `h · p`: returns `(out, eps)` where `out = h ⊕ p` and +/// +/// - `eps = 0` if `h` and `p` commute (caller should skip — `[h,p] = 0`), +/// - `eps = -2.0` if `h·p` has phase `+i` (so `i·[h,p] = -2·out`), +/// - `eps = +2.0` if `h·p` has phase `-i` (so `i·[h,p] = +2·out`). +#[inline(always)] +pub(crate) fn comm_product(h: &Word, p: &Word) -> (Word, f64) { + let (out, phase) = pauli_mul(h, p); + let eps = match phase { + 1 => -2.0, + 3 => 2.0, + _ => 0.0, + }; + (out, eps) +} + +/// Full Pauli product `p · q`: returns `(out, phase)` where the product +/// is `ω · out` with `ω = i^phase`. +#[inline(always)] +pub(crate) fn pauli_mul(p: &Word, q: &Word) -> (Word, u8) { + let mut out = Word::::new(p.n_qubits()); + let mut sign_count: u32 = 0; + let mut imag_count: u32 = 0; + for i in 0..C { + let a = p.xbits.data[i]; + let b = p.zbits.data[i]; + let c = q.xbits.data[i]; + let d = q.zbits.data[i]; + let sign = (a & b & c & !d) | (a & !b & !c & d) | (!a & b & c & d); + let imag = (a & !b & d) | (a & !c & d) | (!a & b & c) | (b & c & !d); + sign_count += sign.count_ones(); + imag_count += imag.count_ones(); + out.xbits.data[i] = a ^ c; + out.zbits.data[i] = b ^ d; + } + out.rehash(); + (out, ((2 * sign_count + imag_count) & 3) as u8) +} diff --git a/crates/ppvm-lindblad/src/basis.rs b/crates/ppvm-lindblad/src/basis.rs new file mode 100644 index 000000000..09e162177 --- /dev/null +++ b/crates/ppvm-lindblad/src/basis.rs @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Basis-level `L*` operators: in-basis generator and off-basis leakage. + +use crate::Error; +use crate::sector::Sector; +use crate::spec::LindbladSpec; +use crate::truncate::{cap_map_to_room, order_by_desc_mag}; +use crate::word::{Word, word_hash}; +use fxhash::{FxBuildHasher, FxHashMap, FxHashSet}; +use num::Complex; +use rayon::prelude::*; + +/// Chunk size for the leakage accumulation loops: candidates are folded +/// into the live map (and the room-cap applied) once per chunk. +const CHUNK_SIZE: usize = 4096; + +/// Build a `word → row` map for a basis assumed to contain unique Pauli +/// words; debug-asserts the uniqueness invariant. +pub fn build_basis_index(basis: &[Word]) -> FxHashMap, u32> { + let mut index: FxHashMap, u32> = FxHashMap::default(); + for (i, w) in basis.iter().enumerate() { + let prev = index.insert(*w, i as u32); + debug_assert!( + prev.is_none(), + "basis contains duplicate Pauli word at positions {} and {}", + prev.unwrap(), + i, + ); + } + index +} + +impl LindbladSpec { + /// Off-basis component of `L*( Σ_j coeffs[j] · basis[j] )`. Output + /// strings that lie in `basis` or in `protected` are dropped. + pub fn leakage( + &self, + basis: &[Word], + coeffs: &[f64], + protected: &[Word], + ) -> Result, f64)>, Error> { + self.leakage_with_prune(basis, coeffs, protected, usize::MAX, 0.0) + } + + /// Like [`Self::leakage`], but caps the live off-basis leakage map to + /// the *available room* `room = max_basis − basis.len()` — only the + /// strings we could actually add to the basis are worth keeping. The + /// cap is applied during accumulation (after each chunk), keeping the + /// `room` largest-magnitude entries. + /// + /// Basis indices are processed in descending-`|c|` order so the + /// running cap keeps the entries that are most likely to be the true + /// largest contributors. When `max_basis` is large enough that + /// `room ≥ all candidates`, nothing is dropped — the near-exact case. + pub fn leakage_with_prune( + &self, + basis: &[Word], + coeffs: &[f64], + protected: &[Word], + max_basis: usize, + tau_add: f64, + ) -> Result, f64)>, Error> { + if basis.len() != coeffs.len() { + return Err(Error::LengthMismatch { + what: "basis and coeffs", + a: basis.len(), + b: coeffs.len(), + }); + } + // Hash-only membership tables: storing 8-byte `u64` keys instead + // of 48-byte Words shrinks the in-basis structure ~6×, keeping it + // in L3 (and often L2) at basis sizes where the full-Word version + // would spill to DRAM. + let in_basis: FxHashMap = basis.iter().map(|w| (word_hash(w), ())).collect(); + let protected_set: FxHashMap = + protected.iter().map(|w| (word_hash(w), ())).collect(); + + let order = order_by_desc_mag(coeffs); + let room = max_basis.saturating_sub(basis.len()); + let n_qubits = self.n_qubits(); + let mut merged: FxHashMap, f64> = FxHashMap::default(); + for chunk_indices in order.chunks(CHUNK_SIZE) { + let local: Vec, f64)>> = chunk_indices + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(n_qubits), + Vec::::with_capacity(128), + FxHashMap::, Complex>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), &i| { + let p = &basis[i]; + let c = coeffs[i]; + let terms = self.compute_action_terms(p, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (w, v) in terms.iter() { + let h = word_hash(w); + if !in_basis.contains_key(&h) && !protected_set.contains_key(&h) { + out.push((*w, c * *v)); + } + } + out + }, + ) + .collect(); + for v in local { + for (k, val) in v { + *merged.entry(k).or_insert(0.0) += val; + } + } + cap_map_to_room(&mut merged, room); + } + // Rate-based admission: keep only candidates whose leakage rate + // exceeds `tau_add`. `tau_add = 0` admits everything except exact + // zeros. + Ok(merged + .into_iter() + .filter(|(_, c)| c.abs() > tau_add) + .collect()) + } + + /// Sparse generator matrix in COO form: returns `(row, col, val)` + /// triplets. Row = output Pauli's position in `basis`; col = input + /// Pauli's position. Output Paulis not in `basis` are silently dropped. + /// + /// Precondition: `basis` must not contain duplicate Pauli words + /// (asserted in debug builds). + pub fn generator(&self, basis: &[Word]) -> Vec<(usize, usize, f64)> { + let index = build_basis_index(basis); + let n_qubits = self.n_qubits(); + + // `compute_action_terms` returns a deduplicated `Vec<(Word, f64)>`, + // so it can be scattered directly into COO triplets. + let local: Vec> = basis + .par_iter() + .enumerate() + .map_init( + || { + ( + Vec::::with_capacity(n_qubits), + Vec::::with_capacity(128), + FxHashMap::, Complex>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), (col, p)| { + let terms = self.compute_action_terms(p, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (w, v) in terms.iter() { + if let Some(&row) = index.get(w) { + out.push((row as usize, col, *v)); + } + } + out + }, + ) + .collect(); + + // Pre-allocate the flat output to avoid sequential push reallocation. + let total: usize = local.iter().map(|v| v.len()).sum(); + let mut flat = Vec::with_capacity(total); + for v in local { + flat.extend(v); + } + flat + } + + /// Complex-coefficient variant of [`Self::leakage`]: off-basis + /// component of `L*( Σ_j coeffs[j] · basis[j] )` with complex `coeffs`. + pub fn leakage_complex( + &self, + basis: &[Word], + coeffs: &[Complex], + protected: &[Word], + ) -> Result, Complex)>, Error> { + if basis.len() != coeffs.len() { + return Err(Error::LengthMismatch { + what: "basis and coeffs", + a: basis.len(), + b: coeffs.len(), + }); + } + let in_basis: FxHashMap = basis.iter().map(|w| (word_hash(w), ())).collect(); + let protected_set: FxHashMap = + protected.iter().map(|w| (word_hash(w), ())).collect(); + + let n_qubits = self.n_qubits(); + let mut merged: FxHashMap, Complex> = FxHashMap::default(); + for chunk_start in (0..basis.len()).step_by(CHUNK_SIZE) { + let chunk_end = (chunk_start + CHUNK_SIZE).min(basis.len()); + let chunk_basis = &basis[chunk_start..chunk_end]; + let chunk_coeffs = &coeffs[chunk_start..chunk_end]; + let local: Vec, Complex)>> = chunk_basis + .par_iter() + .zip(chunk_coeffs.par_iter()) + .map_init( + || { + ( + Vec::::with_capacity(n_qubits), + Vec::::with_capacity(128), + FxHashMap::, Complex>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), (p, &c)| { + let terms = self.compute_action_terms(p, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (w, v) in terms.iter() { + let h = word_hash(w); + if !in_basis.contains_key(&h) && !protected_set.contains_key(&h) { + out.push((*w, c * *v)); + } + } + out + }, + ) + .collect(); + for v in local { + for (k, val) in v { + *merged.entry(k).or_insert(Complex::new(0.0, 0.0)) += val; + } + } + } + Ok(merged.into_iter().filter(|(_, c)| c.norm() > 0.0).collect()) + } + + /// Phase-aware leakage: out-of-basis component of `L*(O_k)` where + /// `O_k` is the operator represented by `basis` (orbit reps) and + /// `coeffs` (complex coefficients in momentum `sector`). + /// + /// For each input rep `r` with coefficient `c_r`, and each output `q` + /// of `L*(r) = Σ_q v_q · q`: + /// 1. Canonicalize `q` → `(r_q, χ_k, |orbit_q|)` via + /// [`Sector::canonicalize_phase`]. + /// 2. If `r_q` NOT in `basis` and NOT in `protected`: + /// `merged[r_q] += χ_k · v_q · c_r · |orbit_r| / |orbit_q|`. + /// + /// The `|orbit_r| / |orbit_q|` factor is the convention conversion + /// documented on [`crate::mf_expm`]'s `build_orbit_rep_cols`, so the + /// admitted rates are comparable to the averaged-convention + /// coefficients the caller holds. + /// + /// Returns `(r_q, sum)` pairs for all candidates with nonzero sum. + /// + /// This is the orbit-rep counterpart of [`Self::leakage_with_prune`], + /// and caps the live candidate map the same way: to the *available + /// room* `room = max_basis − basis.len()` (the reps we could actually + /// add), applied during accumulation. A large `max_basis` + /// (room ≥ all candidates) disables the cap — the near-exact case. + pub fn leakage_orbit_rep( + &self, + basis: &[Word], + coeffs: &[Complex], + protected: &[Word], + sector: &Sector<'_>, + max_basis: usize, + ) -> Result, Complex)>, Error> { + if basis.len() != coeffs.len() { + return Err(Error::LengthMismatch { + what: "basis and coeffs", + a: basis.len(), + b: coeffs.len(), + }); + } + // Membership is tested on the canonical rep `r_q`, so unlike the + // real path these are full-Word sets, not `word_hash` tables. + let in_basis: FxHashSet<&Word> = basis.iter().collect(); + let protected_set: FxHashSet<&Word> = protected.iter().collect(); + + let order = order_by_desc_mag(coeffs); + let room = max_basis.saturating_sub(basis.len()); + let n_qubits = self.n_qubits(); + let mut merged: FxHashMap, Complex> = FxHashMap::default(); + for chunk_indices in order.chunks(CHUNK_SIZE) { + let local: Vec, Complex)>> = chunk_indices + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(n_qubits), + Vec::::with_capacity(128), + FxHashMap::, Complex>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), &i| { + let r = &basis[i]; + let c_r = coeffs[i]; + // A rep that cannot carry the sector contributes + // nothing (its coefficient is identically zero). + let Some(orbit_in) = sector.orbit_size(r) else { + return Vec::new(); + }; + let terms = self.compute_action_terms(r, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (q, v) in terms.iter() { + let Some((r_q, phase, orbit_out)) = sector.canonicalize_phase(q) else { + continue; + }; + if !in_basis.contains(&r_q) && !protected_set.contains(&r_q) { + let rate = phase * *v * c_r * (orbit_in as f64 / orbit_out as f64); + out.push((r_q, rate)); + } + } + out + }, + ) + .collect(); + for v in local { + for (k, val) in v { + *merged.entry(k).or_insert(Complex::new(0.0, 0.0)) += val; + } + } + cap_map_to_room(&mut merged, room); + } + Ok(merged.into_iter().filter(|(_, c)| c.norm() > 0.0).collect()) + } +} diff --git a/crates/ppvm-lindblad/src/config.rs b/crates/ppvm-lindblad/src/config.rs new file mode 100644 index 000000000..3fe32a71d --- /dev/null +++ b/crates/ppvm-lindblad/src/config.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Configuration objects for the predictor-corrector stepper. + +/// Truncation and execution policy for a single predictor-corrector step +/// ([`crate::LindbladSpec::pc_step`], [`crate::LindbladSpec::pc_step_timed`]). +/// +/// These are the per-run *tuning knobs*, kept separate from the per-call data +/// (`basis`, `coeffs`, `dt`, `protected`). +/// +/// `max_basis` is the primary accuracy/cost dial; `admit_basis` selects the +/// displacement scheme; `drop_tol` is the churn valve of the admission-bound +/// scheme; `tau_add` is a wall optimization at most. +#[derive(Debug, Clone, Copy)] +pub struct PcStepConfig { + /// Hard rank cap on the retained basis: after the corrector, only the + /// top-`max_basis` strings by `|coeff|` are kept (protected words always + /// survive). The primary convergence dial — verify by re-running at 2×. + pub max_basis: usize, + /// Working-set (admission) bound. When `Some(a)` with `a > max_basis`, + /// enrichment may grow the live basis to `a` and the final cap performs a + /// genuine top-`max_basis`-of-union rank displacement (the analog of + /// two-site TDVP truncation at `χ_max`); `drop_tol` is then not needed + /// for membership turnover. `None` bounds admission by `max_basis` + /// itself — the valve scheme, which requires `drop_tol > 0` to keep the + /// basis adapting once it fills. + pub admit_basis: Option, + /// Magnitude prune applied after the corrector: basis entries whose + /// `|coeff|` is below `drop_tol` are discarded (protected words are + /// always kept). `<= 0.0` disables pruning — valid only with + /// `admit_basis` set, otherwise the basis freezes once it fills the cap. + pub drop_tol: f64, + /// Optional absolute rate threshold on leakage admission: a candidate is + /// admitted only if its inflow rate exceeds `tau_add`. This is the + /// natural (dt- and drop_tol-independent) parameterization — the + /// admission accuracy cliff sits at a fixed `tau_add`. `None` = no + /// filter, the recommended default with cap-based truncation. + pub tau_add: Option, + /// When `Some(n)`, run the entire step inside a freshly built rayon + /// thread pool of `n` threads (useful for benchmarking parallel + /// scaling). When `None`, the global rayon pool is used. + pub num_threads: Option, +} + +impl Default for PcStepConfig { + /// Uncapped, unfiltered, no pruning: the near-exact reference + /// configuration. Production runs should set `max_basis` (and usually + /// `admit_basis ≈ 2-3×` it). + fn default() -> Self { + Self { + max_basis: usize::MAX, + admit_basis: None, + drop_tol: 0.0, + tau_add: None, + num_threads: None, + } + } +} diff --git a/crates/ppvm-lindblad/src/error.rs b/crates/ppvm-lindblad/src/error.rs new file mode 100644 index 000000000..1e10c53dd --- /dev/null +++ b/crates/ppvm-lindblad/src/error.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Error type for [`crate::LindbladSpec`] construction and stepping. + +use std::fmt; + +/// Errors raised when constructing a [`crate::LindbladSpec`]. +#[derive(Debug, Clone)] +pub enum Error { + /// `got` qubits do not fit the word width in use, which holds `max`. + TooManyQubits { + got: usize, + max: usize, + }, + LengthMismatch { + what: &'static str, + a: usize, + b: usize, + }, + InvalidPauliCode { + code: u8, + }, + InvalidPauliChar { + c: char, + }, + WrongLength { + expected: usize, + got: usize, + }, + NegativeRate { + index: usize, + rate: f64, + }, + EmptyLincomb { + index: usize, + }, + /// Row `row` of the Kossakowski matrix is not `n_ops` wide. + KMatrixRowLength { + row: usize, + expected: usize, + got: usize, + }, + /// `K_nm ≠ conj(K_mn)`: not a valid GKSL pair matrix. + KMatrixNotHermitian { + n: usize, + m: usize, + }, + Internal(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::TooManyQubits { got, max } => { + write!(f, "LindbladSpec supports n_qubits ≤ {max}; got {got}") + } + Error::LengthMismatch { what, a, b } => { + write!(f, "{what}: expected matching lengths, got {a} and {b}") + } + Error::InvalidPauliCode { code } => write!( + f, + "Pauli code must be 0 (I), 1 (X), 2 (Z), or 3 (Y); got {code}" + ), + Error::InvalidPauliChar { c } => { + write!(f, "invalid Pauli character '{c}'; expected I, X, Y, or Z") + } + Error::WrongLength { expected, got } => { + write!(f, "Pauli string has length {got} but n_qubits = {expected}") + } + Error::NegativeRate { index, rate } => { + write!(f, "jump rate must be non-negative; got γ_{index} = {rate}") + } + Error::EmptyLincomb { index } => { + write!( + f, + "jump {index}: lincomb must contain at least one Pauli term" + ) + } + Error::KMatrixRowLength { row, expected, got } => write!( + f, + "kossakowski K row {row} has length {got}; expected {expected} (one per operator)" + ), + Error::KMatrixNotHermitian { n, m } => write!( + f, + "kossakowski K must be Hermitian; K[{n}][{m}] ≠ conj(K[{m}][{n}])" + ), + Error::Internal(msg) => write!(f, "internal error: {msg}"), + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/ppvm-lindblad/src/expm.rs b/crates/ppvm-lindblad/src/expm.rs new file mode 100644 index 000000000..f203f4f56 --- /dev/null +++ b/crates/ppvm-lindblad/src/expm.rs @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Taylor-partition selection for the `quspin-expm`-backed `exp(t·A)·b` +//! engine (driven from [`crate::mf_expm`]): the `(m, s)` selection tables +//! [`THETA`] / [`THETA_LOOSE`] from Al-Mohy & Higham (2011), used to pick +//! the partition handed to `quspin-expm`'s `from_parts`. + +/// `θ_m` table from Al-Mohy & Higham (2011), Table A.3, for double +/// precision (unit roundoff `u = 2^{-53}`). +/// +/// `θ_m` bounds `‖A‖₁` such that the degree-`m` Taylor polynomial +/// approximates `exp(A)` to within `u`. We pick `(m, s)` with +/// `s ≥ ⌈‖tA‖₁ / θ_m⌉` and minimise `m·s` (total SpMV count). +pub(crate) const THETA: &[(u32, f64)] = &[ + (1, 2.29e-16), + (2, 2.58e-8), + (3, 1.39e-5), + (4, 3.40e-4), + (5, 2.40e-3), + (6, 9.07e-3), + (7, 2.38e-2), + (8, 5.00e-2), + (9, 8.96e-2), + (10, 1.44e-1), + (11, 2.14e-1), + (12, 3.00e-1), + (13, 4.00e-1), + (14, 5.14e-1), + (15, 6.41e-1), + (16, 7.81e-1), + (17, 9.31e-1), + (18, 1.09), + (19, 1.26), + (20, 1.44), + (21, 1.62), + (22, 1.82), + (23, 2.01), + (24, 2.22), + (25, 2.43), + (26, 2.64), + (27, 2.86), + (28, 3.08), + (29, 3.31), + (30, 3.54), +]; + +/// `θ_m` table for a relaxed backward-error tolerance `tol = 1e-6`, computed +/// with the same Al-Mohy & Higham (2011) construction as [`THETA`] (the +/// backward-error series `h_{m+1}(x) = log(e^{-x} T_m(x))`; validated by +/// reproducing the `u = 2^{-53}` table above to ~2 significant figures). +/// +/// The predictor-corrector truncates the Pauli basis at `drop_tol` (typically +/// 1e-3), so computing `exp` to double-precision backward error (~1e-16) is +/// ~10 orders more accurate than the state it acts on. Using `tol = 1e-6` +/// (still ~1000x tighter than the truncation) admits a lower-degree Taylor +/// polynomial for the same `‖tA‖`, cutting the SpMV count (e.g. 23 -> 13 at +/// `‖tA‖ ≈ 2`) with no measurable effect on the truncated result. +pub(crate) const THETA_LOOSE: &[(u32, f64)] = &[ + (1, 2.000e-06), + (2, 2.447e-03), + (3, 2.863e-02), + (4, 1.025e-01), + (5, 2.262e-01), + (6, 3.911e-01), + (7, 5.866e-01), + (8, 8.045e-01), + (9, 1.039), + (10, 1.285), + (11, 1.539), + (12, 1.801), + (13, 2.067), + (14, 2.337), + (15, 2.610), + (16, 2.885), + (17, 3.162), + (18, 3.441), + (19, 3.721), + (20, 4.001), + (21, 4.282), + (22, 4.564), + (23, 4.847), + (24, 5.129), + (25, 5.412), + (26, 5.696), + (27, 5.979), + (28, 6.263), + (29, 6.546), + (30, 6.830), +]; + +/// Pick `(m, s)` minimising `s·m` subject to `s ≥ ⌈t_norm / θ_m⌉, s ≥ 1`, +/// using the `θ_m` table `theta`. Restricted to the table's `m` range; for +/// larger norms `s` simply grows linearly. +fn select_ms_with(t_norm: f64, theta: &[(u32, f64)]) -> (u32, u32) { + if t_norm <= 0.0 { + return (1, 1); + } + let mut best_m = 1u32; + let mut best_s = 1u32; + let mut best_cost = u64::MAX; + for &(m, th) in theta { + let s_f = (t_norm / th).ceil(); + let s = if s_f >= 1.0 { s_f as u32 } else { 1 }; + let cost = (m as u64) * (s as u64); + if cost < best_cost { + best_cost = cost; + best_m = m; + best_s = s; + } + } + (best_m, best_s) +} + +/// `(m, s)` selection at double-precision backward error ([`THETA`]). +pub(crate) fn select_ms(t_norm: f64) -> (u32, u32) { + select_ms_with(t_norm, THETA) +} + +/// `(m, s)` selection at the relaxed `tol = 1e-6` backward error +/// ([`THETA_LOOSE`]) — fewer SpMVs, used on the truncated PC expm path. +pub(crate) fn select_ms_loose(t_norm: f64) -> (u32, u32) { + select_ms_with(t_norm, THETA_LOOSE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ms_selection_sane() { + // tiny norm → small m, s = 1 + let (m, s) = select_ms(1e-9); + assert!(m <= 5, "expected small m for tiny norm, got m={m}"); + assert_eq!(s, 1); + + // moderate norm → m·s should be ~10-50 + let (m, s) = select_ms(1.0); + assert!((m * s) <= 50, "moderate norm cost too high: m={m} s={s}"); + + // large norm → s grows + let (_m, s) = select_ms(100.0); + assert!(s >= 20, "large norm should require many steps, got s={s}"); + } +} diff --git a/crates/ppvm-lindblad/src/kossakowski.rs b/crates/ppvm-lindblad/src/kossakowski.rs new file mode 100644 index 000000000..07c6220af --- /dev/null +++ b/crates/ppvm-lindblad/src/kossakowski.rs @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Kossakowski-form dissipator. +//! +//! For a family of operators `A_n` and a Hermitian, positive-semidefinite +//! pair matrix `K`, the adjoint dissipator is +//! +//! ```text +//! D*(O) = Σ_{n,m} K_nm ( A_n† O A_m − ½ {A_n† A_m, O} ). +//! ``` +//! +//! This is the general GKSL form; the diagonal `K = diag(γ_k)` case is the +//! jump-operator form handled by [`crate::spec::JumpKind::General`]. +//! +//! Each `(n, m)` pair is compiled once into a [`Pair`]. Hermitian-conjugate +//! pairs `(n,m)` and `(m,n)` are *folded* into a single upper-triangle entry: +//! both sandwiches produce the same output words with conjugate phase and the +//! final action keeps only the real part, so one entry that doubles-and-takes- +//! `Re` suffices, halving the pair count. [`PairShape`] records which of the +//! two a compiled pair is, and carries the extra term list that only the +//! folded case needs. + +use crate::Error; +use crate::algebra::{ + COEFF_DROP_TOL, PauliTerm, comm_product, pauli_mul, phase_factor, precompute_adag_b, + support_mask, +}; +use crate::word::{Chunk, W_CHUNKS, Word, parse_pauli_string}; +use fxhash::FxHashMap; +use num::Complex; +use std::collections::BTreeSet; + +/// Relative tolerance for the Hermiticity check on `K`. +const HERMITICITY_TOL: f64 = 1e-10; + +/// Sandwich table of a pair, grouped by the left word: one +/// `(P_a, [(P_b, coeff), …])` group per distinct `P_a`, so `P_a · p` is +/// computed once per group and reused across its `P_b` partners. +type SandwichGroups = Vec<(Word, Vec<(Word, Complex)>)>; + +/// Which of the two compiled pair forms a [`Pair`] is. +/// +/// The distinction changes the meaning of [`Pair::dd`] and selects the term +/// list used by the one-sided commutator path, so it is modelled as a sum +/// type rather than a flag: the off-diagonal-only term list cannot be +/// reached on a diagonal pair. +pub(crate) enum PairShape { + /// `n == m`. [`Pair::dd`] is `K_nn · A_n†A_n`. + Diagonal, + /// `n < m`, folding in the conjugate `(m, n)` pair. [`Pair::dd`] is the + /// Hermitian sum `2·Re(K_nm·A_n†A_m)` used by the both-sided + /// anticommutator. + OffDiagonal { + /// The anti-Hermitian difference `−2i·Im(K_nm·A_n†A_m)` + /// (pure-imaginary coefficients), used by the one-sided commutator + /// of the folded conjugate pair. + dd_anti: Vec>, + }, +} + +/// One compiled `(n, m)` pair of a Kossakowski dissipator. +pub(crate) struct Pair { + sand: SandwichGroups, + /// `A_n†A_m` scaled by `K_nm`; see [`PairShape`] for the exact form. + dd: Vec>, + shape: PairShape, + /// Support masks of `A_n` and `A_m`, for the one-sided fast path. + left_mask: [Chunk; C], + right_mask: [Chunk; C], +} + +/// Compile a Kossakowski dissipator into one [`Pair`] per non-negligible +/// upper-triangle entry of `K`. +/// +/// Returns the pairs alongside, for each pair, the union support of its two +/// operators, so the caller can index them by qubit. +pub(crate) fn compile( + ops: &[Vec<(String, Complex)>], + k: &[Vec>], + n_qubits: usize, +) -> Result, Vec)>, Error> { + let max_abs = validate_k(k, ops.len())?; + let (parsed, op_support) = parse_ops(ops, n_qubits)?; + + let pair_tol = COEFF_DROP_TOL * max_abs; + let mut out = Vec::new(); + for n in 0..ops.len() { + for m in n..ops.len() { + if k[n][m].norm() <= pair_tol { + continue; + } + let pair = compile_pair(&parsed[n], &parsed[m], k[n][m], n != m); + let mut union: BTreeSet = op_support[n].iter().copied().collect(); + union.extend(op_support[m].iter().copied()); + out.push((pair, union.into_iter().collect())); + } + } + Ok(out) +} + +/// Check that `k` is square with side `n_ops` and Hermitian. Returns the +/// largest `|K_nm|`, which sets the scale for the negligible-pair cutoff. +fn validate_k(k: &[Vec>], n_ops: usize) -> Result { + if k.len() != n_ops { + return Err(Error::LengthMismatch { + what: "kossakowski ops and K rows", + a: n_ops, + b: k.len(), + }); + } + for (row, entries) in k.iter().enumerate() { + if entries.len() != n_ops { + return Err(Error::KMatrixRowLength { + row, + expected: n_ops, + got: entries.len(), + }); + } + } + + let max_abs = k + .iter() + .flat_map(|row| row.iter().map(|c| c.norm())) + .fold(0.0_f64, f64::max); + + // A non-Hermitian K is not a valid GKSL pair matrix and would produce an + // action that does not preserve Hermiticity. + let tol = HERMITICITY_TOL * max_abs.max(1.0); + for (n, row_n) in k.iter().enumerate() { + for (m, k_nm) in row_n.iter().enumerate().skip(n) { + if (k_nm - k[m][n].conj()).norm() > tol { + return Err(Error::KMatrixNotHermitian { n, m }); + } + } + } + Ok(max_abs) +} + +/// Parsed operator table: the Pauli terms of each `A_n`, and each `A_n`'s +/// union support. +type ParsedOps = (Vec>>, Vec>); + +/// Parse each operator's Pauli lincomb, returning the parsed terms and each +/// operator's union support. +fn parse_ops( + ops: &[Vec<(String, Complex)>], + n_qubits: usize, +) -> Result, Error> { + let mut parsed = Vec::with_capacity(ops.len()); + let mut supports = Vec::with_capacity(ops.len()); + for (i, op) in ops.iter().enumerate() { + if op.is_empty() { + return Err(Error::EmptyLincomb { index: i }); + } + let mut terms = Vec::with_capacity(op.len()); + let mut union: BTreeSet = BTreeSet::new(); + for (s, c) in op { + let (word, support) = parse_pauli_string(s, n_qubits)?; + union.extend(support.iter().copied()); + terms.push(PauliTerm { word, coeff: *c }); + } + parsed.push(terms); + supports.push(union.into_iter().collect()); + } + Ok((parsed, supports)) +} + +/// Compile the `(n, m)` entry with `A_n = a_terms`, `A_m = b_terms`. +fn compile_pair( + a_terms: &[PauliTerm], + b_terms: &[PauliTerm], + k_nm: Complex, + off_diag: bool, +) -> Pair { + // A_n†A_m as `Σ γ_w W`, then scaled by K_nm. + let adag_b = precompute_adag_b(a_terms, b_terms); + let (dd, shape) = if off_diag { + // Splitting K_nm·γ_w into its Hermitian and anti-Hermitian halves is + // what lets the conjugate (m,n) pair be dropped: the (m,n) sandwich + // contributes the complex conjugate, so the sum is 2·Re on the + // both-sided path and 2i·Im on the one-sided one. + let mut dd = Vec::with_capacity(adag_b.len()); + let mut dd_anti = Vec::with_capacity(adag_b.len()); + for t in &adag_b { + let c = k_nm * t.coeff; + if c.re.abs() > COEFF_DROP_TOL { + dd.push(PauliTerm { + word: t.word, + coeff: Complex::new(2.0 * c.re, 0.0), + }); + } + if c.im.abs() > COEFF_DROP_TOL { + dd_anti.push(PauliTerm { + word: t.word, + coeff: Complex::new(0.0, -2.0 * c.im), + }); + } + } + (dd, PairShape::OffDiagonal { dd_anti }) + } else { + let dd = adag_b + .iter() + .map(|t| PauliTerm { + word: t.word, + coeff: k_nm * t.coeff, + }) + .collect(); + (dd, PairShape::Diagonal) + }; + + let sand = a_terms + .iter() + .map(|a| { + let rights = b_terms + .iter() + .map(|b| (b.word, a.coeff.conj() * b.coeff * k_nm)) + .collect(); + (a.word, rights) + }) + .collect(); + + Pair { + sand, + dd, + shape, + left_mask: support_mask(a_terms), + right_mask: support_mask(b_terms), + } +} + +impl Pair { + /// Accumulate this pair's contribution to `L*(p)` into `local`. + pub(crate) fn accumulate(&self, p: &Word, local: &mut FxHashMap, Complex>) { + let mut p_bits = [0 as Chunk; C]; + for (i, slot) in p_bits.iter_mut().enumerate() { + *slot = p.xbits.data[i] | p.zbits.data[i]; + } + let hits = |mask: &[Chunk; C]| (0..C).any(|i| mask[i] & p_bits[i] != 0); + let (hit_l, hit_r) = (hits(&self.left_mask), hits(&self.right_mask)); + + // The pair is only visited when `p` overlaps at least one side, so + // "not both" means exactly one. + if hit_l && hit_r { + self.accumulate_both_sided(p, local); + } else { + self.accumulate_one_sided(p, hit_r, local); + } + } + + /// One-sided fast path: when `p` is disjoint from one of the two + /// operators the sandwich and anticommutator collapse to a commutator. + /// For a diagonal pair with `D = K·A_n†A_m`: + /// + /// ```text + /// p disjoint from A_n (left): C = −½ [D, p] + /// p disjoint from A_m (right): C = +½ [D, p] + /// ``` + /// + /// For a folded off-diagonal pair the two conjugate one-sided + /// contributions combine into `±½ [F, p]` with the anti-Hermitian + /// `F = dd_anti` and the *opposite* sign. With `[P_c, p] = −i·eps·out` + /// from [`comm_product`], the term coefficient is `∓ t_c · (i/2) · eps`. + fn accumulate_one_sided( + &self, + p: &Word, + hit_r: bool, + local: &mut FxHashMap, Complex>, + ) { + let zero = Complex::new(0.0, 0.0); + let (terms, half_i) = match &self.shape { + PairShape::Diagonal => ( + &self.dd, + if hit_r { + Complex::new(0.0, 0.5) + } else { + Complex::new(0.0, -0.5) + }, + ), + PairShape::OffDiagonal { dd_anti } => ( + dd_anti, + if hit_r { + Complex::new(0.0, -0.5) + } else { + Complex::new(0.0, 0.5) + }, + ), + }; + for t in terms { + let (out, eps) = comm_product(&t.word, p); + if eps != 0.0 { + *local.entry(out).or_insert(zero) += t.coeff * half_i * eps; + } + } + } + + /// Both sides hit: full sandwich plus anticommutator. The sandwich is + /// grouped by the left word so `P_a · p` is computed once per distinct + /// `P_a` and reused across all its `P_b` partners. For a folded + /// off-diagonal pair the sandwich is doubled and its real part taken + /// (the conjugate `(m,n)` pair supplies the other half). + fn accumulate_both_sided(&self, p: &Word, local: &mut FxHashMap, Complex>) { + let zero = Complex::new(0.0, 0.0); + let fold = matches!(self.shape, PairShape::OffDiagonal { .. }); + for (wa, rights) in &self.sand { + let (r_ap, phi1) = pauli_mul(wa, p); + // Hoisted out of the inner loop: the fold is a property of the + // pair, not of the term. + if fold { + for (wb, c0) in rights { + let (s, phi2) = pauli_mul(&r_ap, wb); + let v = c0 * phase_factor(phi1 + phi2); + *local.entry(s).or_insert(zero) += Complex::new(2.0 * v.re, 0.0); + } + } else { + for (wb, c0) in rights { + let (s, phi2) = pauli_mul(&r_ap, wb); + *local.entry(s).or_insert(zero) += c0 * phase_factor(phi1 + phi2); + } + } + } + + // −½{D, p}. For Pauli words, {P_c, p} = 2·sign·R when they commute + // (P_c·p = sign·R) and 0 when they anti-commute; the ½ cancels the 2. + for t in &self.dd { + let (r, phase) = pauli_mul(&t.word, p); + if phase & 1 == 0 { + let sign = if phase == 0 { 1.0 } else { -1.0 }; + *local.entry(r).or_insert(zero) -= t.coeff * Complex::new(sign, 0.0); + } + } + } +} diff --git a/crates/ppvm-lindblad/src/lib.rs b/crates/ppvm-lindblad/src/lib.rs new file mode 100644 index 000000000..683e34751 --- /dev/null +++ b/crates/ppvm-lindblad/src/lib.rs @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct Heisenberg-picture Lindbladian evolution on an adaptive +//! Pauli-string basis. +//! +//! For a Hermitian Pauli Hamiltonian `H = Σ c_i P_i` and jump operators +//! `L_k = Σ_a λ_{k,a} P_{k,a}` (each a Hermitian-Pauli linear combination +//! with possibly complex coefficients) with rates `γ_k ≥ 0`, the adjoint +//! Lindbladian acts on a single Pauli string `p` as +//! +//! ```text +//! L*(p) = i [H, p] + Σ_k γ_k ( L_k† p L_k − 1/2 {L_k† L_k, p} ). +//! ``` +//! +//! Two jump shapes are supported with separate code paths: +//! +//! - **Hermitian Pauli** (`L = P`, `λ ∈ ℝ`): the dissipator collapses to a +//! diagonal `-2γ` on Pauli strings that anti-commute with `P`. Same fast +//! path used by every dephasing-style model. +//! +//! - **General** (complex `λ_a`, e.g. `σ± = (X ± iY)/2`): the dissipator +//! becomes a double sum `Σ_{a,b} λ_a* λ_b P_a p P_b` plus a Pauli- +//! linear-combination anti-commutator with `L†L`, which is precomputed +//! once at construction. Intermediate coefficients are complex; the +//! result is real because `L*` preserves Hermiticity, so we cast back +//! to `f64` at the boundary (with a debug-only check that `|Im|` is at +//! FP noise). +//! +//! Pauli strings are stored as [`ppvm_pauli_word::word::PauliWord`] backed by +//! a fixed array of `C` chunks (64-bit, or 32-bit on 32-bit targets) with +//! cached hashes for fast HashMap lookup. The crate is const-generic in `C` +//! ([`Word`], [`LindbladSpec`]); the default is the 128-qubit width, +//! and [`chunks_for`] picks the narrowest of the 128/256/512-qubit widths +//! for a register. The hot-path commutator/ +//! product loops bypass the higher-level word API and operate directly on +//! the raw chunks for speed. + +mod algebra; +mod basis; +pub mod config; +pub mod error; +pub(crate) mod expm; +mod kossakowski; +mod scalar; +pub mod sector; +mod spec; +mod step; +mod truncate; +mod word; + +/// Matrix-free / quspin-expm-backed `exp(dt·L*)·b` engine. See module docs. +pub(crate) mod mf_expm; + +pub use basis::build_basis_index; +pub use config::PcStepConfig; +pub use error::Error; +pub use sector::{Sector, canonicalize_basis_to_rep}; +pub use spec::{JumpInput, LindbladSpec}; +pub use step::PcStepTimings; +pub use word::{ + CHUNK_BITS, MAX_QUBITS, MAX_SUPPORTED_QUBITS, W_CHUNKS, WIDTHS, Word, chunks_for, + codes_from_word, max_qubits, parse_pauli_string, word_from_codes, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/ppvm-lindblad/src/mf_expm.rs b/crates/ppvm-lindblad/src/mf_expm.rs new file mode 100644 index 000000000..2b99be096 --- /dev/null +++ b/crates/ppvm-lindblad/src/mf_expm.rs @@ -0,0 +1,496 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Matrix-free `exp(dt · L*) · b`, driven by the external `quspin-expm` +//! crate — for both the real (`f64`) adaptive path and the complex, +//! phase-aware orbit-rep path. +//! +//! Instead of materialising the in-basis-restricted generator as a CSR, the +//! per-column generator action is computed ONCE per expm call (via +//! [`build_mf_cols`] / [`build_orbit_rep_cols`]) and reused, CSC-style, +//! across every Krylov/Taylor matvec +//! by [`CscOp`] (a [`quspin_types::LinearOperator`]) fed to +//! [`quspin_expm::ExpmOp::from_parts`]. Each matvec is then a cheap CSC +//! scatter; the Pauli-commutator action is never recomputed per matvec. +//! `from_parts` (rather than `ExpmOp::new`) supplies the diagonal shift `μ`, +//! the partition count `s`, and the truncation order `m*` directly, bypassing +//! quspin's adaptive parameter selection — so the 1-norm *estimator* and +//! `dot_transpose` are never invoked on the single-vector `apply` path; only +//! [`LinearOperator::dot`] runs. +//! +//! `μ`, the trace, and the column 1-norm of `A − μ·I` are computed in the +//! same single action pass as the cache, and turned into an `apply` by the +//! shared [`expm_apply_cached`] tail. The `(m, s)` Taylor partition is +//! picked with the tolerance-matched tables in [`crate::expm`]: a relaxed +//! `tol=1e-6` table when the PC prunes coarsely (`drop_tol ≥ 1e-4`), else the +//! double-precision table (keeping the exact-reference test paths bit-exact). + +use crate::scalar::Coeff; +use crate::sector::Sector; +use crate::{LindbladSpec, Word, build_basis_index, expm}; +use fxhash::{FxBuildHasher, FxHashMap}; +use num::Complex; +use quspin_types::{ExpmComputation, LinearOperator, QuSpinError}; +use rayon::prelude::*; +use std::iter::Sum; +use std::ops::{AddAssign, Div, Mul, Sub}; + +/// Per-column `(raw, diag)` for the `μ`/1-norm selection: `raw` bounds +/// `Σ_r |M[r,c]|` from above and `diag = M[c,c]`. +type PerCol = Vec<(f64, T)>; + +/// Scratch buffers for [`LindbladSpec::compute_action_terms`]. +type ActionScratch = (Vec, Vec, FxHashMap, Complex>); + +/// Consecutive CSC columns stored flat: local column `j` holds +/// `rows[offsets[j]..offsets[j + 1]]` and the matching `vals`. +struct CscBlock { + offsets: Vec, + rows: Vec, + vals: Vec, +} + +/// Cached in-basis action in CSC form, stored as blocks of `block` columns. +/// +/// One exactly-sized allocation triple per block replaces one `Vec` per +/// column: at `|basis| ~ 10^6` the per-column layout reserved every `L*` +/// output (in- and out-of-basis) and left ~10^6 small allocations for the +/// system allocator to retain after the expm call. +pub(crate) struct BlockCsc { + blocks: Vec>, + block: usize, + dim: usize, +} + +impl BlockCsc { + /// Visit the columns `range` in order as `(col, rows, vals)`. + fn for_each_col(&self, range: std::ops::Range, mut f: impl FnMut(usize, &[u32], &[T])) { + let mut c = range.start; + while c < range.end { + let b = &self.blocks[c / self.block]; + let base = (c / self.block) * self.block; + let stop = range.end.min(base + b.offsets.len() - 1); + for j in (c - base)..(stop - base) { + let (lo, hi) = (b.offsets[j] as usize, b.offsets[j + 1] as usize); + f(base + j, &b.rows[lo..hi], &b.vals[lo..hi]); + } + c = stop; + } + } +} + +/// Build the [`BlockCsc`] cache and the per-column `(raw, diag)` data for +/// a `dim`-column generator in one parallel pass. `col(c, scratch, rows, +/// vals)` appends the in-basis entries of column `c` to `rows`/`vals` and +/// returns its `(raw, diag)`. +fn build_block_csc( + spec: &LindbladSpec, + dim: usize, + col: F, +) -> (BlockCsc, PerCol) +where + T: Copy + Send + Sync, + F: Fn(usize, &mut ActionScratch, &mut Vec, &mut Vec) -> (f64, T) + Sync, +{ + // ~16 blocks per thread for load balance, but never so small that the + // per-block allocations matter. + let block = dim + .div_ceil(16 * rayon::current_num_threads().max(1)) + .clamp(64, 4096); + let (blocks, per_col): (Vec>, Vec>) = (0..dim.div_ceil(block)) + .into_par_iter() + .map_init( + || { + let scratch: ActionScratch = ( + Vec::with_capacity(spec.n_qubits()), + Vec::with_capacity(128), + FxHashMap::with_capacity_and_hasher(128, FxBuildHasher::default()), + ); + (scratch, Vec::::new(), Vec::::new()) + }, + |(scratch, rows, vals), b| { + let cols = (b * block)..dim.min((b + 1) * block); + rows.clear(); + vals.clear(); + let mut offsets = Vec::with_capacity(cols.len() + 1); + let mut per_col = Vec::with_capacity(cols.len()); + offsets.push(0); + for c in cols { + per_col.push(col(c, scratch, rows, vals)); + offsets.push(u32::try_from(rows.len()).expect("CSC block exceeds u32 entries")); + } + // `to_vec` sizes the stored block exactly; the staging + // buffers are reused for the next block on this thread. + let blk = CscBlock { + offsets, + rows: rows.to_vec(), + vals: vals.to_vec(), + }; + (blk, per_col) + }, + ) + .unzip(); + let per_col = per_col.into_iter().flatten().collect(); + (BlockCsc { blocks, block, dim }, per_col) +} + +/// Per-column in-basis action of the real generator `M`, plus the data the +/// `(m, s)`/`μ` selection needs — all from ONE action pass over the basis. +/// +/// Returns `(cols, per_col)` where `cols[c]` holds `(row, coeff)` for every +/// action output of `L*(basis[c])` that lands back in `basis` (CSC column +/// `c`), and `per_col[c] = (raw, diag)` with `raw = Σ|coeff|` over ALL action +/// outputs (in- and out-of-basis, an upper bound on the column 1-norm) and +/// `diag` the coefficient of the output Word equal to the input Word. The +/// cache is reused by [`CscOp`] across every Krylov/Taylor matvec. +fn build_mf_cols( + spec: &LindbladSpec, + basis: &[Word], + index: &FxHashMap, u32>, +) -> (BlockCsc, PerCol) { + build_block_csc(spec, basis.len(), |c, (s1, s2, lm), rows, vals| { + let p = &basis[c]; + let terms = spec.compute_action_terms(p, s1, s2, lm); + let mut raw = 0.0; + let mut diag = 0.0; + for (w, v) in terms.iter() { + raw += v.abs(); + if w == p { + diag = *v; + } + if let Some(&row) = index.get(w) { + rows.push(row); + vals.push(*v); + } + } + (raw, diag) + }) +} + +/// Per-column **phase-aware** action of the in-basis-restricted orbit-rep +/// generator `M` at momentum `sector`, plus the `(m, s)`/`μ` selection data +/// — from ONE action pass over the basis. +/// +/// `cols[c]` holds `(row, χ_k(g_{cnt_q}) · v_q · |orbit_c| / |orbit_row|)` +/// for every action output Pauli `q` of `L*(basis[c])` whose orbit rep +/// `r_q` is in `basis` at index `row`; outputs whose rep is out of basis +/// are dropped. This is the expensive part of the orbit-rep dynamics +/// (`compute_action_terms`, [`Sector::canonicalize_phase`]). +/// +/// The character-weighted sum runs over the *output* orbit's distinct +/// members, which makes it the generator in the **summing** convention +/// `ĉ_r = |orbit_r| · c_r`. Coefficients here are in the *averaged* +/// convention (`c_r` = the plain coefficient of the rep word, what +/// `canonicalize_pauli_sum_complex` produces), so each entry carries the +/// similarity factor `|orbit_c| / |orbit_row|` that converts between +/// them. It is 1 exactly when both orbits are free — hence the factor is +/// invisible until an orbit has a non-trivial stabilizer, and cannot be +/// hoisted out as a global `|G|`. +/// +/// Unlike [`build_mf_cols`], `per_col[c].0` sums only the retained +/// in-basis entries — the exact column 1-norm of the restricted `M`, not an +/// upper bound: several distinct outputs `q` can share one rep, so the +/// out-of-basis magnitudes are not attributable to a column of `M`. `diag` +/// accumulates for the same reason. +fn build_orbit_rep_cols( + spec: &LindbladSpec, + basis: &[Word], + index: &FxHashMap, u32>, + sector: &Sector<'_>, +) -> (BlockCsc>, PerCol>) { + build_block_csc(spec, basis.len(), |c, (s1, s2, lm), rows, vals| { + let r = &basis[c]; + // A rep that cannot carry the sector has coefficient zero + // identically, so its column is empty. + let Some(orbit_in) = sector.orbit_size(r) else { + return (0.0, Complex::new(0.0, 0.0)); + }; + let terms = spec.compute_action_terms(r, s1, s2, lm); + let mut raw = 0.0; + let mut diag = Complex::new(0.0, 0.0); + for (q, v) in terms.iter() { + let Some((r_q, phase, orbit_out)) = sector.canonicalize_phase(q) else { + continue; + }; + if let Some(&row) = index.get(&r_q) { + let val = phase * *v * (orbit_in as f64 / orbit_out as f64); + raw += val.norm(); + if row as usize == c { + diag += val; + } + rows.push(row); + vals.push(val); + } + } + (raw, diag) + }) +} + +/// Borrowed CSC-style view of an in-basis-restricted generator `M`, backed +/// by a cached per-column action computed once per expm call +/// ([`build_mf_cols`]). `dot` performs the CSC matvec `y = M·x` against the cache; the +/// remaining `LinearOperator` entry points are unused on the `from_parts` + +/// single-vector `apply` path. +/// +/// Borrowed, not owned: `quspin-types` provides a blanket `LinearOperator` +/// impl for `&T`, so `ExpmOp::from_parts(op, ...)` accepts a `CscOp` by +/// value while it keeps borrowing `cols`. +pub(crate) struct CscOp<'a, T> { + pub(crate) cols: &'a BlockCsc, +} + +impl LinearOperator for CscOp<'_, T> +where + T: ExpmComputation + + Copy + + PartialEq + + num::Zero + + std::ops::AddAssign + + std::ops::Mul + + Send + + Sync, +{ + fn dim(&self) -> usize { + self.cols.dim + } + + fn parallel_hint(&self) -> bool { + // `dot` parallelises internally over column chunks, and we drive the + // sequential single-vector `apply` path; never let quspin run its + // persistent-thread pool on top of our rayon parallelism. + false + } + + fn dot(&self, overwrite: bool, input: &[T], output: &mut [T]) -> Result<(), QuSpinError> { + let n = self.cols.dim; + if n == 0 { + return Ok(()); + } + let num_threads = rayon::current_num_threads().max(1); + let chunk_size = n.div_ceil(num_threads); + + // Parallelise over column chunks; each thread accumulates into a dense + // local `y` of length `dim`, reading the cached action; the partials + // are reduced into `output` sequentially at the end. + let partial_ys: Vec> = (0..n.div_ceil(chunk_size)) + .into_par_iter() + .map(|chunk_idx| { + let cols = (chunk_idx * chunk_size)..n.min((chunk_idx + 1) * chunk_size); + let mut y_local = vec![T::zero(); n]; + self.cols.for_each_col(cols, |c, rows, vals| { + let xc = input[c]; + if xc == T::zero() { + return; + } + for (&row, &val) in rows.iter().zip(vals) { + y_local[row as usize] += val * xc; + } + }); + y_local + }) + .collect(); + + if overwrite { + output.fill(T::zero()); + } + for partial in &partial_ys { + for (oi, &pi) in output.iter_mut().zip(partial.iter()) { + *oi += pi; + } + } + Ok(()) + } + + fn trace(&self) -> T { + // Computed eagerly by the callers; never reached on the + // `from_parts` + single-vector `apply` path. + unreachable!("CscOp::trace not used on the from_parts apply path") + } + + fn onenorm(&self, _shift: T) -> ::Real { + unreachable!("CscOp::onenorm not used on the from_parts apply path") + } + + fn dot_transpose( + &self, + _overwrite: bool, + _input: &[T], + _output: &mut [T], + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_transpose not used on the from_parts apply path".into(), + )) + } + + fn dot_many( + &self, + _overwrite: bool, + _input: ndarray::ArrayView2<'_, T>, + _output: ndarray::ArrayViewMut2<'_, T>, + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_many not used on the from_parts apply path".into(), + )) + } + + fn dot_chunk( + &self, + _overwrite: bool, + _input: &[T], + _output_chunk: &mut [T], + _row_start: usize, + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_chunk not used on the from_parts apply path".into(), + )) + } + + fn dot_transpose_chunk( + &self, + _input: &[T], + _output: &[::Atomic], + _rows: std::ops::Range, + ) -> Result<(), QuSpinError> { + Err(QuSpinError::RuntimeError( + "CscOp: dot_transpose_chunk not used on the from_parts apply path".into(), + )) + } +} + +/// Shared tail of every matrix-free expm: from the cached per-column action +/// derive the diagonal shift `μ = tr(M)/n` and a bound on the column 1-norm +/// of `M − μ·I` (`raw − |diag| + |diag − μ|` per column), pick the Taylor +/// partition via `select` from `‖dt·(M−μI)‖₁`, and hand everything to +/// [`quspin_expm::ExpmOp::from_parts`]. Returns `exp(dt · M) · coeffs`. +/// +/// `select` maps `‖dt·(M−μI)‖₁` to `(m*, s, backward-error tol)`; the two +/// call sites differ only in that choice. +fn expm_apply_cached( + cols: &BlockCsc, + per_col: &PerCol, + dt: f64, + coeffs: &[T], + select: impl FnOnce(f64) -> (u32, u32, f64), +) -> Vec +where + T: ExpmComputation + + Coeff + + PartialEq + + num::Zero + + AddAssign + + Mul + + Sub + + Div + + From + + Sum, +{ + let n = cols.dim; + let trace: T = per_col.iter().map(|(_, d)| *d).sum(); + let mu = trace / n as f64; + let onenorm = per_col + .iter() + .map(|&(raw, diag)| raw - diag.mag() + (diag - mu).mag()) + .fold(0.0_f64, f64::max); + let (m_star, s, expm_tol) = select(dt.abs() * onenorm); + + let mut v = coeffs.to_vec(); + let op = CscOp { cols }; + let expm = + quspin_expm::ExpmOp::from_parts(op, T::from(dt), mu, s as usize, m_star as usize, expm_tol); + expm.apply(ndarray::ArrayViewMut1::from(v.as_mut_slice())) + .expect("expm apply"); + v +} + +/// Compute `exp(dt · M) · coeffs` for the in-basis-restricted generator +/// `M`, matrix-free, via `quspin-expm`. Returns a fresh `Vec` of length +/// `basis.len()`. +/// +/// ONE action pass builds the CSC cache `cols` (reused across every matvec) +/// and, in the same pass, the `(raw, diag)` data the `μ`/1-norm selection +/// needs; [`expm_apply_cached`] does the rest. +pub(crate) fn expm_apply_mf( + spec: &LindbladSpec, + basis: &[Word], + dt: f64, + coeffs: &[f64], + drop_tol: f64, +) -> Vec { + if basis.is_empty() { + return Vec::new(); + } + let index = build_basis_index(basis); + let (cols, per_col) = build_mf_cols(spec, basis, &index); + + // Pick the Taylor backward-error tolerance to match the basis truncation: + // when the PC prunes coarsely (drop_tol >= 1e-4) a double-precision exp is + // ~10 orders more accurate than the state it acts on, so the relaxed + // (tol=1e-6, still >=100x tighter than the cut) table is used — it admits a + // lower-degree Taylor polynomial and cuts the SpMV count with no effect on + // the truncated result. At tight/zero drop_tol we keep double precision so + // the exact-reference paths (orbit-rep / merged) still agree bit-for-bit. + expm_apply_cached(&cols, &per_col, dt, coeffs, |t_norm| { + if drop_tol >= 1e-4 { + let (m, s) = expm::select_ms_loose(t_norm); + (m, s, 1e-6) + } else { + let (m, s) = expm::select_ms(t_norm); + (m, s, 1e-12) + } + }) +} + +/// Compute `exp(dt · M) · coeffs` for the in-basis-restricted **orbit-rep** +/// generator `M` at momentum `sector`, via `quspin-expm`. Returns a fresh +/// `Vec>` of length `basis.len()`. +/// +/// The expensive phase-aware action is computed ONCE here (via +/// [`build_orbit_rep_cols`]) and reused, CSC-style, across every +/// Krylov–Taylor matvec, exactly as on the real path. +pub(crate) fn expm_apply_orbit_rep( + spec: &LindbladSpec, + basis: &[Word], + sector: &Sector<'_>, + dt: f64, + coeffs: &[Complex], +) -> Vec> { + if basis.is_empty() { + return Vec::new(); + } + let index = build_basis_index(basis); + let (cols, per_col) = build_orbit_rep_cols(spec, basis, &index, sector); + + expm_apply_cached(&cols, &per_col, dt, coeffs, |t_norm| { + let (m, s) = expm::select_ms(t_norm); + (m, s, 1e-12) + }) +} + +/// `exp(dt · M) · b` where `M` is the REAL in-basis-restricted generator but +/// the input vector `b` is complex. Because `M` is real, +/// `exp(dt·M)·(re + i·im) = exp(dt·M)·re + i·exp(dt·M)·im`, so we split the +/// complex vector into its real and imaginary parts, run two real +/// matrix-free applies, and recombine. Used by the test-only full-space +/// complex reference step. +#[cfg(test)] +pub(crate) fn expm_apply_mf_cxvec( + spec: &LindbladSpec, + basis: &[Word], + dt: f64, + b: &[Complex], + drop_tol: f64, +) -> Vec> { + let n = basis.len(); + if n == 0 { + return Vec::new(); + } + let re: Vec = b.iter().map(|z| z.re).collect(); + let im: Vec = b.iter().map(|z| z.im).collect(); + let re_out = expm_apply_mf(spec, basis, dt, &re, drop_tol); + let im_out = expm_apply_mf(spec, basis, dt, &im, drop_tol); + re_out + .into_iter() + .zip(im_out) + .map(|(r, i)| Complex::new(r, i)) + .collect() +} diff --git a/crates/ppvm-lindblad/src/scalar.rs b/crates/ppvm-lindblad/src/scalar.rs new file mode 100644 index 000000000..9ae3f4175 --- /dev/null +++ b/crates/ppvm-lindblad/src/scalar.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! The coefficient scalar of a Pauli-sum basis: real (`f64`) on the +//! plain adaptive path, complex on the momentum-sector orbit-rep path. +//! +//! Every truncation and 1-norm decision in this crate only ever needs a +//! magnitude and a zero, so the two paths share one implementation +//! parameterised by [`Coeff`] instead of a real and a complex copy. + +use num::Complex; + +/// A Pauli-sum coefficient: `f64` or `Complex`. +pub(crate) trait Coeff: Copy + Send + Sync { + /// Absolute value (`f64::abs`) / modulus (`Complex::norm`). + fn mag(self) -> f64; + + fn zero() -> Self; +} + +impl Coeff for f64 { + #[inline] + fn mag(self) -> f64 { + self.abs() + } + + #[inline] + fn zero() -> Self { + 0.0 + } +} + +impl Coeff for Complex { + #[inline] + fn mag(self) -> f64 { + self.norm() + } + + #[inline] + fn zero() -> Self { + Complex::new(0.0, 0.0) + } +} diff --git a/crates/ppvm-lindblad/src/sector.rs b/crates/ppvm-lindblad/src/sector.rs new file mode 100644 index 000000000..181d9359b --- /dev/null +++ b/crates/ppvm-lindblad/src/sector.rs @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Momentum sectors of a translation group, and the phase-aware +//! canonicalization that drives orbit-representative evolution. +//! +//! On the orbit-rep path the state lives entirely in **orbit-rep form** +//! throughout: the basis contains only canonical translation-orbit +//! representatives and the coefficients are complex (one per rep). The +//! dynamics `L*` is computed with **phase-aware action** — for each +//! output Pauli `q`, we canonicalize `q` to its orbit rep `r_q` with +//! shift counter `cnt_q`, and accumulate +//! `χ_k(g_{cnt_q}) · v · c_r · |orbit_r| / |orbit_{r_q}|` (where `v` is +//! the matrix element of `L*` between input rep `r` and output `q`). +//! [`Sector::canonicalize_phase`] is that step. +//! +//! Coefficients are in the **averaged** convention: `c_r` is the plain +//! coefficient of the rep word, as produced by +//! `canonicalize_pauli_sum_complex`. The character-weighted action is +//! naturally the generator in the *summing* convention +//! `ĉ_r = |orbit_r| · c_r`, which is where the orbit-size ratio comes +//! from; it is 1 whenever both orbits are free. +//! +//! The orbit-rep basis is ~`|G|`× smaller than the full-basis +//! representation, throughout the entire evolution. +//! +//! ## Limitations +//! +//! - Callers are responsible for ensuring the input basis is in +//! orbit-rep form (i.e. each entry is the canonical representative of +//! its translation orbit). Use [`canonicalize_basis_to_rep`] if +//! needed. +//! - A [`Sector`] is fixed for the duration of one +//! [`LindbladSpec::pc_step_orbit_rep`](crate::LindbladSpec::pc_step_orbit_rep) +//! call. To compute a full site-resolved profile, call it once per +//! momentum mode and inverse-Fourier the results. + +use crate::Word; +use num::Complex; +use ppvm_pauli_sum::symmetry::{CharacterTable, TranslationGroup}; + +/// A momentum sector of a translation group: the group `G` together with +/// one integer mode index per generator. The wavenumber along generator +/// `g` is `2π · k_modes[g] / group.generator_order(g)`; `k_modes = [0, …]` +/// is the trivial sector. +/// +/// The two halves are meaningless apart — every phase-aware routine +/// needs both — so they travel as one value. Construction precomputes the +/// sector's [`CharacterTable`] (`|G|` entries), so the per-term work in the +/// hot loops is one canonicalization and a table lookup; build a `Sector` +/// once per step and pass it by reference. +#[derive(Clone)] +pub struct Sector<'a> { + group: &'a TranslationGroup, + k_modes: &'a [i32], + characters: CharacterTable, +} + +impl<'a> Sector<'a> { + pub fn new(group: &'a TranslationGroup, k_modes: &'a [i32]) -> Self { + let characters = group.character_table(k_modes); + Self { + group, + k_modes, + characters, + } + } + + /// The translation group. + pub fn group(&self) -> &'a TranslationGroup { + self.group + } + + /// The integer momentum mode per generator. + pub fn k_modes(&self) -> &'a [i32] { + self.k_modes + } + + /// Canonicalize `q` to its orbit representative `r_q` and return it + /// alongside the character phase `χ_k(g_{cnt_q})` of the group + /// element that maps `q` to `r_q`, and the number of **distinct** + /// members of that orbit. The phase weights the matrix element of + /// `L*` when it is accumulated onto `r_q`; the orbit size converts + /// between the two coefficient conventions (see + /// [`Self::orbit_size`]). + /// + /// `None` when `q`'s orbit cannot carry this sector (its stabilizer + /// is incompatible with `k`): the coefficient of such a rep is + /// identically zero, so the term is dropped. + #[inline] + pub fn canonicalize_phase( + &self, + q: &Word, + ) -> Option<(Word, Complex, usize)> { + let (rep, idx, orbit_size) = self + .group + .canonicalize_in_sector_indexed(q, &self.characters)?; + Some((rep, self.characters.value(idx), orbit_size)) + } + + /// Number of **distinct** members of `w`'s translation orbit, or + /// `None` if the orbit cannot carry this sector. + /// + /// This is the factor between the two orbit-rep coefficient + /// conventions: the *averaged* one, in which `c_r` is the plain + /// coefficient of the rep word (what `canonicalize_pauli_sum_complex` + /// and this crate's public orbit-rep API use), and the *summing* one + /// `ĉ_r = |orbit_r| · c_r` (what `momentum_merge_pauli_sum_pair` + /// uses). It is `|G|` only for free orbits. + #[inline] + pub fn orbit_size(&self, w: &Word) -> Option { + self.group + .canonicalize_in_sector_indexed(w, &self.characters) + .map(|(_, _, orbit_size)| orbit_size) + } +} + +/// Replace each entry of `basis` with its canonical orbit +/// representative under `group`. Pure rewrite; coefficients are +/// untouched. Useful to enforce the orbit-rep invariant before calling +/// [`LindbladSpec::pc_step_orbit_rep`](crate::LindbladSpec::pc_step_orbit_rep). +/// +/// Does NOT deduplicate — if multiple input entries collapse to the +/// same rep, both are kept (caller should run a merge afterwards). +pub fn canonicalize_basis_to_rep(basis: &mut [Word], group: &TranslationGroup) { + for w in basis.iter_mut() { + *w = group.canonicalize(w); + } +} diff --git a/crates/ppvm-lindblad/src/spec.rs b/crates/ppvm-lindblad/src/spec.rs new file mode 100644 index 000000000..013884d0a --- /dev/null +++ b/crates/ppvm-lindblad/src/spec.rs @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Precompiled Lindbladian: construction and the single-Pauli `L*` kernel. + +use crate::Error; +use crate::algebra::{ + PauliTerm, anti_commutes, comm_product, pauli_mul, phase_factor, precompute_adag_b, +}; +use crate::kossakowski; +use crate::word::{W_CHUNKS, Word, check_width, parse_pauli_string, word_support}; +use fxhash::FxHashMap; +use num::Complex; + +/// Parsed Hamiltonian term. +#[derive(Clone)] +struct HTerm { + word: Word, + coeff: f64, +} + +/// One entry of the dissipator. `HermitianPauli` and `General` are the +/// jump-operator form (`K` diagonal); `Kossakowski` is one compiled pair of +/// the general form. See [`crate::kossakowski`]. +enum JumpKind { + HermitianPauli { + word: Word, + rate: f64, + }, + General { + terms: Vec>, // L = Σ_a λ_a P_a + dagger_dagger: Vec>, // L†L = Σ_c μ_c P_c (μ_c ∈ ℝ) + rate: f64, + }, + Kossakowski(kossakowski::Pair), +} + +/// Union of `index[q]` for each `q ∈ p_support`, deduped. +#[inline] +fn candidate_terms(p_support: &[u32], index: &[Vec], scratch: &mut Vec) { + scratch.clear(); + for &q in p_support { + scratch.extend_from_slice(&index[q as usize]); + } + scratch.sort_unstable(); + scratch.dedup(); +} + +/// Precompiled Lindbladian. Constructed once from string-form Hamiltonian +/// terms + jump operators; reused across many calls to [`Self::action`], +/// [`Self::leakage`], [`Self::generator`]. `L*(p)` is recomputed on every +/// call rather than cached: for sparse-local Hamiltonians a per-word cache +/// costs more than the recompute (hash lookup ≳ recompute) and its several +/// KB per cached word dominate memory at large basis sizes. +pub struct LindbladSpec { + n_qubits: usize, + h_terms: Vec>, + j_kinds: Vec>, + /// `h_support[q]` = indices of Hamiltonian terms acting on qubit `q`. + h_support: Vec>, + /// `j_support[q]` = indices of jumps whose support contains qubit `q`. + j_support: Vec>, +} + +/// User-facing description of one jump operator: a complex Pauli linear +/// combination together with its rate. +#[derive(Clone, Debug)] +pub struct JumpInput { + /// `(pauli_string, λ)` pairs forming `L_k = Σ_a λ_a P_a`. + pub lincomb: Vec<(String, Complex)>, + /// Non-negative GKSL rate `γ_k`. + pub rate: f64, +} + +impl LindbladSpec { + /// Construct a Lindbladian spec from Hamiltonian terms and jump operators. + /// + /// `h_terms` are `(pauli_string, coefficient)` pairs forming the Hermitian + /// Hamiltonian. Each jump operator is a complex Pauli linear combination; + /// a length-1 jump with imaginary part `0` is routed to the Hermitian-Pauli + /// fast path (with rate scaled by the squared real coefficient). + pub fn new( + n_qubits: usize, + h_terms: &[(String, f64)], + jumps: &[JumpInput], + ) -> Result { + check_width::(n_qubits)?; + + let mut h_parsed: Vec> = Vec::with_capacity(h_terms.len()); + let mut h_support_idx: Vec> = vec![Vec::new(); n_qubits]; + for (i, (s, c)) in h_terms.iter().enumerate() { + let (word, support) = parse_pauli_string(s, n_qubits)?; + for q in support { + h_support_idx[q as usize].push(i as u32); + } + h_parsed.push(HTerm { word, coeff: *c }); + } + + let mut j_kinds: Vec> = Vec::with_capacity(jumps.len()); + let mut j_support_idx: Vec> = vec![Vec::new(); n_qubits]; + for (k, jump) in jumps.iter().enumerate() { + if jump.rate < 0.0 { + return Err(Error::NegativeRate { + index: k, + rate: jump.rate, + }); + } + if jump.lincomb.is_empty() { + return Err(Error::EmptyLincomb { index: k }); + } + + // Fast path: single-term, purely real → Hermitian Pauli. + if jump.lincomb.len() == 1 && jump.lincomb[0].1.im == 0.0 { + let (s, c) = &jump.lincomb[0]; + let (word, support) = parse_pauli_string(s, n_qubits)?; + for q in support { + j_support_idx[q as usize].push(k as u32); + } + j_kinds.push(JumpKind::HermitianPauli { + word, + rate: jump.rate * c.re * c.re, + }); + continue; + } + + // General path: parse all terms, precompute L†L, record union support. + let mut terms: Vec> = Vec::with_capacity(jump.lincomb.len()); + let mut union_support: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for (s, c) in &jump.lincomb { + let (word, support) = parse_pauli_string(s, n_qubits)?; + for q in &support { + union_support.insert(*q); + } + terms.push(PauliTerm { word, coeff: *c }); + } + for q in union_support { + j_support_idx[q as usize].push(k as u32); + } + let dagger_dagger = precompute_adag_b(&terms, &terms); + j_kinds.push(JumpKind::General { + terms, + dagger_dagger, + rate: jump.rate, + }); + } + + Ok(Self { + n_qubits, + h_terms: h_parsed, + j_kinds, + h_support: h_support_idx, + j_support: j_support_idx, + }) + } + + /// Add a Kossakowski-form dissipator + /// `D*(O) = Σ_{n,m} K_nm ( A_n† O A_m − ½ {A_n† A_m, O} )`. + /// + /// `ops` lists the operators `A_n` as complex Pauli linear combinations; + /// `k` is the `n_ops × n_ops` Hermitian pair matrix. Contributions are + /// added to any jumps already present. See [`crate::kossakowski`] for + /// how each `(n, m)` entry is compiled. + pub fn add_kossakowski( + &mut self, + ops: &[Vec<(String, Complex)>], + k: &[Vec>], + ) -> Result<(), Error> { + for (pair, support) in kossakowski::compile(ops, k, self.n_qubits)? { + let idx = self.j_kinds.len() as u32; + self.j_kinds.push(JumpKind::Kossakowski(pair)); + for q in support { + self.j_support[q as usize].push(idx); + } + } + Ok(()) + } + + pub fn n_qubits(&self) -> usize { + self.n_qubits + } + + pub fn num_h_terms(&self) -> usize { + self.h_terms.len() + } + + pub fn num_jump_terms(&self) -> usize { + self.j_kinds.len() + } + + /// Apply `L*` to a single Pauli string `p`. Returns the output Pauli + /// strings and their real coefficients (zero entries omitted). + pub fn action(&self, p: &Word) -> Vec<(Word, f64)> { + let mut out: FxHashMap, f64> = FxHashMap::default(); + let mut s1 = Vec::new(); + let mut s2 = Vec::new(); + self.accumulate_action(p, 1.0, &mut out, &mut s1, &mut s2); + out.into_iter().filter(|(_, c)| *c != 0.0).collect() + } + + /// Compute the unscaled list of `(output, coefficient)` pairs that + /// `L*(p)` contributes (without the input coefficient). + pub(crate) fn compute_action_terms( + &self, + p: &Word, + scratch_support: &mut Vec, + scratch_cands: &mut Vec, + scratch_local: &mut FxHashMap, Complex>, + ) -> Vec<(Word, f64)> { + word_support(p, scratch_support); + let zero = Complex::new(0.0, 0.0); + scratch_local.clear(); + let local = scratch_local; + + // ── i [H, p] ───────────────────────────────────────────────── + candidate_terms(scratch_support, &self.h_support, scratch_cands); + for &i in scratch_cands.iter() { + let h = &self.h_terms[i as usize]; + let (r, eps) = comm_product(&h.word, p); + if eps != 0.0 { + *local.entry(r).or_insert(zero) += Complex::new(h.coeff * eps, 0.0); + } + } + + // ── dissipator ─────────────────────────────────────────────── + candidate_terms(scratch_support, &self.j_support, scratch_cands); + for &k in scratch_cands.iter() { + match &self.j_kinds[k as usize] { + JumpKind::HermitianPauli { word, rate } => { + if anti_commutes(word, p) { + *local.entry(*p).or_insert(zero) += Complex::new(-2.0 * *rate, 0.0); + } + } + JumpKind::General { + terms, + dagger_dagger, + rate, + } => { + let rate_c = Complex::new(*rate, 0.0); + // Sandwich: γ Σ_{a,b} λ_a* λ_b P_a p P_b. + for a in terms { + let (r_ap, phi1) = pauli_mul(&a.word, p); + for b in terms { + let (s, phi2) = pauli_mul(&r_ap, &b.word); + let coeff = + a.coeff.conj() * b.coeff * phase_factor(phi1 + phi2) * rate_c; + *local.entry(s).or_insert(zero) += coeff; + } + } + // -1/2 γ {L†L, p}. For Hermitian Pauli P_c and Pauli p, + // {P_c, p} = 2·sign·R if they commute (P_c·p = sign·R), + // = 0 if they anti-commute. + for c_term in dagger_dagger { + let (r, phase) = pauli_mul(&c_term.word, p); + if phase & 1 == 0 { + let sign = if phase == 0 { 1.0 } else { -1.0 }; + let coeff = -c_term.coeff * rate_c * Complex::new(sign, 0.0); + *local.entry(r).or_insert(zero) += coeff; + } + } + } + JumpKind::Kossakowski(pair) => pair.accumulate(p, local), + } + } + + // L* preserves Hermiticity; imaginary parts must cancel to FP noise. + // `drain()` empties `scratch_local` so its allocation can be reused + // by the next call on the same thread (`Vec` keeps capacity). + local + .drain() + .filter_map(|(w, c)| { + debug_assert!( + c.im.abs() < 1e-9, + "L*(p) produced non-real coefficient {c}; bug in dissipator" + ); + if c.re == 0.0 { None } else { Some((w, c.re)) } + }) + .collect() + } + + /// Accumulate `scale · L*(p)` into `out`. + fn accumulate_action( + &self, + p: &Word, + scale: f64, + out: &mut FxHashMap, f64>, + scratch_support: &mut Vec, + scratch_cands: &mut Vec, + ) { + let mut scratch_local = FxHashMap::default(); + let terms = + self.compute_action_terms(p, scratch_support, scratch_cands, &mut scratch_local); + for (w, c) in terms.iter() { + *out.entry(*w).or_insert(0.0) += scale * c; + } + } +} diff --git a/crates/ppvm-lindblad/src/step.rs b/crates/ppvm-lindblad/src/step.rs new file mode 100644 index 000000000..debd9666f --- /dev/null +++ b/crates/ppvm-lindblad/src/step.rs @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Predictor-corrector adaptive step `O ← exp(dt·L*) O`. + +use crate::sector::Sector; +use crate::spec::LindbladSpec; +use crate::truncate::{add_leakage_capped, cap_basis, prune_basis}; +use crate::word::Word; +use crate::{Error, PcStepConfig, mf_expm}; +use num::Complex; +use std::time::Instant; + +/// Per-phase timing breakdown (microseconds) returned by +/// [`LindbladSpec::pc_step_timed`]. +#[derive(Default, Clone, Copy, Debug)] +pub struct PcStepTimings { + pub leakage1_us: u64, + pub expand1_us: u64, + pub expm1_us: u64, + pub leakage2_us: u64, + pub expand2_us: u64, + pub expm2_us: u64, +} + +impl PcStepTimings { + pub fn total_us(&self) -> u64 { + self.leakage1_us + + self.expand1_us + + self.expm1_us + + self.leakage2_us + + self.expand2_us + + self.expm2_us + } +} + +/// Clock for one `pc_step` phase. Disarmed (`None`) on the untimed path, so +/// [`LindbladSpec::pc_step`] pays no `Instant` syscalls. +struct Phase(Option); + +impl Phase { + fn start(timed: bool) -> Self { + Self(timed.then(Instant::now)) + } + + fn stop(self, slot: &mut u64) { + if let Some(t0) = self.0 { + *slot = t0.elapsed().as_micros() as u64; + } + } +} + +impl LindbladSpec { + /// One predictor-corrector step `O ← exp(dt·L*) O` in the adaptive + /// real-coefficient Pauli basis: first-hop leakage admission, predictor + /// exponential, second-hop admission from the predicted state, corrector + /// exponential from the saved pre-step state, then truncation (prune + + /// rank cap) per [`PcStepConfig`]. Exact in `dt` within the working + /// basis — the only error is basis truncation. + /// + /// When the second hop admits no string (in particular when the first + /// hop already filled the admission room `admit_basis − |basis|`, the + /// usual case once the basis has reached `max_basis`), the corrector + /// would repeat the predictor exactly; the second leakage pass (if + /// `room = 0`) and the corrector exponential are then skipped, with + /// bit-identical results. The step's timings report 0 for skipped + /// phases. + /// + /// `protected` words are never dropped. All tuning knobs live in `cfg`. + pub fn pc_step( + &self, + basis: &mut Vec>, + coeffs: &mut Vec, + dt: f64, + protected: &[Word], + cfg: &PcStepConfig, + ) -> Result<(), Error> { + self.run_in_pool(cfg, |this| { + this.pc_step_inner(basis, coeffs, dt, protected, cfg, false) + .map(|_| ()) + }) + } + + /// Same as [`Self::pc_step`] but also returns a per-phase timing + /// breakdown (microseconds), for profiling parallel scaling and hot + /// spots. + pub fn pc_step_timed( + &self, + basis: &mut Vec>, + coeffs: &mut Vec, + dt: f64, + protected: &[Word], + cfg: &PcStepConfig, + ) -> Result { + self.run_in_pool(cfg, |this| { + this.pc_step_inner(basis, coeffs, dt, protected, cfg, true) + }) + } + + fn run_in_pool( + &self, + cfg: &PcStepConfig, + f: impl FnOnce(&Self) -> Result + Send, + ) -> Result { + if let Some(n) = cfg.num_threads { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n) + .build() + .map_err(|e| Error::Internal(format!("rayon pool build: {e}")))?; + pool.install(|| f(self)) + } else { + f(self) + } + } + + fn pc_step_inner( + &self, + basis: &mut Vec>, + coeffs: &mut Vec, + dt: f64, + protected: &[Word], + cfg: &PcStepConfig, + timed: bool, + ) -> Result { + let PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + .. + } = *cfg; + // Admission bound: enrichment may grow the live basis to `admit` + // >= `max_basis`; the final `cap_basis` then keeps the top- + // `max_basis` strings by evolved |coeff| over the whole union + // (retained + admitted) — rank displacement. With `admit_basis = + // None` admission is bounded by `max_basis` itself, `cap_basis` is + // a no-op, and membership turnover requires `drop_tol > 0`. + let admit = admit_basis.unwrap_or(max_basis).max(max_basis); + let tau_add = tau_add.unwrap_or(0.0); + let mut t = PcStepTimings::default(); + + // 1. First-hop expansion. After this, `coeffs` contains the pre-step + // coefficients followed by zeros for the newly-added leakage strings. + // We rely on `coeffs` itself as the pre-step buffer for the corrector + // — no `.clone()` is needed because `expm_step` only borrows it. + let p = Phase::start(timed); + let leak = self.leakage_with_prune(basis, coeffs, protected, admit, tau_add)?; + p.stop(&mut t.leakage1_us); + + let p = Phase::start(timed); + add_leakage_capped(basis, coeffs, leak, admit); + p.stop(&mut t.expand1_us); + + // 2. Predictor: `expm_step` reads `coeffs` immutably and returns a + // new owned vector with the predicted state. + let p = Phase::start(timed); + let coeffs_predict = self.expm_step(basis, dt, coeffs, drop_tol); + p.stop(&mut t.expm1_us); + + // 3. Second-hop expansion from the predicted state. Extend `coeffs` + // with zeros for any newly-added second-hop strings so it remains a + // valid input (pre-step state) for the corrector. Once the basis is + // full, the first hop usually fills the whole admission room; with + // `room = 0` the second hop can admit nothing, so its leakage pass + // is skipped. + let n_predict = basis.len(); + if admit > n_predict { + let p = Phase::start(timed); + let leak2 = + self.leakage_with_prune(basis, &coeffs_predict, protected, admit, tau_add)?; + p.stop(&mut t.leakage2_us); + + let p = Phase::start(timed); + add_leakage_capped(basis, coeffs, leak2, admit); + p.stop(&mut t.expand2_us); + } + + // 4. Corrector: redo from pre-step state on the doubly-enlarged basis. + // If the second hop admitted nothing, the corrector would repeat the + // predictor's computation exactly, so the predicted state is kept. + if basis.len() == n_predict { + *coeffs = coeffs_predict; + } else { + drop(coeffs_predict); + let p = Phase::start(timed); + *coeffs = self.expm_step(basis, dt, coeffs, drop_tol); + p.stop(&mut t.expm2_us); + } + + // 5. Prune basis entries below `drop_tol` (protected words never dropped). + prune_basis(basis, coeffs, drop_tol, protected); + cap_basis(basis, coeffs, max_basis, protected); + Ok(t) + } + + /// Compute `exp(dt · M) · b` for the in-basis-restricted generator + /// `M`, matrix-free, via `quspin-expm` (see [`crate::mf_expm`]). + fn expm_step(&self, basis: &[Word], dt: f64, b: &[f64], drop_tol: f64) -> Vec { + mf_expm::expm_apply_mf(self, basis, dt, b, drop_tol) + } + + /// One predictor-corrector step in **orbit-rep form** at momentum + /// `sector`: the same five phases as [`Self::pc_step`], but the basis + /// holds only canonical translation-orbit representatives, the + /// coefficients are complex, and the `L*` action is phase-aware (see + /// [`crate::sector`]). The basis stays ~`|G|`× smaller than the + /// equivalent full-basis complex evolution, every step. + /// + /// `max_basis` is a hard rank cap on the live orbit-rep basis: + /// enrichment adds at most `admit − basis.len()` of the largest + /// leakage reps, the leakage map is capped to the same room, and the + /// post-step basis is trimmed to the top-`max_basis` reps by `|c|`. + /// Pass a large value (e.g. `usize::MAX`) for the near-exact, + /// uncapped case. `drop_tol` additionally prunes by magnitude. + /// `protected` reps are never dropped. + /// + /// `basis` is assumed to contain only canonical orbit + /// representatives. If not, call + /// [`canonicalize_basis_to_rep`](crate::canonicalize_basis_to_rep) + /// first. + /// + /// Honours `cfg.num_threads` the same way [`Self::pc_step`] does. + pub fn pc_step_orbit_rep( + &self, + basis: &mut Vec>, + coeffs: &mut Vec>, + dt: f64, + protected: &[Word], + sector: &Sector<'_>, + cfg: &PcStepConfig, + ) -> Result<(), Error> { + self.run_in_pool(cfg, |this| { + this.pc_step_orbit_rep_inner(basis, coeffs, dt, protected, sector, cfg) + }) + } + + fn pc_step_orbit_rep_inner( + &self, + basis: &mut Vec>, + coeffs: &mut Vec>, + dt: f64, + protected: &[Word], + sector: &Sector<'_>, + cfg: &PcStepConfig, + ) -> Result<(), Error> { + let PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + .. + } = *cfg; + // Admission bound, mirroring `pc_step_inner`: enrichment may grow + // the live basis to `admit` >= `max_basis`; the final `cap_basis` + // keeps the top-`max_basis` reps by evolved |coeff| over the whole + // union (rank displacement). With `admit_basis = None` admission is + // bounded by `max_basis` itself and membership turnover requires + // `drop_tol > 0`. + let admit = admit_basis.unwrap_or(max_basis).max(max_basis); + let tau_add = tau_add.unwrap_or(0.0); + + // 1. First-hop phase-aware leakage. + let mut leak = self.leakage_orbit_rep(basis, coeffs, protected, sector, admit)?; + if tau_add > 0.0 { + leak.retain(|(_, c)| c.norm() > tau_add); + } + add_leakage_capped(basis, coeffs, leak, admit); + + // 2. Predictor: the phase-aware action is built once and reused + // across every matvec. + let coeffs_predict = mf_expm::expm_apply_orbit_rep(self, basis, sector, dt, coeffs); + + // 3. Second-hop leakage from the predicted state, skipped when the + // first hop left no admission room (see `pc_step_inner`). + let n_predict = basis.len(); + if admit > n_predict { + let mut leak2 = + self.leakage_orbit_rep(basis, &coeffs_predict, protected, sector, admit)?; + if tau_add > 0.0 { + leak2.retain(|(_, c)| c.norm() > tau_add); + } + add_leakage_capped(basis, coeffs, leak2, admit); + } + + // 4. Corrector: redo from the pre-step state if the basis grew; + // otherwise it would reproduce the predictor exactly. + if basis.len() == n_predict { + *coeffs = coeffs_predict; + } else { + drop(coeffs_predict); + *coeffs = mf_expm::expm_apply_orbit_rep(self, basis, sector, dt, coeffs); + } + + // 5. Prune by magnitude, then rank-cap to max_basis. + prune_basis(basis, coeffs, drop_tol, protected); + cap_basis(basis, coeffs, max_basis, protected); + Ok(()) + } +} diff --git a/crates/ppvm-lindblad/src/tests.rs b/crates/ppvm-lindblad/src/tests.rs new file mode 100644 index 000000000..8d4651ef0 --- /dev/null +++ b/crates/ppvm-lindblad/src/tests.rs @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use fxhash::FxHashMap; +use num::Complex; + +/// Test-only full-space complex predictor-corrector step, UNTRUNCATED: +/// adds every nonzero leakage string (two hops) and applies the exact +/// in-basis exponential to the complex coefficient vector. Reference +/// bridge between the real `pc_step` and the orbit-rep path. +fn pc_step_complex_full( + spec: &LindbladSpec, + basis: &mut Vec, + coeffs: &mut Vec>, + dt: f64, +) { + let protected: Vec = Vec::new(); + let leak = spec.leakage_complex(basis, coeffs, &protected).unwrap(); + for (w, v) in leak { + if v.norm() > 0.0 { + basis.push(w); + coeffs.push(Complex::new(0.0, 0.0)); + } + } + let predict = mf_expm::expm_apply_mf_cxvec(spec, basis, dt, coeffs, 0.0); + let leak2 = spec.leakage_complex(basis, &predict, &protected).unwrap(); + drop(predict); + for (w, v) in leak2 { + if v.norm() > 0.0 { + basis.push(w); + coeffs.push(Complex::new(0.0, 0.0)); + } + } + *coeffs = mf_expm::expm_apply_mf_cxvec(spec, basis, dt, coeffs, 0.0); +} + +fn jump_hpauli(s: &str, rate: f64) -> JumpInput { + JumpInput { + lincomb: vec![(s.to_string(), Complex::new(1.0, 0.0))], + rate, + } +} + +#[test] +fn z_dephasing_action_on_x() { + // L = Z on a single qubit; L*(X) = γ(ZXZ - X) = γ(-X - X) = -2γ X. + let spec = ::new( + 1, + &[("X".to_string(), 0.0)], // no Hamiltonian + &[jump_hpauli("Z", 0.5)], + ) + .unwrap(); + let (x, _) = parse_pauli_string::("X", 1).unwrap(); + let terms = spec.action(&x); + assert_eq!(terms.len(), 1); + assert!((terms[0].1 - (-1.0)).abs() < 1e-12); // -2·0.5 = -1 +} + +#[test] +fn amplitude_damping_action_on_z() { + // Single-qubit σ⁻ jump: L*(Z) = -γ(I + Z). With γ=1 we expect + // I coefficient = -1, Z coefficient = -1. + let sigma_minus = JumpInput { + lincomb: vec![ + ("X".to_string(), Complex::new(0.5, 0.0)), + ("Y".to_string(), Complex::new(0.0, -0.5)), + ], + rate: 1.0, + }; + let spec = ::new(1, &[], &[sigma_minus]).unwrap(); + let (z, _) = parse_pauli_string::("Z", 1).unwrap(); + let terms = spec.action(&z); + let (i_word, _) = parse_pauli_string::("I", 1).unwrap(); + let mut i_coeff = 0.0; + let mut z_coeff = 0.0; + for (w, c) in &terms { + if w == &i_word { + i_coeff = *c; + } else if w == &z { + z_coeff = *c; + } + } + assert!((i_coeff - (-1.0)).abs() < 1e-10, "I coeff = {i_coeff}"); + assert!((z_coeff - (-1.0)).abs() < 1e-10, "Z coeff = {z_coeff}"); +} + +#[test] +fn word_codec_roundtrip() { + let codes = [0u8, 1, 2, 3, 1, 0, 3, 2]; + let w = word_from_codes::(&codes).unwrap(); + let mut out = vec![0u8; codes.len()]; + codes_from_word(&w, &mut out); + assert_eq!(out.as_slice(), &codes); +} + +/// Translation-invariant XY chain with PBC on `n` sites, no dissipation. +fn xy_chain_pbc(n: usize) -> Vec<(String, f64)> { + let mut h_terms: Vec<(String, f64)> = Vec::new(); + for j in 0..n { + let nxt = (j + 1) % n; + for op in ['X', 'Y'] { + let mut s = vec!['I'; n]; + s[j] = op; + s[nxt] = op; + h_terms.push((s.into_iter().collect(), 1.0)); + } + } + h_terms +} + +/// Per-step orbit-rep evolution must give the SAME final orbit-rep state +/// as full-basis complex evolution followed by a single projection at the +/// end (the projection theorem), for a `seed` that is a momentum-`k` +/// eigenstate in full-basis form. +/// +/// Both sides run untruncated: `pc_step_complex_full` admits every +/// leakage string, and the orbit-rep side gets a huge `max_basis`, so the +/// only remaining difference would be a bug in the phase-aware action. +fn assert_orbit_rep_matches_projection( + n: usize, + h_terms: &[(String, f64)], + seed: &[(&str, Complex)], + k: &[i32], + dt: f64, + n_steps: usize, +) { + use ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex; + + let spec = ::new(n, h_terms, &[]).unwrap(); + let group = ppvm_pauli_sum::symmetry::TranslationGroup::chain_1d(n); + let basis_full: Vec = seed + .iter() + .map(|(s, _)| parse_pauli_string::(s, n).unwrap().0) + .collect(); + let coeffs_full: Vec> = seed.iter().map(|(_, c)| *c).collect(); + + // ----- Full-basis path, projected once at the end ----- + let mut bf = basis_full.clone(); + let mut cf = coeffs_full.clone(); + let protected: Vec = Vec::new(); + for _ in 0..n_steps { + pc_step_complex_full(&spec, &mut bf, &mut cf, dt); + } + canonicalize_pauli_sum_complex(&mut bf, &mut cf, &group, k); + + // ----- Orbit-rep path: project the seed, then evolve in rep form ----- + let mut br = basis_full.clone(); + let mut cr = coeffs_full.clone(); + canonicalize_pauli_sum_complex(&mut br, &mut cr, &group, k); + let sector = Sector::new(&group, k); + for _ in 0..n_steps { + spec.pc_step_orbit_rep( + &mut br, + &mut cr, + dt, + &protected, + §or, + &PcStepConfig { + max_basis: 10_000_000, + ..Default::default() + }, + ) + .unwrap(); + } + + let mf: FxHashMap> = bf.into_iter().zip(cf).collect(); + let mr: FxHashMap> = br.into_iter().zip(cr).collect(); + assert_eq!( + mf.len(), + mr.len(), + "orbit-rep ({}) and full-basis-projected ({}) basis sizes differ", + mr.len(), + mf.len() + ); + let mut max_diff = 0.0_f64; + for (w, cm) in &mr { + let cf_val = mf + .get(w) + .copied() + .unwrap_or_else(|| panic!("rep {w} in orbit-rep but not in full-basis")); + max_diff = max_diff.max((cm - cf_val).norm()); + } + assert!( + max_diff < 1e-9, + "orbit-rep diverged from full-basis: max |Δc| = {max_diff:e}" + ); +} + +/// Validates the phase-aware complex action against the full-basis +/// reference on a `k=1` seed whose orbits are all free. +#[test] +fn pc_step_orbit_rep_matches_full_basis_projection() { + use std::f64::consts::PI; + + let n = 4usize; + let k_mode = 1i32; + // `O_k = Σ_a e^{-2πi k a / n} Z_a`, a k=1 momentum eigenstate. + let words: Vec = (0..n) + .map(|j| { + let mut s = vec!['I'; n]; + s[j] = 'Z'; + s.into_iter().collect() + }) + .collect(); + let seed: Vec<(&str, Complex)> = words + .iter() + .enumerate() + .map(|(a, s)| { + let phase = -2.0 * PI * (k_mode as f64) * (a as f64) / (n as f64); + (s.as_str(), Complex::from_polar(1.0, phase)) + }) + .collect(); + + assert_orbit_rep_matches_projection(n, &xy_chain_pbc(n), &seed, &[k_mode], 0.01, 3); +} + +/// Same projection-theorem check on a seed living on a **stabilized** +/// orbit: `ZIZI + IZIZ` has period 2 on a 4-site chain, so its orbit has +/// 2 distinct members, not 4. +/// +/// Regression test: the phase-aware action is the orbit-rep generator in +/// the *summing* convention, and converting it to the *averaged* +/// convention that `canonicalize_pauli_sum_complex` uses takes a per- +/// orbit-pair `|orbit_in| / |orbit_out|` factor — which is 1 only when +/// both orbits are free. Without that factor this evolves `ZIZI + IZIZ` +/// with every coefficient off by exactly 2. +#[test] +fn pc_step_orbit_rep_handles_stabilized_orbits() { + let n = 4usize; + let seed = [ + ("ZIZI", Complex::new(1.0, 0.0)), + ("IZIZ", Complex::new(1.0, 0.0)), + ]; + assert_orbit_rep_matches_projection(n, &xy_chain_pbc(n), &seed, &[0], 0.01, 3); +} + +/// The full-space complex step at momentum k=0 must reproduce the real +/// pc_step on the same trajectory exactly. +#[test] +fn complex_full_matches_real_at_kzero() { + let n = 4usize; + let dt = 0.01f64; + let n_steps = 5usize; + let mut h_terms: Vec<(String, f64)> = Vec::new(); + for j in 0..n { + let nxt = (j + 1) % n; + for op in ["X", "Y"] { + let mut s = vec!['I'; n]; + s[j] = op.chars().next().unwrap(); + s[nxt] = op.chars().next().unwrap(); + h_terms.push((s.into_iter().collect(), 1.0)); + } + } + let spec = ::new(n, &h_terms, &[]).unwrap(); + + let mut basis_r: Vec = (0..n) + .map(|j| { + let mut s = vec!['I'; n]; + s[j] = 'Z'; + let st: String = s.into_iter().collect(); + let (w, _) = parse_pauli_string::(&st, n).unwrap(); + w + }) + .collect(); + let mut coeffs_r: Vec = vec![1.0; n]; + + let mut basis_c = basis_r.clone(); + let mut coeffs_c: Vec> = coeffs_r.iter().map(|&v| Complex::new(v, 0.0)).collect(); + + let protected: Vec = Vec::new(); + for _ in 0..n_steps { + // Large max_basis: rank cap never binds, so the real path + // enriches fully (adds every leakage string). Match the + // complex path by setting its tau_add=0.0 (also full + // enrichment) so the two stay in lock-step at k=0. + spec.pc_step( + &mut basis_r, + &mut coeffs_r, + dt, + &protected, + &PcStepConfig { + max_basis: 10_000_000, + ..Default::default() + }, + ) + .unwrap(); + pc_step_complex_full(&spec, &mut basis_c, &mut coeffs_c, dt); + } + // Match as (word → coeff) maps. + let map_r: FxHashMap = basis_r.into_iter().zip(coeffs_r).collect(); + let map_c: FxHashMap> = basis_c.into_iter().zip(coeffs_c).collect(); + assert_eq!( + map_r.len(), + map_c.len(), + "real and complex pc_step produced different basis sizes ({} vs {})", + map_r.len(), + map_c.len() + ); + let mut max_diff = 0.0_f64; + for (w, cr) in &map_r { + let cc = map_c + .get(w) + .copied() + .unwrap_or_else(|| panic!("word {:?} in real but not complex", w)); + assert!(cc.im.abs() < 1e-10, "expected zero imag at k=0, got {cc:?}"); + max_diff = max_diff.max((cr - cc.re).abs()); + } + assert!( + max_diff < 1e-10, + "real vs complex pc_step diverged: max |Δc| = {max_diff:e}" + ); +} + +/// Small-system end-to-end check that orbit-rep merging gives the +/// same physics as standard evolution, when no truncation is applied. +/// +/// Setup: n=4 qubit chain, PBC, translation-invariant XY Hamiltonian +/// `H = Σ_j (X_j X_{j+1} + Y_j Y_{j+1})`, no dissipation. Initial +/// operator `O(0) = Σ_j Z_j` is translation-invariant (k=0 sector). +/// +/// Run 10 pc_step iterations with `drop_tol = 0` (no truncation): +/// once without merging, once applying `canonicalize_pauli_sum` +/// after each step. Canonicalize the un-merged final state once at +/// the end. The two orbit-rep representations should be +/// bit-identical up to FP noise. +#[test] +fn pc_step_matches_symmetry_merged_on_small_chain() { + use ppvm_pauli_sum::symmetry::{TranslationGroup, canonicalize_pauli_sum}; + + let n = 4usize; + let dt = 0.05f64; + let n_steps = 10usize; + + // Build XY-chain Hamiltonian with PBC. 8 terms (4 bonds × {XX, YY}). + let mut h_terms: Vec<(String, f64)> = Vec::new(); + for j in 0..n { + let nxt = (j + 1) % n; + for op in ["X", "Y"] { + let mut s = vec!['I'; n]; + s[j] = op.chars().next().unwrap(); + s[nxt] = op.chars().next().unwrap(); + h_terms.push((s.into_iter().collect(), 1.0)); + } + } + // No dissipation. + let spec = ::new(n, &h_terms, &[]).unwrap(); + let group = TranslationGroup::chain_1d(n); + + // Initial: O(0) = Σ_j Z_j (translation-invariant). + let mut basis_u: Vec = (0..n) + .map(|j| { + let mut s = vec!['I'; n]; + s[j] = 'Z'; + let st: String = s.into_iter().collect(); + let (w, _) = parse_pauli_string::(&st, n).unwrap(); + w + }) + .collect(); + let mut coeffs_u: Vec = vec![1.0; n]; + + // Mirror state for the "with merging" run. + let mut basis_m = basis_u.clone(); + let mut coeffs_m = coeffs_u.clone(); + + let protected: Vec = Vec::new(); + for _ in 0..n_steps { + // max_basis == current basis size → room = 0: no leakage + // enrichment, only the expm step (the regime where merging + // commutes with evolution). drop_tol = 0 → no truncation. + let cfg_u = PcStepConfig { + max_basis: basis_u.len(), + ..Default::default() + }; + spec.pc_step(&mut basis_u, &mut coeffs_u, dt, &protected, &cfg_u) + .unwrap(); + + let cfg_m = PcStepConfig { + max_basis: basis_m.len(), + ..Default::default() + }; + spec.pc_step(&mut basis_m, &mut coeffs_m, dt, &protected, &cfg_m) + .unwrap(); + // Apply symmetry merging on the "with merging" run only. + canonicalize_pauli_sum(&mut basis_m, &mut coeffs_m, &group); + } + + // Canonicalize the un-merged final state once. + canonicalize_pauli_sum(&mut basis_u, &mut coeffs_u, &group); + + // Both representations should now be in orbit-rep form; compare + // as (word → coeff) maps with FP tolerance. + let map_u: FxHashMap = basis_u.into_iter().zip(coeffs_u).collect(); + let map_m: FxHashMap = basis_m.into_iter().zip(coeffs_m).collect(); + assert_eq!( + map_u.len(), + map_m.len(), + "merged basis size {} != post-merged-unmerged basis size {}", + map_m.len(), + map_u.len() + ); + let mut max_diff = 0.0f64; + for (w, c_u) in &map_u { + let c_m = map_m.get(w).copied().unwrap_or_else(|| { + panic!( + "rep {:?} present in un-merged-then-canonicalized but not in merged", + w + ); + }); + max_diff = max_diff.max((c_u - c_m).abs()); + } + assert!( + max_diff < 1e-9, + "with-merging vs without-merging diverged: max |Δc| = {max_diff:e}" + ); +} diff --git a/crates/ppvm-lindblad/src/truncate.rs b/crates/ppvm-lindblad/src/truncate.rs new file mode 100644 index 000000000..1fe7a9fb3 --- /dev/null +++ b/crates/ppvm-lindblad/src/truncate.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Basis truncation and enrichment: the magnitude prune, the rank cap, +//! and the capped leakage admission shared by every `pc_step` variant. +//! +//! All three are generic over the coefficient scalar ([`Coeff`]), so the +//! real adaptive path and the complex orbit-rep path run the same code. + +use crate::scalar::Coeff; +use crate::word::Word; +use fxhash::{FxHashMap, FxHashSet}; + +/// Basis indices in descending coefficient magnitude. Leakage +/// accumulation walks the basis in this order so the running room-cap +/// keeps the entries most likely to be the true largest contributors. +pub(crate) fn order_by_desc_mag(coeffs: &[T]) -> Vec { + let mut order: Vec = (0..coeffs.len()).collect(); + order.sort_by(|&a, &b| desc_by_mag(coeffs[a], coeffs[b])); + order +} + +/// Keep only the `room` largest-magnitude entries of a live leakage +/// candidate map — `room` being the number of strings we could actually +/// admit to the basis, so there is no point tracking more. Applied after +/// each accumulation chunk. +pub(crate) fn cap_map_to_room( + merged: &mut FxHashMap, T>, + room: usize, +) { + if merged.len() <= room { + return; + } + if room == 0 { + merged.clear(); + return; + } + let mut mags: Vec = merged.values().map(|v| v.mag()).collect(); + let k = room.min(mags.len() - 1); + let cutoff = nth_largest(&mut mags, k); + merged.retain(|_, v| v.mag() >= cutoff); +} + +/// Compact `basis` / `coeffs` in place: drop entries whose coefficient +/// magnitude is below `drop_tol` unless the word appears in `protected`. +/// No-op when `drop_tol ≤ 0`. +pub(crate) fn prune_basis( + basis: &mut Vec>, + coeffs: &mut Vec, + drop_tol: f64, + protected: &[Word], +) { + if drop_tol <= 0.0 { + return; + } + debug_assert_eq!(basis.len(), coeffs.len()); + let protected_set: FxHashSet<&Word> = protected.iter().collect(); + retain_in_place(basis, coeffs, |w, c| { + c.mag() >= drop_tol || protected_set.contains(w) + }); +} + +/// Global max-basis cap (PauliStrings.jl-style top-M trim): keep only the +/// `max_basis` largest-magnitude terms (protected strings always kept), +/// dropping the rest. Rank-based total-basis bound; dual of `drop_tol`. +/// A `max_basis` large enough to cover the whole basis is a no-op. +pub(crate) fn cap_basis( + basis: &mut Vec>, + coeffs: &mut Vec, + max_basis: usize, + protected: &[Word], +) { + if basis.len() <= max_basis { + return; + } + let protected_set: FxHashSet<&Word> = protected.iter().collect(); + let n_prot = basis.iter().filter(|w| protected_set.contains(w)).count(); + let slots = max_basis.saturating_sub(n_prot); + let mut mags: Vec = basis + .iter() + .zip(coeffs.iter()) + .filter(|(w, _)| !protected_set.contains(w)) + .map(|(_, c)| c.mag()) + .collect(); + let cutoff = if slots == 0 { + f64::INFINITY + } else if slots >= mags.len() { + return; + } else { + nth_largest(&mut mags, slots - 1) + }; + retain_in_place(basis, coeffs, |w, c| { + protected_set.contains(w) || c.mag() >= cutoff + }); +} + +/// Add the largest leakage strings to the basis, up to the available room +/// `room = max_basis − basis.len()` — so the in-step basis (hence the +/// expm/leakage peak memory) never exceeds `max_basis`. New strings get +/// coefficient 0; the surrounding expm fills them. No magnitude filter: the +/// top-`room` by `|leakage|` are added (a large `max_basis` adds them all). +pub(crate) fn add_leakage_capped( + basis: &mut Vec>, + coeffs: &mut Vec, + mut leak: Vec<(Word, T)>, + max_basis: usize, +) { + let room = max_basis.saturating_sub(basis.len()); + if leak.len() > room { + if room > 0 { + leak.select_nth_unstable_by(room - 1, |a, b| desc_by_mag(a.1, b.1)); + } + leak.truncate(room); + } + for (w, _) in leak { + basis.push(w); + coeffs.push(T::zero()); + } +} + +/// Keep the `basis`/`coeffs` entries satisfying `keep`, preserving order, +/// by swapping survivors down and truncating. +fn retain_in_place( + basis: &mut Vec>, + coeffs: &mut Vec, + mut keep: impl FnMut(&Word, &T) -> bool, +) { + let mut write = 0; + for read in 0..basis.len() { + if keep(&basis[read], &coeffs[read]) { + if write != read { + basis.swap(write, read); + coeffs.swap(write, read); + } + write += 1; + } + } + basis.truncate(write); + coeffs.truncate(write); +} + +/// The `k`-th largest element of `mags` (0-indexed), via a partial sort. +/// Reorders `mags`. Panics if `k >= mags.len()`. +fn nth_largest(mags: &mut [f64], k: usize) -> f64 { + mags.select_nth_unstable_by(k, |a, b| { + b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) + }); + mags[k] +} + +/// Descending comparison by magnitude, NaN-tolerant. +fn desc_by_mag(a: T, b: T) -> std::cmp::Ordering { + b.mag() + .partial_cmp(&a.mag()) + .unwrap_or(std::cmp::Ordering::Equal) +} diff --git a/crates/ppvm-lindblad/src/word.rs b/crates/ppvm-lindblad/src/word.rs new file mode 100644 index 000000000..d79e9fe57 --- /dev/null +++ b/crates/ppvm-lindblad/src/word.rs @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Packed Pauli-word type and the string / `u8`-label codecs. + +use crate::Error; +use fxhash::FxBuildHasher; +use ppvm_pauli_word::word::PauliWord; +use ppvm_traits::PauliWordTrait; + +/// Pauli-word storage chunk: `u64` on 64-bit targets, `u32` elsewhere +/// (`bitvec` implements `BitStore` for `u64` only on 64-bit targets, so +/// e.g. wasm32 builds use four 32-bit chunks instead of two 64-bit ones). +#[cfg(target_pointer_width = "64")] +pub(crate) type Chunk = u64; +#[cfg(not(target_pointer_width = "64"))] +pub(crate) type Chunk = u32; + +/// Bits per storage chunk. +pub const CHUNK_BITS: usize = Chunk::BITS as usize; + +/// Chunks for a 128-qubit word: the default width, and the only one +/// instantiated for registers of at most 128 qubits. +pub const W_CHUNKS: usize = 128 / CHUNK_BITS; + +/// Chunk counts of the word widths the Python layer instantiates, narrowest +/// first: 128, 256 and 512 qubits. [`chunks_for`] picks among them. +pub const WIDTHS: [usize; 3] = [128 / CHUNK_BITS, 256 / CHUNK_BITS, 512 / CHUNK_BITS]; + +/// Largest register any instantiated width supports. +pub const MAX_SUPPORTED_QUBITS: usize = 512; + +/// Maximum number of qubits of the default-width [`Word`] (128). +pub const MAX_QUBITS: usize = max_qubits::(); + +/// Capacity of a `C`-chunk [`Word`], in qubits. +pub const fn max_qubits() -> usize { + C * CHUNK_BITS +} + +/// The narrowest entry of [`WIDTHS`] that holds `n_qubits`, or `None` above +/// [`MAX_SUPPORTED_QUBITS`]. +pub const fn chunks_for(n_qubits: usize) -> Option { + let mut i = 0; + while i < WIDTHS.len() { + if n_qubits <= WIDTHS[i] * CHUNK_BITS { + return Some(WIDTHS[i]); + } + i += 1; + } + None +} + +/// The Pauli-word storage type used throughout this crate, `C` chunks wide. +/// +/// The crate is const-generic in the chunk count so a register of any size +/// up to [`MAX_SUPPORTED_QUBITS`] gets a fixed-size word; the default +/// `C = W_CHUNKS` covers 128 qubits, byte-for-byte the historical layout. +/// The `FxBuildHasher` matches the hash used by the `FxHashMap` keys we +/// wrap with; `REHASH=true` means `set()` keeps the cached hash in sync. +pub type Word = PauliWord<[Chunk; C], FxBuildHasher, true>; + +/// `Err(TooManyQubits)` unless a `C`-chunk word holds `n_qubits`. +pub(crate) fn check_width(n_qubits: usize) -> Result<(), Error> { + if n_qubits > max_qubits::() { + return Err(Error::TooManyQubits { + got: n_qubits, + max: max_qubits::(), + }); + } + Ok(()) +} + +/// Build a [`Word`] from a length-`n_qubits` slice of Pauli labels +/// (`0=I, 1=X, 2=Z, 3=Y` — the [`ppvm_traits::char::Pauli`] discriminants). +/// Sets all bits and rehashes once. +pub fn word_from_codes(codes: &[u8]) -> Result, Error> { + let n_qubits = codes.len(); + check_width::(n_qubits)?; + let mut w = Word::::new(n_qubits); + for (q, &b) in codes.iter().enumerate() { + if b > 3 { + return Err(Error::InvalidPauliCode { code: b }); + } + if b & 1 != 0 { + w.xbits.set(q, true); + } + if b & 2 != 0 { + w.zbits.set(q, true); + } + } + w.rehash(); + Ok(w) +} + +/// Inverse of [`word_from_codes`]: write `n_qubits` Pauli labels into `out`. +pub fn codes_from_word(w: &Word, out: &mut [u8]) { + debug_assert_eq!(out.len(), w.n_qubits()); + for (q, slot) in out.iter_mut().enumerate() { + let xb = w.xbits[q] as u8; + let zb = w.zbits[q] as u8; + *slot = xb | (zb << 1); + } +} + +/// Parse a `"IXYZ..."` string into a [`Word`] together with the list of +/// qubits where the Pauli is non-identity (the term's support). +pub fn parse_pauli_string( + s: &str, + n_qubits: usize, +) -> Result<(Word, Vec), Error> { + check_width::(n_qubits)?; + let chars: Vec = s.chars().filter(|c| *c != '_').collect(); + if chars.len() != n_qubits { + return Err(Error::WrongLength { + expected: n_qubits, + got: chars.len(), + }); + } + let mut w = Word::::new(n_qubits); + let mut support = Vec::new(); + for (q, c) in chars.into_iter().enumerate() { + match c { + 'I' => {} + 'X' => { + w.xbits.set(q, true); + support.push(q as u32); + } + 'Z' => { + w.zbits.set(q, true); + support.push(q as u32); + } + 'Y' => { + w.xbits.set(q, true); + w.zbits.set(q, true); + support.push(q as u32); + } + other => return Err(Error::InvalidPauliChar { c: other }), + } + } + w.rehash(); + Ok((w, support)) +} + +/// Compute the support (non-identity qubits) of `w`. +pub(crate) fn word_support(w: &Word, out: &mut Vec) { + out.clear(); + for q in 0..w.n_qubits() { + if w.xbits[q] || w.zbits[q] { + out.push(q as u32); + } + } +} + +/// Compact 64-bit hash of a [`Word`], used as the key in cache-friendly +/// membership tables: an `FxHashMap` over the basis has a working +/// set ~6× smaller than `FxHashMap`. The hash mixes the word's +/// cached hash once through `FxHasher` and never touches the 32-byte +/// payload. +#[inline(always)] +pub(crate) fn word_hash(w: &Word) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = fxhash::FxHasher::default(); + w.hash(&mut h); + h.finish() +} diff --git a/crates/ppvm-lindblad/tests/word_width.rs b/crates/ppvm-lindblad/tests/word_width.rs new file mode 100644 index 000000000..00c5830fe --- /dev/null +++ b/crates/ppvm-lindblad/tests/word_width.rs @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Pauli-word width is a const-generic parameter, so a `LindbladSpec` can be +//! instantiated wider than the historical 128-qubit ceiling. +//! +//! The central check is a *padding invariance*: the same physical problem, +//! embedded in words of different widths, must produce bit-identical numbers. +//! A width bug (mask truncation, a stray `W_CHUNKS`, a wrong support index) +//! breaks that immediately. + +use num::Complex; +use ppvm_lindblad::{ + CHUNK_BITS, JumpInput, LindbladSpec, MAX_SUPPORTED_QUBITS, PcStepConfig, Sector, W_CHUNKS, + WIDTHS, Word, chunks_for, codes_from_word, max_qubits, word_from_codes, +}; +use ppvm_pauli_sum::symmetry::{TranslationGroup, canonicalize_pauli_sum_complex}; + +const W128: usize = WIDTHS[0]; +const W256: usize = WIDTHS[1]; +const W512: usize = WIDTHS[2]; + +/// `H = J Σ_{i (Vec<(String, f64)>, Vec) { + let pad = |sites: &[(usize, char)]| -> String { + let mut s = vec!['I'; n_total]; + for &(q, c) in sites { + s[q] = c; + } + s.into_iter().collect() + }; + let mut h = Vec::new(); + for i in 0..n_active - 1 { + h.push((pad(&[(i, 'Z'), (i + 1, 'Z')]), 0.7)); + } + for i in 0..n_active { + h.push((pad(&[(i, 'X')]), 1.3)); + } + let jumps = (0..n_active) + .map(|i| JumpInput { + lincomb: vec![(pad(&[(i, 'Z')]), Complex::new(1.0, 0.0))], + rate: 0.05, + }) + .collect(); + (h, jumps) +} + +/// A decay-type Kossakowski dissipator `A_0 = σ⁻_0`, `A_1 = σ⁻_1` with a +/// non-diagonal pair matrix, on the first two qubits of `n_total`. +fn add_kossakowski(spec: &mut LindbladSpec, n_total: usize) { + let op = |q: usize| -> Vec<(String, Complex)> { + let mut x = vec!['I'; n_total]; + let mut y = vec!['I'; n_total]; + x[q] = 'X'; + y[q] = 'Y'; + vec![ + (x.into_iter().collect(), Complex::new(0.5, 0.0)), + (y.into_iter().collect(), Complex::new(0.0, -0.5)), + ] + }; + let k = vec![ + vec![Complex::new(0.3, 0.0), Complex::new(0.1, 0.05)], + vec![Complex::new(0.1, -0.05), Complex::new(0.2, 0.0)], + ]; + spec.add_kossakowski(&[op(0), op(1)], &k).unwrap(); +} + +/// Evolve `Z_0 Z_1` for a few steps and return the coefficient sum over +/// `{I, Z}`-only strings — i.e. the expectation on the all-`Z = -1` product +/// state, up to the sign convention (identical across widths, which is all +/// this test needs). +fn evolve(n_total: usize, n_active: usize, steps: usize, koss: bool) -> f64 { + let (h, jumps) = model(n_total, n_active); + let mut spec = LindbladSpec::::new(n_total, &h, &jumps).unwrap(); + if koss { + add_kossakowski(&mut spec, n_total); + } + + let mut codes = vec![0u8; n_total]; + codes[0] = 2; // Z + codes[1] = 2; // Z + let mut basis = vec![word_from_codes::(&codes).unwrap()]; + let mut coeffs = vec![1.0f64]; + + let cfg = PcStepConfig { + max_basis: 20_000, + admit_basis: Some(60_000), + drop_tol: 0.0, + tau_add: None, + num_threads: Some(1), + }; + for _ in 0..steps { + spec.pc_step(&mut basis, &mut coeffs, 0.05, &[], &cfg) + .unwrap(); + } + + let mut out = vec![0u8; n_total]; + let mut acc = 0.0; + for (w, c) in basis.iter().zip(&coeffs) { + codes_from_word(w, &mut out); + if out.iter().all(|&b| b == 0 || b == 2) { + let nz = out.iter().filter(|&&b| b == 2).count(); + acc += if nz % 2 == 0 { *c } else { -*c }; + } + } + acc +} + +/// Momentum-sector evolution of `Σ_q X_q` under a translation-invariant ring +/// `H = Σ (X X + Y Y + ½ Z Z) + 0.3 Σ Z` of `n` sites, untruncated. Returns +/// the rep coefficients sorted by word. +fn evolve_orbit(n: usize, k: i32, steps: usize) -> Vec<(Vec, Complex)> { + let word = |ops: &[(usize, char)]| -> String { + let mut s = vec!['I'; n]; + for &(q, c) in ops { + s[q] = c; + } + s.into_iter().collect() + }; + let mut h = Vec::new(); + for i in 0..n { + let j = (i + 1) % n; + h.push((word(&[(i, 'X'), (j, 'X')]), 1.0)); + h.push((word(&[(i, 'Y'), (j, 'Y')]), 1.0)); + h.push((word(&[(i, 'Z'), (j, 'Z')]), 0.5)); + h.push((word(&[(i, 'Z')]), 0.3)); + } + let spec = LindbladSpec::::new(n, &h, &[]).unwrap(); + let group = TranslationGroup::chain_1d(n); + let k_modes = [k]; + + let mut basis: Vec> = (0..n) + .map(|q| { + let mut codes = vec![0u8; n]; + codes[q] = 1; // X + word_from_codes::(&codes).unwrap() + }) + .collect(); + let mut coeffs: Vec> = (0..n) + .map(|q| { + Complex::from_polar( + 1.0, + -2.0 * std::f64::consts::PI * (k as f64) * q as f64 / n as f64, + ) + }) + .collect(); + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, &group, &k_modes); + + let sector = Sector::new(&group, &k_modes); + let cfg = PcStepConfig { + max_basis: usize::MAX, + admit_basis: None, + drop_tol: 0.0, + tau_add: None, + num_threads: Some(1), + }; + for _ in 0..steps { + spec.pc_step_orbit_rep(&mut basis, &mut coeffs, 0.1, &[], §or, &cfg) + .unwrap(); + } + let mut out: Vec<(Vec, Complex)> = basis + .iter() + .zip(&coeffs) + .map(|(w, c)| { + let mut codes = vec![0u8; n]; + codes_from_word(w, &mut codes); + (codes, *c) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +#[test] +fn capacity_scales_with_chunk_count() { + assert_eq!(max_qubits::(), 128); + assert_eq!(max_qubits::(), 256); + assert_eq!(max_qubits::(), 512); + assert_eq!(W_CHUNKS, W128); + assert_eq!(W128 * CHUNK_BITS, 128); + assert_eq!(chunks_for(1), Some(W128)); + assert_eq!(chunks_for(128), Some(W128)); + assert_eq!(chunks_for(129), Some(W256)); + assert_eq!(chunks_for(130), Some(W256)); + assert_eq!(chunks_for(512), Some(W512)); + assert_eq!(chunks_for(MAX_SUPPORTED_QUBITS + 1), None); +} + +#[test] +fn wide_words_exceed_the_old_128_qubit_ceiling() { + // The exact case that used to fail with "supports n_qubits ≤ 128". + let (h, jumps) = model(130, 4); + let spec = LindbladSpec::::new(130, &h, &jumps).unwrap(); + assert_eq!(spec.n_qubits(), 130); + + let (h, jumps) = model(512, 4); + let spec = LindbladSpec::::new(512, &h, &jumps).unwrap(); + assert_eq!(spec.n_qubits(), 512); +} + +#[test] +fn too_many_qubits_for_the_width_is_rejected() { + let (h, jumps) = model(200, 4); + let Err(err) = LindbladSpec::::new(200, &h, &jumps) else { + panic!("a 200-qubit spec must not fit 128-qubit words"); + }; + assert_eq!( + err.to_string(), + "LindbladSpec supports n_qubits ≤ 128; got 200" + ); + assert!(LindbladSpec::::new(200, &h, &jumps).is_ok()); + assert!(word_from_codes::(&[0u8; 129]).is_err()); +} + +#[test] +fn padding_into_a_wider_word_changes_nothing() { + // Identical 6-qubit physics, embedded in 64-, 200- and 400-qubit + // registers backed by 128-, 256- and 512-qubit words. + let narrow = evolve::(64, 6, 6, false); + let wide = evolve::(200, 6, 6, false); + let widest = evolve::(400, 6, 6, false); + assert!(narrow.abs() > 1e-6, "test observable is trivially zero"); + assert_eq!(narrow.to_bits(), wide.to_bits(), "{narrow} vs {wide}"); + assert_eq!(narrow.to_bits(), widest.to_bits(), "{narrow} vs {widest}"); + + // The Kossakowski accumulation sums through a hash map, whose iteration + // order follows the word's hash — and a wider word hashes differently. + // Same physics, so agreement to rounding. + let narrow = evolve::(64, 6, 6, true); + for wide in [ + evolve::(200, 6, 6, true), + evolve::(400, 6, 6, true), + ] { + assert!( + (narrow - wide).abs() <= 1e-13 * narrow.abs(), + "{narrow} vs {wide}" + ); + } +} + +#[test] +fn same_width_different_register_size_agrees() { + // Within one width, the spectator qubits must not touch the answer. + assert_eq!( + evolve::(130, 6, 5, false).to_bits(), + evolve::(256, 6, 5, false).to_bits() + ); +} + +#[test] +fn orbit_rep_step_is_width_independent() { + // The momentum-orbit path (canonicalization, masked-shift generators, + // character table) on the same ring stored in 128- and 512-qubit words. + // Untruncated, so both widths hold the same reps; basis *order* follows + // hash-map iteration (width-dependent), so coefficients agree to rounding. + for (n, k, steps) in [(12, 0, 3), (12, 1, 3), (100, 0, 2)] { + let narrow = evolve_orbit::(n, k, steps); + let wide = evolve_orbit::(n, k, steps); + assert!(narrow.len() > 10, "basis did not grow"); + assert_eq!(narrow.len(), wide.len(), "n={n}, k={k}"); + for ((wa, ca), (wb, cb)) in narrow.iter().zip(&wide) { + assert_eq!(wa, wb, "n={n}, k={k}: rep sets differ"); + assert!((ca - cb).norm() <= 1e-12, "n={n}, k={k}: {ca} vs {cb}"); + } + } +} + +#[test] +fn orbit_rep_step_runs_beyond_128_qubits() { + let reps = evolve_orbit::(130, 0, 2); + assert!(reps.len() > 10); +} diff --git a/crates/ppvm-pauli-sum/Cargo.toml b/crates/ppvm-pauli-sum/Cargo.toml index c286a6973..8f9f7dd2a 100644 --- a/crates/ppvm-pauli-sum/Cargo.toml +++ b/crates/ppvm-pauli-sum/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] ppvm-traits = { version = "0.1.0", path = "../ppvm-traits" } ppvm-pauli-word = { version = "0.1.0", path = "../ppvm-pauli-word" } +bytemuck = { version = "1", features = ["min_const_generics"] } fxhash = "0.2.1" num = "0.4.3" itertools = "0.14.0" diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index cda4ae7c7..5175941b5 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -62,6 +62,92 @@ pub(super) fn validate_site_count(n: usize, context: &str) { .unwrap_or_else(|_| panic!("{context}: site count {n} exceeds the u32-addressable range")); } +/// A precondition violation in [`TranslationGroup::try_from_generators`]. +/// +/// Every variant is caller-supplied-input error, and its [`Display`] +/// text is exactly what [`TranslationGroup::from_generators`] panics +/// with. Arithmetic overflow in the group order or character phase +/// modulus is NOT covered — that needs generator orders in the billions +/// and still panics. +/// +/// [`Display`]: std::fmt::Display +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupError { + /// `perms` and `orders` describe different numbers of generators. + LengthMismatch { perms: usize, orders: usize }, + /// A generator's permutation is not `n_qubits` long. + PermutationLength { + generator: usize, + len: usize, + n_qubits: usize, + }, + /// A generator maps a qubit outside `0..n_qubits`. + TargetOutOfRange { + generator: usize, + target: u32, + n_qubits: usize, + }, + /// A generator maps two qubits to the same position. + DuplicateTarget { generator: usize, target: u32 }, + /// A generator declares cyclic order zero. + ZeroOrder { generator: usize }, + /// A generator's declared order is not its exact cyclic order. + OrderMismatch { + generator: usize, + declared: u32, + exact: u32, + }, + /// Two generators do not commute, so they generate no abelian group. + NonCommuting { left: usize, right: usize }, +} + +impl std::fmt::Display for GroupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LengthMismatch { perms, orders } => write!( + f, + "perms ({perms} generators) and orders ({orders}) must have the same length" + ), + Self::PermutationLength { + generator, + len, + n_qubits, + } => write!( + f, + "generator {generator}: permutation length {len} != n_qubits {n_qubits}" + ), + Self::TargetOutOfRange { + generator, + target, + n_qubits, + } => write!( + f, + "generator {generator}: target {target} out of range [0, {n_qubits})" + ), + Self::DuplicateTarget { generator, target } => write!( + f, + "generator {generator}: not a permutation (duplicate target {target})" + ), + Self::ZeroOrder { generator } => { + write!(f, "generator {generator} order must be nonzero") + } + Self::OrderMismatch { + generator, + declared, + exact, + } => write!( + f, + "generator {generator} declared order {declared} != exact permutation order {exact}" + ), + Self::NonCommuting { left, right } => { + write!(f, "generators {left} and {right} do not commute") + } + } + } +} + +impl std::error::Error for GroupError {} + /// A finite abelian symmetry group acting on qubit positions by /// permutations. /// @@ -78,8 +164,9 @@ pub(super) fn validate_site_count(n: usize, context: &str) { /// 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. +/// Only the **generators** are stored; [`Self::canonicalize`] either runs +/// the `O(N)` least-rotation scan (chain/ladder layouts) or walks the +/// group as a mixed-radix odometer. #[derive(Debug, Clone)] pub struct TranslationGroup { /// Number of qubits the group acts on. @@ -91,64 +178,300 @@ pub struct TranslationGroup { pub(super) orders: Vec, order: usize, phase_modulus: usize, + /// Set when the group is a *single* generator acting as a cyclic + /// shift inside contiguous, aligned blocks of qubits — i.e. exactly + /// the [`Self::chain_1d`] and [`Self::ladder`] layouts. Enables the + /// `O(N)` least-rotation canonicalizer (see + /// [`Self::canonicalize_block_cyclic`]). + pub(super) block_cyclic: Option, + /// Per generator, its block-rotation form when it has one (all lattice + /// translations do). Enables the masked-shift [`Self::apply_generator`]. + pub(super) rotations: Vec>, +} + +/// Layout of a single-generator group acting as a cyclic shift within +/// `n_blocks` contiguous, aligned blocks of `len` qubits each: qubit +/// `b * len + j` maps to `b * len + (j + 1) % len`. +#[derive(Debug, Clone, Copy)] +pub(super) struct BlockCyclic { + n_blocks: usize, + len: usize, +} + +/// A generator that acts as a cyclic shift by `stride` positions within +/// aligned blocks of `block` qubits: `b·block + p ↦ b·block + (p + stride) mod block`. +/// +/// Every lattice-translation generator has this form: the fastest axis of a +/// torus is `stride = 1` with `block = lx`, the next is `stride = lx` with +/// `block = lx·ly`, and so on. Recognising it lets the whole permutation be +/// applied as a masked shift of the two bit planes rather than a per-qubit +/// gather (see [`TranslationGroup::apply_block_rotation`]). +#[derive(Debug, Clone)] +pub(super) struct BlockRotation { + stride: usize, + block: usize, + /// Destinations that survive the plain left shift: everything except the + /// low `stride` slots of each block (which receive the previous block's + /// spill) and everything at or beyond `n_qubits`. + keep: Vec, + /// Sources that wrap: the top `stride` slots of each block. + high: Vec, +} + +/// Widest storage the masked-shift path handles, in 64-bit words. +const MAX_ROT_WORDS: usize = 16; + +/// Recognise a generator permutation as a [`BlockRotation`], and precompute +/// its masks. Returns `None` for permutations that are not block rotations. +fn detect_block_rotation(n_qubits: usize, perm: &[u32]) -> Option { + if n_qubits == 0 { + return None; + } + let stride = perm[0] as usize; + if stride == 0 { + return None; + } + // Whatever maps to qubit 0 sits `stride` below the top of block 0. + let block = perm.iter().position(|&t| t == 0)? + stride; + if block > n_qubits || stride >= block || !n_qubits.is_multiple_of(block) { + return None; + } + for b in 0..n_qubits / block { + for p in 0..block { + if perm[b * block + p] as usize != b * block + (p + stride) % block { + return None; + } + } + } + let mut keep = vec![0u64; n_qubits.div_ceil(64)]; + let mut high = keep.clone(); + for q in 0..n_qubits { + let p = q % block; + if p >= stride { + keep[q / 64] |= 1u64 << (q % 64); + } + if p >= block - stride { + high[q / 64] |= 1u64 << (q % 64); + } + } + Some(BlockRotation { + stride, + block, + keep, + high, + }) +} + +/// `dst = src << s` over a little-endian multiword bit array. +#[inline] +fn shl_words(src: &[u64], dst: &mut [u64], s: usize) { + let (ws, bs) = (s / 64, s % 64); + for i in (0..src.len()).rev() { + let lo = if i >= ws { src[i - ws] } else { 0 }; + dst[i] = if bs == 0 { + lo + } else { + let hi = if i > ws { + src[i - ws - 1] >> (64 - bs) + } else { + 0 + }; + (lo << bs) | hi + }; + } +} + +/// `dst = src >> s` over a little-endian multiword bit array. +#[inline] +fn shr_words(src: &[u64], dst: &mut [u64], s: usize) { + let n = src.len(); + let (ws, bs) = (s / 64, s % 64); + for i in 0..n { + let hi = if i + ws < n { src[i + ws] } else { 0 }; + dst[i] = if bs == 0 { + hi + } else { + let lo = if i + ws + 1 < n { + src[i + ws + 1] << (64 - bs) + } else { + 0 + }; + (hi >> bs) | lo + }; + } +} + +/// Detect the [`BlockCyclic`] layout, if the generators have it. +fn detect_block_cyclic(n_qubits: usize, perms: &[Vec], orders: &[u32]) -> Option { + if perms.len() != 1 { + return None; + } + let len = orders[0] as usize; + if len == 0 || n_qubits == 0 || !n_qubits.is_multiple_of(len) { + return None; + } + let n_blocks = n_qubits / len; + let perm = &perms[0]; + for b in 0..n_blocks { + for j in 0..len { + if perm[b * len + j] as usize != b * len + (j + 1) % len { + return None; + } + } + } + Some(BlockCyclic { n_blocks, len }) +} + +/// Start index of the lexicographically smallest rotation of an abstract +/// `m`-symbol cyclic sequence, via the two-pointer (Booth/Duval) scan. +/// +/// `cmp(a, b)` compares the symbols at positions `a` and `b`. `O(m)` +/// comparisons, no allocation. +fn least_rotation(m: usize, cmp: &F) -> usize +where + F: Fn(usize, usize) -> std::cmp::Ordering, +{ + let (mut i, mut j, mut k) = (0usize, 1usize, 0usize); + while i < m && j < m && k < m { + match cmp((i + k) % m, (j + k) % m) { + std::cmp::Ordering::Equal => { + k += 1; + continue; + } + std::cmp::Ordering::Greater => i += k + 1, + std::cmp::Ordering::Less => j += k + 1, + } + if i == j { + j += 1; + } + k = 0; + } + i.min(j) +} + +/// Period of the cyclic sequence `t ↦ start + t (mod m)` — the smallest +/// `p` dividing `m` with `s[t] == s[t + p]` for all `t`. +/// +/// Computed as the length of the first Lyndon factor (Duval): the minimal +/// rotation of a sequence is a power `w^{m/|w|}` of a Lyndon word `w`, and +/// `|w|` is the period. `O(m)` comparisons, no allocation. Callers pass the +/// `start` returned by [`least_rotation`]; the count of rotations achieving +/// the minimum is then `m / period`, spaced `period` apart. +fn minimal_rotation_period(m: usize, start: usize, cmp: &F) -> usize +where + F: Fn(usize, usize) -> std::cmp::Ordering, +{ + let at = |t: usize| (start + t) % m; + let (mut j, mut k) = (1usize, 0usize); + while j < m { + match cmp(at(k), at(j)) { + std::cmp::Ordering::Less => { + k = 0; + j += 1; + } + std::cmp::Ordering::Equal => { + k += 1; + j += 1; + } + std::cmp::Ordering::Greater => break, + } + } + let len = j - k; + if m.is_multiple_of(len) { len } else { m } } impl TranslationGroup { - /// Construct from explicit generator permutations and orders. + /// Construct from explicit generator permutations and orders, + /// panicking on any precondition violation. /// /// 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. + /// + /// Use [`Self::try_from_generators`] when the generators come from + /// outside the program (an FFI boundary, a config file) and a + /// precondition violation should be reported rather than abort. 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() - ); + Self::try_from_generators(n_qubits, perms, orders) + .unwrap_or_else(|err| panic!("TranslationGroup::from_generators: {err}")) + } + + /// Fallible [`Self::from_generators`]: validates every precondition + /// on the caller-supplied generators and reports the first violation + /// as a [`GroupError`] instead of panicking. + pub fn try_from_generators( + n_qubits: usize, + perms: Vec>, + orders: Vec, + ) -> Result { + if perms.len() != orders.len() { + return Err(GroupError::LengthMismatch { + perms: perms.len(), + orders: orders.len(), + }); + } + for (generator, perm) in perms.iter().enumerate() { + if perm.len() != n_qubits { + return Err(GroupError::PermutationLength { + generator, + len: perm.len(), + n_qubits, + }); + } 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 &target in perm { + if target as usize >= n_qubits { + return Err(GroupError::TargetOutOfRange { + generator, + target, + n_qubits, + }); + } + if seen[target as usize] { + return Err(GroupError::DuplicateTarget { generator, target }); + } + seen[target 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 (generator, &declared) in orders.iter().enumerate() { + if declared == 0 { + return Err(GroupError::ZeroOrder { generator }); + } + let exact = permutation_order(&perms[generator], generator); + if declared != exact { + return Err(GroupError::OrderMismatch { + generator, + declared, + 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", - ); + if !permutations_commute(&perms[left], &perms[right]) { + return Err(GroupError::NonCommuting { left, right }); + } } } 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 { + let block_cyclic = detect_block_cyclic(n_qubits, &perms, &orders); + let rotations = perms + .iter() + .map(|p| detect_block_rotation(n_qubits, p)) + .collect(); + Ok(Self { n_qubits, perms, orders, order, phase_modulus, - } + block_cyclic, + rotations, + }) } /// 1D chain of `n` sites with periodic boundary conditions. @@ -305,11 +628,15 @@ impl TranslationGroup { self.phase_modulus } - /// Apply a single generator's permutation to a Pauli word, returning - /// the resulting word. + /// Apply a single generator's permutation to a Pauli word: for each + /// qubit `q` of the input, the `(xbit, zbit)` pair is placed at position + /// `perm[q]` of the output. /// - /// For each qubit `q` of the input, the corresponding `(xbit, zbit)` - /// pair is placed at position `perm[q]` of the output. + /// Does **not** refresh the cached hash. Equality and ordering compare + /// the bit planes, so an unhashed word is safe to compare and to keep as + /// an intermediate; only words that escape into a hash container need + /// `rehash`. The odometer walk applies a generator per group element and + /// hashes just the winner. pub(super) fn apply_generator( &self, w: &PauliWord, @@ -319,6 +646,11 @@ impl TranslationGroup { A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { + if let Some(rot) = &self.rotations[g] + && let Some(out) = Self::apply_block_rotation(w, rot) + { + return out; + } 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) { @@ -331,10 +663,95 @@ impl TranslationGroup { out.set_zbit(pq as usize, true); } } - out.rehash(); out } + /// Apply a block-rotation generator as a masked shift of both bit + /// planes: `out = ((in << stride) & keep) | ((in & high) >> (block − stride))`. + /// + /// This is the same permutation as the per-qubit gather, in `O(N/64)` + /// word operations instead of `O(N)` bit operations. The cached hash is + /// *not* refreshed. Returns `None` on big-endian targets (where the byte + /// view of the bit planes is not in bit order) or if the storage is wider + /// than [`MAX_ROT_WORDS`], leaving the caller on the general path. + pub(super) fn apply_block_rotation( + w: &PauliWord, + rot: &BlockRotation, + ) -> Option> + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + if !cfg!(target_endian = "little") || size_of::() > MAX_ROT_WORDS * 8 { + return None; + } + let mut out = *w; + for plane in 0..2 { + let (src_arr, dst_arr) = if plane == 0 { + (&w.xbits.data, &mut out.xbits.data) + } else { + (&w.zbits.data, &mut out.zbits.data) + }; + let bytes = bytemuck::bytes_of(src_arr); + let nw = bytes.len().div_ceil(8); + let (mut src, mut shifted, mut wrapped) = ( + [0u64; MAX_ROT_WORDS], + [0u64; MAX_ROT_WORDS], + [0u64; MAX_ROT_WORDS], + ); + for (i, chunk) in bytes.chunks(8).enumerate() { + let mut b = [0u8; 8]; + b[..chunk.len()].copy_from_slice(chunk); + src[i] = u64::from_le_bytes(b); + } + shl_words(&src[..nw], &mut shifted[..nw], rot.stride); + for (i, s) in src[..nw].iter_mut().enumerate() { + *s &= rot.high.get(i).copied().unwrap_or(0); + } + shr_words(&src[..nw], &mut wrapped[..nw], rot.block - rot.stride); + for i in 0..nw { + shifted[i] = (shifted[i] & rot.keep.get(i).copied().unwrap_or(0)) | wrapped[i]; + } + let dst = bytemuck::bytes_of_mut(dst_arr); + for (i, chunk) in dst.chunks_mut(8).enumerate() { + let b = shifted[i].to_le_bytes(); + let n = chunk.len(); + chunk.copy_from_slice(&b[..n]); + } + } + Some(out) + } + + /// Odometer step: advance `cur` from the group element with + /// mixed-radix index `idx - 1` to the one with index `idx`. + /// + /// Generator `0` is the fastest-varying digit, so it advances on + /// every step; digit `g` advances only when all lower digits roll + /// over, i.e. when `idx` is a multiple of `orders[0..=g-1]`. Applying + /// generator `g` once always moves digit `g` forward *cyclically* + /// (the `orders[g]`-th application is the identity), so a roll-over + /// is just one more application — no rebuild from the identity. + /// + /// Cost: `O(1)` generator applications amortised, hence `O(|G| × N)` + /// for a full walk. Leaves the cached hash of `cur` stale. + #[inline] + fn advance(&self, cur: &mut PauliWord, idx: usize) + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + let mut p = 1usize; + for (g, &o) in self.orders.iter().enumerate() { + if o > 1 { + *cur = self.apply_generator(cur, g); + } + p *= o as usize; + if !idx.is_multiple_of(p) { + break; + } + } + } + pub(super) fn orbit_with_counters<'a, A, S, const R: bool>( &'a self, word: &'a PauliWord, @@ -357,25 +774,17 @@ impl TranslationGroup { } /// 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. + /// under this group. /// - /// Total cost: `O(|G| × n_qubits)` per call. + /// For chain/ladder layouts this is `O(N)` via the least-rotation + /// canonicalizer ([`Self::canonicalize_block_cyclic`]); otherwise it + /// walks the full group as a mixed-radix odometer, `O(|G| × N)`. 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 + self.canonicalize_with_index(w).0 } /// Lex-min canonical representative `r` of `w` together with the @@ -390,7 +799,10 @@ impl TranslationGroup { /// 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`. + /// Same cost as [`Self::canonicalize`], plus the counter `Vec`. Hot + /// paths should prefer [`Self::canonicalize_with_index`], which is + /// allocation-free and indexes a precomputed + /// [`Self::character_table`](crate::symmetry::TranslationGroup::character_table). pub fn canonicalize_with_shift( &self, w: &PauliWord, @@ -399,22 +811,260 @@ impl TranslationGroup { 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 (rep, idx) = self.canonicalize_with_index(w); + (rep, self.counter_from_index(idx)) + } + + /// Lex-min canonical representative `r` of `w` together with the + /// **mixed-radix index** (generator `0` fastest) of the group element + /// `g` such that `g·r = w` — i.e. the index of the counter returned by + /// [`Self::canonicalize_with_shift`]. + /// + /// The index is directly usable as a subscript into a + /// [`CharacterTable`](crate::symmetry::CharacterTable), which is how the + /// phase-aware evolution gets `χ_k(g)` without decoding a counter or + /// calling `sin`/`cos` per term. + /// + /// Cost: `O(N)` for chain/ladder layouts, else `O(|G| × N)`. + /// Allocation-free apart from the returned word. + pub fn canonicalize_with_index( + &self, + w: &PauliWord, + ) -> (PauliWord, usize) + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + assert_eq!( + w.n_qubits(), + self.n_qubits, + "word and group must agree on n_qubits" + ); + match self.block_cyclic { + Some(bc) => { + let (rep, r, _) = self.canonicalize_block_cyclic(w, bc); + (rep, r) + } + None => { + let (rep, idx, _) = self.canonicalize_odometer(w, |_| true); + (rep, idx) } } - let counter_to_word = counter_from_word - .iter() - .zip(self.orders.iter()) - .map(|(&counter, &order)| (order - counter) % order) - .collect(); - (best, counter_to_word) + } + + /// Canonical rep, the index of the group element mapping it back to `w`, + /// and the **stabilizer** of `w` checked against `trivial`: returns + /// `None` as soon as a stabilizer element `s` (`s·w = w`) with + /// `!trivial(index(s))` is found, else `Some((rep, index, |stabilizer|))`. + /// + /// One traversal gives everything the momentum-sector routines need. + /// `trivial` is only consulted on stabilizer elements, which are rare + /// (none but the identity for a free orbit). + pub(super) fn canonicalize_with_stabilizer( + &self, + w: &PauliWord, + trivial: F, + ) -> Option<(PauliWord, usize, usize)> + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + F: Fn(usize) -> bool, + { + assert_eq!( + w.n_qubits(), + self.n_qubits, + "word and group must agree on n_qubits" + ); + match self.block_cyclic { + Some(bc) => { + let (rep, r, step) = self.canonicalize_block_cyclic(w, bc); + // The stabilizer of a single-generator group is the cyclic + // subgroup generated by `g^step`; its characters are all + // trivial iff that generator's is. + if step < bc.len && !trivial(step) { + return None; + } + Some((rep, r, bc.len / step)) + } + None => { + let (rep, idx, stabilizer) = self.canonicalize_odometer(w, trivial); + (stabilizer != 0).then_some((rep, idx, stabilizer)) + } + } + } + + /// Reference canonicalizer: walk the whole group as a mixed-radix + /// odometer (see [`Self::advance`]), keeping the first smallest word + /// seen. Returns the rep, the index of the group element mapping it + /// back to `w`, and the stabilizer size — or `0` for the latter if a + /// stabilizer element fails `trivial` (the walk stops there). + /// + /// `O(|G| × N)`; used for groups without a [`BlockCyclic`] layout, and + /// as the test oracle for the fast path. + pub(super) fn canonicalize_odometer( + &self, + w: &PauliWord, + trivial: F, + ) -> (PauliWord, usize, usize) + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + F: Fn(usize) -> bool, + { + let mut best = *w; + let mut best_idx = 0usize; + let mut stabilizer = 1usize; + let mut cur = *w; + for idx in 1..self.order { + self.advance(&mut cur, idx); + if cur == *w { + if !trivial(idx) { + return (best, 0, 0); + } + stabilizer += 1; + } + if cur < best { + best = cur; + best_idx = idx; + } + } + // `advance` leaves the cached hash stale; the winner escapes to the + // caller (and into hash containers), so refresh it here. + best.rehash(); + // The walk found `best = g·w` at index `best_idx`, so `w = g⁻¹·best` + // and the element we must report is the inverse. + (best, self.invert_index(best_idx), stabilizer) + } + + /// `O(N)` canonicalizer for single-generator cyclic-block groups + /// (chain, ladder): returns the same rep as the odometer walk — the + /// `Ord`-lex-min of the orbit — the index `r` of the group element + /// with `g^r · rep = w` (the odometer's choice), and the smallest + /// `step > 0` with `g^step · w = w` (`step == len` for a free orbit). + /// + /// ## Why this is not one Booth call + /// + /// `PauliWord`'s `Ord` compares the whole x-bit plane in qubit order, + /// *then* the whole z-bit plane. Under a shift by `r`, the comparison + /// key is therefore the concatenation + /// `rot_r(x_block0) ‖ … ‖ rot_r(z_block0) ‖ …` — `2 · n_blocks` strings + /// rotated *together*, not one rotated string, so lex-min over rotations + /// is not a single least-rotation problem. (Running Booth on an + /// interleaved per-site symbol would be one call, but it minimises a + /// different order and so would silently change which orbit member is + /// canonical.) + /// + /// Instead we refine the candidate rotation set plane by plane. After + /// each plane the surviving rotations form a residue class + /// `{start + i·step}` of size `m = L / step`, because the rotations + /// achieving a minimum are exactly those spaced by the *period* of that + /// minimal rotation. Plane `p + 1` then compares its own string only at + /// those rotations — which is again a least-rotation problem, over `m` + /// super-symbols of `step` bits each. Every plane costs `O(L)` symbol + /// comparisons of `O(step)` bits = `O(L)`, so the whole call is + /// `O(n_blocks · L) = O(N)`, allocation-free apart from the output word. + /// The final survivors are one coset of the stabilizer, so `step` is + /// its generator. + pub(super) fn canonicalize_block_cyclic( + &self, + w: &PauliWord, + bc: BlockCyclic, + ) -> (PauliWord, usize, usize) + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + let l = bc.len; + // Surviving rotations: { (start + i·step) mod l : i < m }, with + // step · m == l throughout, and `start < step` (the smallest one). + let (mut start, mut step, mut m) = (0usize, 1usize, l); + for plane in 0..2 * bc.n_blocks { + if m == 1 { + break; + } + let is_x = plane < bc.n_blocks; + let base = (if is_x { plane } else { plane - bc.n_blocks }) * l; + // Symbol `j` is the run of `step` bits of this plane starting at + // rotation offset `start + j·step`. + let bit = |j: usize, t: usize| -> bool { + let pos = base + (start + j * step + t) % l; + if is_x { + w.get_xbit(pos) + } else { + w.get_zbit(pos) + } + }; + let cmp = |a: usize, b: usize| -> std::cmp::Ordering { + for t in 0..step { + let (x, y) = (bit(a, t), bit(b, t)); + if x != y { + // `false < true`, matching bit-slice lex order. + return x.cmp(&y); + } + } + std::cmp::Ordering::Equal + }; + let j0 = least_rotation(m, &cmp); + let period = minimal_rotation_period(m, j0, &cmp); + start = (start + j0 * step) % l; + step *= period; + m /= period; + start %= step; // smallest member of the surviving residue class + } + // Tie-break exactly as the odometer does: it keeps the *first* + // minimal word it meets, i.e. the smallest number of generator + // applications `idx = (l − r) mod l`. That is `r = 0` when `r = 0` + // survives, and otherwise the largest surviving `r`. + let r = if start == 0 { + 0 + } else { + start + (m - 1) * step + }; + // rep = g^{−r}·w, i.e. rep[base + j] = w[base + (j + r) mod l]. + let mut rep: PauliWord = PauliWord::new(self.n_qubits); + for b in 0..bc.n_blocks { + let base = b * l; + for j in 0..l { + let src = base + (j + r) % l; + if w.get_xbit(src) { + rep.set_xbit(base + j, true); + } + if w.get_zbit(src) { + rep.set_zbit(base + j, true); + } + } + } + rep.rehash(); + (rep, r, step) + } + + /// Decode a group-element index (mixed-radix, generator `0` fastest) + /// into its per-generator counter. + pub fn counter_from_index(&self, idx: usize) -> Vec { + let mut rem = idx; + let mut counter: Vec = Vec::with_capacity(self.orders.len()); + for &o in &self.orders { + counter.push((rem % o as usize) as u32); + rem /= o as usize; + } + counter + } + + /// Index of the inverse of the group element with index `idx`. In an + /// abelian product of cyclic groups that is `(orders[g] − c[g]) mod + /// orders[g]` componentwise. + pub(super) fn invert_index(&self, idx: usize) -> usize { + let mut rem = idx; + let mut out = 0usize; + let mut stride = 1usize; + for &o in &self.orders { + let o = o as usize; + let c = rem % o; + rem /= o; + out += ((o - c) % o) * stride; + stride *= o; + } + out } /// Iterate over all abstract group elements applied to `w`. Yields @@ -456,7 +1106,11 @@ where if self.remaining == 0 { return None; } - let item = (self.current, self.counter.clone()); + // `apply_generator` skips hashing; yielded words may become hash + // keys, so hash each one on the way out. + let mut word = self.current; + word.rehash(); + let item = (word, self.counter.clone()); self.remaining -= 1; if self.remaining == 0 { return Some(item); diff --git a/crates/ppvm-pauli-sum/src/symmetry/mod.rs b/crates/ppvm-pauli-sum/src/symmetry/mod.rs index 30d6d4c01..f18b4dac3 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/mod.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/mod.rs @@ -17,13 +17,19 @@ //! (`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. +//! **Non-trivial momentum sectors (`k ≠ 0`)** fold with the character +//! phase `χ_k(g)` of each translation. Two conventions share one core: +//! [`canonicalize_pauli_sum_complex`] *averages* over each orbit's +//! distinct members (`1/|orbit|`), while +//! [`momentum_merge_pauli_sum_pair`] *sums* — the convention that is +//! idempotent on every orbit and reduces exactly to +//! [`symmetry_merge_pauli_sum`] at `k = 0`. Note `|orbit| = |G|` only for +//! free orbits, so the two are **not** related by a global `|G|` factor. +//! 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` — letting gate-based Trotter evolution +//! stay symmetry-compressed in any momentum sector with real +//! coefficients throughout. //! //! ## Data model //! @@ -66,9 +72,12 @@ mod group; mod merge; mod momentum; -pub use group::TranslationGroup; +pub use group::{GroupError, TranslationGroup}; pub use merge::{canonicalize_pauli_sum, symmetry_merge_pauli_sum}; -pub use momentum::{SectorCheckError, canonicalize_pauli_sum_complex, check_momentum_sector}; +pub use momentum::{ + CharacterTable, SectorCheckError, canonicalize_pauli_sum_complex, check_momentum_sector, + momentum_merge_pauli_sum_pair, +}; #[cfg(test)] mod tests; diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index ad29facdc..d294ca1ec 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 +use crate::sum::PauliSum; use fxhash::{FxHashMap, FxHashSet}; use num::Complex; use ppvm_pauli_word::word::PauliWord; -use ppvm_traits::{HashFinalize, PauliStorage}; +use ppvm_traits::{ACMapAddAssign, ACMapBase, ACMapIter, Config, HashFinalize, PauliStorage}; use std::f64::consts::PI; use std::hash::BuildHasher; @@ -50,6 +51,147 @@ impl TranslationGroup { let phase = 2.0 * PI * numerator as f64 / self.phase_modulus() as f64; Complex::from_polar(1.0, phase) } + + /// All `|G|` momentum-sector characters of sector `k_modes`, indexed + /// by group-element index (mixed-radix, generator `0` fastest): + /// `table.value(idx) == self.character(k_modes, &self.counter_from_index(idx))` + /// exactly, bit for bit. + /// + /// Build this once per evolution step and index it with the value from + /// [`Self::canonicalize_with_index`] or + /// [`Self::canonicalize_in_sector_indexed`]; the alternative — calling + /// [`Self::character`] per action term — costs a counter `Vec`, the + /// exact-numerator arithmetic and a `sin`/`cos` pair every time. + pub fn character_table(&self, k_modes: &[i32]) -> CharacterTable { + assert_eq!( + k_modes.len(), + self.n_generators(), + "k_modes length mismatch" + ); + let modulus = self.phase_modulus(); + let mut numerators = Vec::with_capacity(self.order()); + let mut values = Vec::with_capacity(self.order()); + let mut counter = vec![0u32; self.n_generators()]; + for _ in 0..self.order() { + let numerator = self.character_numerator(k_modes, &counter); + numerators.push(numerator); + values.push(Complex::from_polar( + 1.0, + 2.0 * PI * numerator as f64 / modulus as f64, + )); + // Mixed-radix increment, generator 0 fastest. + for (c, &o) in counter.iter_mut().zip(self.orders.iter()) { + *c += 1; + if *c < o { + break; + } + *c = 0; + } + } + CharacterTable { numerators, values } + } + + /// Everything the phase-aware routines need about `w`'s orbit in + /// momentum sector `k_modes`, from ONE orbit traversal: the lex-min + /// representative `r`, the mixed-radix counter of the group element + /// mapping `r` to `w` (as [`Self::canonicalize_with_shift`]), and the + /// number of **distinct** orbit members `|orbit|`. + /// + /// Returns `None` when the orbit's stabilizer is incompatible with + /// `k_modes` — i.e. some `s` with `s·w = w` has `χ_k(s) ≠ 1`. Such an + /// orbit cannot carry this sector: its momentum projection is + /// identically zero, and the rep coefficient a single traversal would + /// report depends on which counter the traversal happens to pick. + /// + /// `|orbit| = |G| / |stabilizer|` (orbit-stabilizer), and equals + /// `|G|` only for free orbits. + /// + /// Same cost as [`Self::canonicalize_with_shift`]. Hot loops should + /// build a [`CharacterTable`] once and call + /// [`Self::canonicalize_in_sector_indexed`] instead. + pub fn canonicalize_in_sector( + &self, + w: &PauliWord, + k_modes: &[i32], + ) -> Option<(PauliWord, Vec, usize)> + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + assert_eq!( + k_modes.len(), + self.n_generators(), + "k_modes length mismatch" + ); + let (rep, idx, stabilizer) = self.canonicalize_with_stabilizer(w, |idx| { + self.character_numerator(k_modes, &self.counter_from_index(idx)) == 0 + })?; + Some((rep, self.counter_from_index(idx), self.order() / stabilizer)) + } + + /// [`Self::canonicalize_in_sector`] against a precomputed + /// [`CharacterTable`]: returns the rep, the **index** of the group + /// element mapping it to `w` (look its character up with + /// [`CharacterTable::value`]), and `|orbit|` — or `None` when the + /// orbit cannot carry the table's sector. + /// + /// Allocation-free apart from the returned word; `O(N)` for chain / + /// ladder layouts, else `O(|G| × N)`. + #[inline] + pub fn canonicalize_in_sector_indexed( + &self, + w: &PauliWord, + table: &CharacterTable, + ) -> Option<(PauliWord, usize, usize)> + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + assert_eq!( + table.len(), + self.order(), + "character table does not belong to this group" + ); + let (rep, idx, stabilizer) = + self.canonicalize_with_stabilizer(w, |idx| table.is_trivial(idx))?; + Some((rep, idx, self.order() / stabilizer)) + } +} + +/// The characters `χ_k(g)` of one momentum sector for every element of a +/// [`TranslationGroup`], indexed by group-element index. Built by +/// [`TranslationGroup::character_table`]. +#[derive(Debug, Clone)] +pub struct CharacterTable { + /// Exact phase numerators (see `character_numerator`); `0` ⇔ `χ = 1`. + numerators: Vec, + values: Vec>, +} + +impl CharacterTable { + /// `χ_k` of the group element with index `idx`. + #[inline] + pub fn value(&self, idx: usize) -> Complex { + self.values[idx] + } + + /// Whether `χ_k` of element `idx` is exactly `1`. + #[inline] + pub fn is_trivial(&self, idx: usize) -> bool { + self.numerators[idx] == 0 + } + + /// Number of entries, i.e. the group order. + #[inline] + pub fn len(&self) -> usize { + self.values.len() + } + + /// Whether the table is empty (never, for a valid group). + #[inline] + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } } /// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form @@ -99,6 +241,43 @@ pub fn canonicalize_pauli_sum_complex( for (word, &coeff) in basis.iter().zip(coeffs.iter()) { *input.entry(*word).or_insert(Complex::new(0.0, 0.0)) += coeff; } + let projected = project_onto_reps(&input, group, k_modes); + basis.clear(); + coeffs.clear(); + basis.reserve(projected.len()); + coeffs.reserve(projected.len()); + for (word, (sum, orbit_size)) in projected { + basis.push(word); + coeffs.push(sum / orbit_size as f64); + } +} + +/// Character-weighted fold of `input` onto translation-orbit +/// representatives, the shared core of the two momentum-projection +/// conventions. +/// +/// Returns `rep → (Σ_{p ∈ orbit} χ_k(g_p) · c_p, |orbit|)`: the +/// **summing** projector, paired with the number of *distinct* orbit +/// members. Callers pick their convention — +/// [`canonicalize_pauli_sum_complex`] divides by `|orbit|` to average, +/// [`momentum_merge_pauli_sum_pair`] takes the sum as-is. +/// +/// `|orbit|` is `group.order()` only for free orbits; an orbit with a +/// non-trivial stabilizer has fewer distinct members, which is exactly +/// why the two conventions must not be related by a global `|G|` factor. +/// +/// Orbits whose stabilizer is incompatible with `k_modes` (the same +/// orbit member reached with different character numerators) project to +/// zero and are omitted from the output. +fn project_onto_reps( + input: &FxHashMap, Complex>, + group: &TranslationGroup, + k_modes: &[i32], +) -> FxHashMap, (Complex, usize)> +where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ let reps: FxHashSet<_> = input.keys().map(|word| group.canonicalize(word)).collect(); let mut projected = FxHashMap::default(); @@ -122,24 +301,97 @@ pub fn canonicalize_pauli_sum_complex( if !compatible { continue; } - let orbit_size = members.len() as f64; - let mut rep_coeff = Complex::new(0.0, 0.0); + let orbit_size = members.len(); + let mut sum = 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; + sum += group.character(k_modes, &counter) * coeff; } - projected.insert(rep, rep_coeff); + projected.insert(rep, (sum, orbit_size)); } - basis.clear(); - coeffs.clear(); - basis.reserve(projected.len()); - coeffs.reserve(projected.len()); - for (w, c) in projected { - basis.push(w); - coeffs.push(c); + projected +} + +/// Momentum-sector merge of a complex operator carried as a **real +/// pair**: `re` and `im` are the real and imaginary parts of +/// `O = re + i·im`. Both are overwritten in place with the +/// orbit-representative form of `O` projected onto momentum sector +/// `k_modes`. +/// +/// This is the momentum-sector counterpart of +/// [`super::symmetry_merge_pauli_sum`], and generalizes it to `k ≠ 0` +/// while keeping real coefficients on both sums — the only complex +/// arithmetic is the internal character-weighted fold, which reuses +/// [`canonicalize_pauli_sum_complex`]. +/// +/// This is the **summing** projector +/// `Σ_{p ∈ orbit} χ_k(g_p) · c_p` over each orbit's *distinct* members, not the +/// orbit-averaged one that [`canonicalize_pauli_sum_complex`] returns. +/// Summing is what makes the merge idempotent — and hence safe to apply +/// after every Trotter step — for *every* orbit, including orbits with a +/// non-trivial stabilizer, and it reduces exactly to +/// [`super::symmetry_merge_pauli_sum`] at `k = 0`. +/// +/// Entries whose component is exactly zero are dropped, so a purely real +/// operator leaves `im` empty. +/// +/// # Panics +/// +/// If `re` and `im` disagree on qubit count, if either disagrees with +/// `group.n_qubits()`, or if `k_modes.len() != group.n_generators()`. +pub fn momentum_merge_pauli_sum_pair( + re: &mut PauliSum, + im: &mut PauliSum, + group: &TranslationGroup, + k_modes: &[i32], +) where + T: Config, Coeff = f64>, + T::Map: ACMapAddAssign>, + for<'a> T::Map: ACMapIter<'a, Item = (&'a PauliWord, &'a f64)>, + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, +{ + assert_eq!( + re.n_qubits(), + im.n_qubits(), + "real and imaginary parts disagree on qubit count" + ); + assert_eq!( + re.n_qubits(), + group.n_qubits(), + "PauliSum qubit count {} != group qubit count {}", + re.n_qubits(), + group.n_qubits() + ); + assert_eq!( + k_modes.len(), + group.n_generators(), + "k_modes length {} != number of generators {}", + k_modes.len(), + group.n_generators() + ); + + // Gather both real components into `word -> re + i·im`. + let mut combined: FxHashMap, Complex> = FxHashMap::default(); + for (word, coeff) in re.data().iter() { + combined.entry(*word).or_insert(Complex::new(0.0, 0.0)).re += *coeff; + } + for (word, coeff) in im.data().iter() { + combined.entry(*word).or_insert(Complex::new(0.0, 0.0)).im += *coeff; + } + let projected = project_onto_reps(&combined, group, k_modes); + re.data_mut().clear(); + im.data_mut().clear(); + for (word, (sum, _orbit_size)) in projected { + if sum.re != 0.0 { + *re += (word, sum.re); + } + if sum.im != 0.0 { + *im += (word, sum.im); + } } } diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 64c1deec0..5e09d5c47 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::sum::PauliSum; use fxhash::FxHashMap; use num::Complex; use ppvm_pauli_word::word::PauliWord; +use ppvm_traits::PauliWordTrait; use std::f64::consts::PI; type W = PauliWord<[u8; 1], fxhash::FxBuildHasher, true>; @@ -135,6 +137,46 @@ fn canonicalize_with_shift_round_trip() { } } +#[test] +fn canonicalize_in_sector_agrees_with_canonicalize_with_shift() { + let g = TranslationGroup::chain_1d(4); + for src in ["IIXY", "IXYI", "XYII", "YIIX", "XIXI", "IIII"] { + let w = word(src); + let (rep, shift, orbit_size) = g.canonicalize_in_sector(&w, &[0]).unwrap(); + let (ref_rep, ref_shift) = g.canonicalize_with_shift(&w); + assert_eq!(rep, ref_rep, "{src}: rep"); + assert_eq!(shift, ref_shift, "{src}: shift"); + let distinct: std::collections::HashSet = g.orbit(&w).collect(); + assert_eq!(orbit_size, distinct.len(), "{src}: orbit size"); + } +} + +#[test] +fn canonicalize_in_sector_rejects_incompatible_stabilizer() { + // "XIXI" has period 2 on a 4-site chain: 2 distinct orbit members, + // stabilizer generated by T². χ_k(T²) = e^{iπk}, so the orbit + // carries the k=0 and k=2 sectors but not k=1 or k=3. + let g = TranslationGroup::chain_1d(4); + let w = word("XIXI"); + for k in [0, 2] { + let (_, _, orbit_size) = g + .canonicalize_in_sector(&w, &[k]) + .unwrap_or_else(|| panic!("k={k} must be compatible with a period-2 orbit")); + assert_eq!(orbit_size, 2, "k={k}"); + } + for k in [1, 3] { + assert!( + g.canonicalize_in_sector(&w, &[k]).is_none(), + "k={k} must be rejected on a period-2 orbit" + ); + } + // A free orbit carries every sector, with the full |G| members. + for k in 0..4 { + let (_, _, orbit_size) = g.canonicalize_in_sector(&word("XIII"), &[k]).unwrap(); + assert_eq!(orbit_size, 4, "k={k}"); + } +} + #[test] fn character_trivial_sector_is_one() { let g = TranslationGroup::chain_1d(4); @@ -452,6 +494,159 @@ fn pauli_sum_symmetry_merge_matches_plain_trotter() { ); } +/// Build the `(re, im)` real pair of the momentum-`k` eigenstate +/// `O_k = Σ_a e^{-2πi k a / n} Z_a` on an `n`-site chain. +fn seed_z_momentum_pair(n: usize, k: i32) -> (PauliSum, PauliSum) +where + Cfg: ppvm_traits::Config, + PauliSum: for<'s> std::ops::AddAssign<(&'s str, f64)>, +{ + let mut re: PauliSum = PauliSum::builder().n_qubits(n).build(); + let mut im: PauliSum = PauliSum::builder().n_qubits(n).build(); + for a in 0..n { + let mut s: Vec = vec!['I'; n]; + s[a] = 'Z'; + let st: String = s.into_iter().collect(); + let phase = -2.0 * PI * (k as f64) * (a as f64) / (n as f64); + re += (st.as_str(), phase.cos()); + im += (st.as_str(), phase.sin()); + } + (re, im) +} + +/// At `k = 0` the phase-aware pair merge must agree with the independent +/// real-coefficient `symmetry_merge_pauli_sum` code path — for *every* +/// orbit, free or stabilized. This is the +/// regression test for the summing-vs-averaging convention: a global +/// `|G|` rescale of the averaged projector agrees only on free orbits. +#[test] +fn momentum_merge_pair_matches_symmetry_merge_at_k0() { + use crate::config::indexmap::ByteFxHashF64; + use crate::prelude::*; + + type Cfg = ByteFxHashF64<1>; + let n = 4usize; + let group = TranslationGroup::chain_1d(n); + + let mut reference: PauliSum = PauliSum::builder().n_qubits(n).build(); + let mut re: PauliSum = PauliSum::builder().n_qubits(n).build(); + let mut im: PauliSum = PauliSum::builder().n_qubits(n).build(); + // Σ_j Z_j and Σ_j X_j X_{j+1}: free orbits (|orbit| = |G|). + for j in 0..n { + let mut z: Vec = vec!['I'; n]; + z[j] = 'Z'; + let mut xx: Vec = vec!['I'; n]; + xx[j] = 'X'; + xx[(j + 1) % n] = 'X'; + for (s, c) in [(z, 1.5), (xx, -0.25)] { + let st: String = s.into_iter().collect(); + reference += (st.as_str(), c); + re += (st.as_str(), c); + } + } + // Orbits WITH a stabilizer, where a global |G| rescale would be wrong: + // "ZZZZ" is translation-invariant (|orbit| = 1) and "ZIZI" has period 2. + for (st, c) in [("ZZZZ", 0.75), ("ZIZI", 2.0), ("IZIZ", -0.5)] { + reference += (st, c); + re += (st, c); + } + // `im` needs an entry to exist; a zero coefficient must not survive. + im += ("IIII", 0.0); + + symmetry_merge_pauli_sum(&mut reference, &group); + momentum_merge_pauli_sum_pair(&mut re, &mut im, &group, &[0]); + + let expected: FxHashMap<_, f64> = reference.iter().map(|(w, c)| (*w, *c)).collect(); + let got: FxHashMap<_, f64> = re.iter().map(|(w, c)| (*w, *c)).collect(); + assert_eq!(expected.len(), got.len(), "basis sizes differ"); + for (w, &c) in &expected { + let g = *got.get(w).unwrap_or_else(|| panic!("missing rep {w:?}")); + assert!( + (c - g).abs() < 1e-12, + "rep {w:?}: symmetry_merge gave {c}, momentum_merge gave {g}" + ); + } + assert_eq!(im.len(), 0, "a purely real input must leave `im` empty"); +} + +/// A momentum-`k` eigenstate folds onto a single orbit rep whose +/// coefficient is the *summing* projector `Σ_{p ∈ orbit} χ_k(g_p) · c_p`, +/// computed here directly from the group API. +#[test] +fn momentum_merge_pair_matches_summing_projector() { + use crate::config::indexmap::ByteFxHashF64; + + type Cfg = ByteFxHashF64<1>; + let n = 4usize; + let group = TranslationGroup::chain_1d(n); + + for k in 0..n as i32 { + let (mut re, mut im) = seed_z_momentum_pair::(n, k); + // Coefficient of each orbit member before merging. + let before: FxHashMap> = (0..n) + .map(|a| { + let mut s: Vec = vec!['I'; n]; + s[a] = 'Z'; + let phase = -2.0 * PI * (k as f64) * (a as f64) / (n as f64); + ( + word(&s.into_iter().collect::()), + Complex::from_polar(1.0, phase), + ) + }) + .collect(); + + momentum_merge_pauli_sum_pair(&mut re, &mut im, &group, &[k]); + + let got_re: FxHashMap<_, f64> = re.iter().map(|(w, c)| (*w, *c)).collect(); + let got_im: FxHashMap<_, f64> = im.iter().map(|(w, c)| (*w, *c)).collect(); + assert!( + !got_re.is_empty() || !got_im.is_empty(), + "k={k}: merged away" + ); + + let rep = group.canonicalize(&word(&{ + let mut s: Vec = vec!['I'; n]; + s[0] = 'Z'; + s.into_iter().collect::() + })); + // Σ over the orbit of χ_k(g) · c_{g·rep}. + let mut expected = Complex::new(0.0, 0.0); + for (member, counter) in group.orbit_with_counters(&rep) { + expected += group.character(&[k], &counter) * before[&member]; + } + let got = Complex::new( + got_re.get(&rep).copied().unwrap_or(0.0), + got_im.get(&rep).copied().unwrap_or(0.0), + ); + assert!( + (got - expected).norm() < 1e-12, + "k={k}: rep {rep:?} expected {expected:?}, got {got:?}" + ); + } +} + +#[test] +#[should_panic(expected = "k_modes length 2 != number of generators 1")] +fn momentum_merge_pair_rejects_wrong_momentum_length() { + use crate::config::indexmap::ByteFxHashF64; + + type Cfg = ByteFxHashF64<1>; + let group = TranslationGroup::chain_1d(4); + let (mut re, mut im) = seed_z_momentum_pair::(4, 0); + momentum_merge_pauli_sum_pair(&mut re, &mut im, &group, &[0, 0]); +} + +#[test] +#[should_panic(expected = "PauliSum qubit count 4 != group qubit count 3")] +fn momentum_merge_pair_rejects_qubit_count_mismatch() { + use crate::config::indexmap::ByteFxHashF64; + + type Cfg = ByteFxHashF64<1>; + let group = TranslationGroup::chain_1d(3); + let (mut re, mut im) = seed_z_momentum_pair::(4, 0); + momentum_merge_pauli_sum_pair(&mut re, &mut im, &group, &[0]); +} + #[test] #[should_panic(expected = "generator 0 order must be nonzero")] fn rejects_zero_generator_order() { @@ -472,6 +667,83 @@ fn rejects_noncommuting_generators() { TranslationGroup::from_generators(3, vec![swap_01, swap_12], vec![2, 2]); } +#[test] +fn try_from_generators_reports_every_precondition() { + use super::GroupError; + /// `(n_qubits, perms, orders, expected error)` + type Case = (usize, Vec>, Vec, GroupError); + let cases: Vec = vec![ + ( + 2, + vec![vec![1, 0]], + vec![2, 2], + GroupError::LengthMismatch { + perms: 1, + orders: 2, + }, + ), + ( + 3, + vec![vec![1, 0]], + vec![2], + GroupError::PermutationLength { + generator: 0, + len: 2, + n_qubits: 3, + }, + ), + ( + 2, + vec![vec![1, 5]], + vec![2], + GroupError::TargetOutOfRange { + generator: 0, + target: 5, + n_qubits: 2, + }, + ), + ( + 2, + vec![vec![1, 1]], + vec![2], + GroupError::DuplicateTarget { + generator: 0, + target: 1, + }, + ), + ( + 2, + vec![vec![1, 0]], + vec![0], + GroupError::ZeroOrder { generator: 0 }, + ), + ( + 2, + vec![vec![1, 0]], + vec![4], + GroupError::OrderMismatch { + generator: 0, + declared: 4, + exact: 2, + }, + ), + ( + 3, + vec![vec![1, 0, 2], vec![0, 2, 1]], + vec![2, 2], + GroupError::NonCommuting { left: 0, right: 1 }, + ), + ]; + for (n_qubits, perms, orders, expected) in cases { + let err = TranslationGroup::try_from_generators(n_qubits, perms, orders) + .expect_err("must be rejected"); + assert_eq!(err, expected); + } + // Valid input still constructs, and matches the panicking constructor. + let group = TranslationGroup::try_from_generators(4, vec![vec![1, 2, 3, 0]], vec![4]).unwrap(); + assert_eq!(group.order(), TranslationGroup::chain_1d(4).order()); +} + #[test] fn rejects_zero_lattice_dimensions() { assert!(std::panic::catch_unwind(|| TranslationGroup::chain_1d(0)).is_err()); @@ -554,3 +826,277 @@ fn rejects_group_order_overflow() { }; assert!(std::panic::catch_unwind(|| { super::group::checked_group_order(&orders) }).is_err()); } + +// --------------------------------------------------------------------------- +// Fast canonicalization paths (odometer by index, masked-shift generators, +// least-rotation for chain/ladder) against straightforward references. +// --------------------------------------------------------------------------- + +fn xorshift(seed: u64) -> impl FnMut() -> u64 { + let mut rng = seed; + move || { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + rng + } +} + +/// The pre-optimisation `canonicalize_in_sector`: one counter-carrying +/// traversal, first lex-min wins, stabilizer checked on exact numerators. +fn reference_in_sector( + g: &TranslationGroup, + w: &PauliWord, + k: &[i32], +) -> Option<(PauliWord, Vec, usize)> +where + A: ppvm_traits::PauliStorage, + S: std::hash::BuildHasher + Clone + Default + ppvm_traits::HashFinalize, +{ + let mut best: Option<(PauliWord, Vec)> = None; + let mut stabilizer = 0usize; + for (candidate, counter) in g.orbit_with_counters(w) { + if candidate == *w { + if g.character_numerator(k, &counter) != 0 { + return None; + } + stabilizer += 1; + } + if best.as_ref().is_none_or(|(b, _)| candidate < *b) { + best = Some((candidate, counter)); + } + } + let (rep, from_word) = best.unwrap(); + let shift = (0..g.n_generators()) + .map(|i| { + let o = g.generator_order(i); + (o - from_word[i]) % o + }) + .collect(); + Some((rep, shift, g.order() / stabilizer)) +} + +/// Structured (every period dividing `l`, empty planes) plus random words. +fn test_words(n: usize, l: usize, seed: u64) -> Vec { + let alphabet = ['I', 'X', 'Z', 'Y']; + let mut next = xorshift(seed); + let mut cases = vec!["I".repeat(n), "Z".repeat(n), "X".repeat(n)]; + for p in 1..=l { + if l.is_multiple_of(p) { + let cell: String = (0..p).map(|j| alphabet[(j + 1) % 4]).collect(); + let mut s = String::new(); + while s.len() < n { + s.push_str(&cell); + } + s.truncate(n); + cases.push(s); + } + } + for _ in 0..200 { + let sparse = next() & 1 == 0; + let s: String = (0..n) + .map(|q| { + let v = (next() >> (q % 32)) as usize; + if sparse && !v.is_multiple_of(4) { + 'I' + } else { + alphabet[v % 4] + } + }) + .collect(); + cases.push(s); + } + cases +} + +#[test] +fn masked_shift_generator_matches_the_per_qubit_gather() { + // The word-parallel generator application must reproduce the plain + // permutation gather exactly, for every generator of every layout — + // including strides that are not 1 (the y/z axes of a torus) and + // block lengths that do not divide 64. + type W64 = PauliWord<[u64; 2], fxhash::FxBuildHasher, true>; + type W32x4 = PauliWord<[u32; 4], fxhash::FxBuildHasher, true>; + let mut next = xorshift(0x9E37_79B9_7F4A_7C15); + let groups = [ + ("chain_1d(7)", TranslationGroup::chain_1d(7)), + ("chain_1d(64)", TranslationGroup::chain_1d(64)), + ("ladder(5,2)", TranslationGroup::ladder(5, 2)), + ("torus_2d(3,5)", TranslationGroup::torus_2d(3, 5)), + ("torus_3d(3,3,3)", TranslationGroup::torus_3d(3, 3, 3)), + ("torus_3d(5,5,5)", TranslationGroup::torus_3d(5, 5, 5)), + ]; + for (name, g) in groups { + let n = g.n_qubits(); + for gi in 0..g.n_generators() { + let rot = g.rotations[gi] + .as_ref() + .unwrap_or_else(|| panic!("{name}: generator {gi} is a block rotation")); + for _ in 0..200 { + let mut w: W64 = PauliWord::new(n); + let mut w32: W32x4 = PauliWord::new(n); + for q in 0..n { + let v = next(); + if v.is_multiple_of(3) { + w.set_xbit(q, true); + w32.set_xbit(q, true); + } + if (v >> 8).is_multiple_of(3) { + w.set_zbit(q, true); + w32.set_zbit(q, true); + } + } + let perm = &g.perms[gi]; + let mut want: W64 = PauliWord::new(n); + let mut want32: W32x4 = PauliWord::new(n); + for (q, &pq) in perm.iter().enumerate() { + if w.get_xbit(q) { + want.set_xbit(pq as usize, true); + want32.set_xbit(pq as usize, true); + } + if w.get_zbit(q) { + want.set_zbit(pq as usize, true); + want32.set_zbit(pq as usize, true); + } + } + let got = TranslationGroup::apply_block_rotation(&w, rot).expect("fast path"); + assert_eq!(got, want, "{name}: generator {gi} mismatch"); + // 32-bit chunk storage (the wasm32 word layout) too. + let got32 = TranslationGroup::apply_block_rotation(&w32, rot).expect("fast path"); + assert_eq!( + got32, want32, + "{name}: generator {gi} mismatch (u32 chunks)" + ); + } + } + } +} + +#[test] +fn block_cyclic_canonicalizer_matches_the_odometer() { + // The O(N) least-rotation path must return bit-identical results to the + // O(|G|·N) walk — same rep AND same shift index (it sets the momentum + // phase) AND the same stabilizer. Stabilised words are the interesting + // case: the minimising rotation is not unique there. + type W32 = PauliWord<[u8; 4], fxhash::FxBuildHasher, true>; + for g in [ + TranslationGroup::chain_1d(6), + TranslationGroup::chain_1d(7), // prime order: no proper periods + TranslationGroup::chain_1d(8), + TranslationGroup::ladder(5, 2), + TranslationGroup::ladder(6, 2), + TranslationGroup::ladder(6, 3), + ] { + let n = g.n_qubits(); + let bc = g.block_cyclic.expect("expected the fast path"); + for s in test_words(n, g.order(), 0x2545_F491_4F6C_DD1D) { + let w = W32::from(s.as_str()); + for member in g.orbit(&w) { + let (rep, r, step) = g.canonicalize_block_cyclic(&member, bc); + let (rep_o, r_o, stab_o) = g.canonicalize_odometer(&member, |_| true); + assert_eq!(rep, rep_o, "rep mismatch on {s}"); + assert_eq!(r, r_o, "shift mismatch on {s}"); + assert_eq!(g.order() / step, stab_o, "stabilizer mismatch on {s}"); + let mut cur = rep; + for _ in 0..r { + cur = g.apply_generator(&cur, 0); + } + assert_eq!(cur, member, "shift {r} does not reproduce {s}"); + } + } + } +} + +#[test] +fn multi_generator_groups_keep_the_odometer_path() { + assert!(TranslationGroup::torus_2d(2, 3).block_cyclic.is_none()); + assert!(TranslationGroup::torus_3d(2, 2, 2).block_cyclic.is_none()); + // A 4-cycle that is not the block-aligned `j → j+1` one: 0→2→1→3→0. + let g = TranslationGroup::from_generators(4, vec![vec![2u32, 3, 1, 0]], vec![4]); + assert!(g.block_cyclic.is_none()); + assert!(g.rotations[0].is_none()); + for member in g.orbit(&word("XZII")) { + assert_eq!(g.canonicalize(&member), g.canonicalize(&word("XZII"))); + } +} + +#[test] +fn indexed_sector_canonicalization_matches_the_counter_reference() { + // Every fast entry point must reproduce the straightforward + // counter-carrying traversal: rep, shift, orbit size, and `None` for + // stabilizer-incompatible orbits — on free orbits, stabilized orbits, + // and a non-faithful action (two generators with the same permutation). + type W16 = PauliWord<[u8; 2], fxhash::FxBuildHasher, true>; + let shift6: Vec = (0..6).map(|q| ((q + 1) % 6) as u32).collect(); + let groups: Vec<(TranslationGroup, Vec>)> = vec![ + ( + TranslationGroup::chain_1d(6), + vec![vec![0], vec![1], vec![2], vec![3]], + ), + ( + TranslationGroup::ladder(4, 2), + vec![vec![0], vec![1], vec![2]], + ), + ( + TranslationGroup::torus_2d(2, 3), + vec![vec![0, 0], vec![1, 0], vec![0, 1], vec![1, 2]], + ), + ( + TranslationGroup::torus_2d(4, 3), + vec![vec![0, 0], vec![2, 0], vec![1, 1]], + ), + ( + TranslationGroup::from_generators(6, vec![shift6.clone(), shift6], vec![6, 6]), + vec![vec![0, 0], vec![1, 5], vec![1, 0]], + ), + ]; + for (g, sectors) in groups { + let n = g.n_qubits(); + for k in sectors { + let table = g.character_table(&k); + assert_eq!(table.len(), g.order()); + for idx in 0..g.order() { + let cnt = g.counter_from_index(idx); + assert_eq!(table.value(idx), g.character(&k, &cnt), "table entry {idx}"); + assert_eq!(table.is_trivial(idx), g.character_numerator(&k, &cnt) == 0); + } + for s in test_words(n, g.order().min(n), 0x1234_5678_9ABC_DEF1) { + let w = W16::from(s.as_str()); + let want = reference_in_sector(&g, &w, &k); + let got = g.canonicalize_in_sector(&w, &k); + assert_eq!(got, want, "canonicalize_in_sector on {s}, k={k:?}"); + let got_idx = g.canonicalize_in_sector_indexed(&w, &table); + assert_eq!( + got_idx.map(|(r, i, o)| (r, g.counter_from_index(i), o)), + want, + "canonicalize_in_sector_indexed on {s}, k={k:?}" + ); + // The unconditional forms agree on rep and shift. + let (rep_ref, shift_ref, _) = + reference_in_sector(&g, &w, &vec![0; k.len()]).unwrap(); + assert_eq!(g.canonicalize(&w), rep_ref); + assert_eq!(g.canonicalize_with_shift(&w), (rep_ref, shift_ref)); + // Returned reps carry a valid hash (they become map keys). + let mut rehashed = rep_ref; + rehashed.rehash(); + assert_eq!( + fxhash::hash64(&g.canonicalize(&w)), + fxhash::hash64(&rehashed), + "stale hash on the rep of {s}" + ); + } + } + } +} + +#[test] +fn orbit_yields_hashed_words() { + // `apply_generator` no longer rehashes; `orbit` must still yield words + // whose cached hash matches their content. + let g = TranslationGroup::torus_2d(3, 2); + for member in g.orbit(&word("XZIIYI")) { + let mut fresh = member; + fresh.rehash(); + assert_eq!(fxhash::hash64(&member), fxhash::hash64(&fresh)); + } +} diff --git a/crates/ppvm-python-native/Cargo.toml b/crates/ppvm-python-native/Cargo.toml index 7d948575b..e926b570e 100644 --- a/crates/ppvm-python-native/Cargo.toml +++ b/crates/ppvm-python-native/Cargo.toml @@ -13,7 +13,14 @@ test = false [dependencies] bnum = "0.13.0" +# mimalloc reduces peak RSS for the allocation-heavy adaptive-Pauli (lindblad) +# paths; installed as the global allocator in lib.rs. +mimalloc = { version = "0.1", default-features = false } +num = "0.4.3" +# numpy 0.29 pairs with pyo3 0.29 (used by the lindblad + symmetry array bindings). +numpy = "0.29" paste = "1.0.15" +ppvm-lindblad = { version = "0.1.0", path = "../ppvm-lindblad" } ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } ppvm-stim = { version = "0.1.0", path = "../ppvm-stim", features = ["rayon"] } ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau" } diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 44a47b83d..6657b5451 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -48,6 +48,104 @@ macro_rules! create_interface_loss_methods { }; } +macro_rules! create_interface_symmetry_methods { + // Skip loss variants: LossyPauliWord canonicalization would need + // simultaneous permutation of the loss bitmap, which we don't + // implement here. + ($name: ident, $type: ident, true) => {}; + ($name: ident, $type: ident, false) => { + #[pymethods] + impl $name { + /// Symmetry-merge this PauliSum in place: replace every + /// Pauli word by its canonical orbit representative under + /// `group`, accumulating coefficients on collision. Reduces + /// entry count by up to `|group|×` for translation-invariant + /// operators. + /// + /// See `ppvm.TranslationGroup` for constructors + /// (`chain_1d`, `torus_2d`, `torus_3d`, `ladder`). + /// + /// Plain real-coefficient merge (the `k=0` symmetry sector). + /// For non-trivial momentum sectors use `momentum_merge`. + pub fn symmetry_merge( + &mut self, + group: &crate::symmetry::TranslationGroup, + ) -> pyo3::PyResult<()> { + if self.inner.n_qubits() != group.core().n_qubits() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "PauliSum has {} qubits but the TranslationGroup acts on {}", + self.inner.n_qubits(), + group.core().n_qubits(), + ))); + } + ppvm_pauli_sum::symmetry::symmetry_merge_pauli_sum(&mut self.inner, group.core()); + Ok(()) + } + + /// Phase-aware (momentum-sector) merge for a complex operator + /// carried as a *real pair*: `self` is the real part, `other` + /// the imaginary part of `O = self + i·other`. Both are + /// overwritten in place with the orbit-representative form + /// projected onto momentum sector `momentum` (one integer mode + /// per group generator; `[0,…]` is the trivial sector and + /// reduces to `symmetry_merge`). This generalizes + /// `symmetry_merge` to k != 0 while keeping real coefficients on + /// the Python side — the only place complex arithmetic appears + /// is the internal character-weighted fold, which like + /// `symmetry_merge` *sums* over each orbit (so the merge is + /// idempotent on every orbit, free or stabilized). + /// + /// `self` and `other` must be distinct objects with identical + /// qubit count. After a translation-covariant gate layer this + /// is exact; under a generic Trotter step it carries the same + /// O(dt^{p+1}) equivariance error as the k=0 merge. + #[pyo3(signature = (other, group, momentum))] + pub fn momentum_merge( + &mut self, + other: &Bound<'_, Self>, + group: &crate::symmetry::TranslationGroup, + momentum: Vec, + ) -> pyo3::PyResult<()> { + // `self` is already mutably borrowed, so passing the same + // object twice fails here — report that rather than letting + // PyO3's raw borrow error surface. + let mut other = other.try_borrow_mut().map_err(|_| { + pyo3::exceptions::PyValueError::new_err( + "momentum_merge: `self` and `other` must be distinct \ + PauliSum objects (got the same one twice)", + ) + })?; + let n_q = group.core().n_qubits(); + for (label, n) in [ + ("self", self.inner.n_qubits()), + ("other", other.inner.n_qubits()), + ] { + if n != n_q { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "{label} PauliSum has {n} qubits but the \ + TranslationGroup acts on {n_q}", + ))); + } + } + if momentum.len() != group.core().n_generators() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "momentum has {} entries but the group has {} generators", + momentum.len(), + group.core().n_generators(), + ))); + } + ppvm_pauli_sum::symmetry::momentum_merge_pauli_sum_pair( + &mut self.inner, + &mut other.inner, + group.core(), + &momentum, + ); + Ok(()) + } + } + }; +} + macro_rules! create_strategy { (false, $min_abs_coeff:ident, $max_pauli_weight:ident, $_max_loss_weight:ident) => { CombinedStrategy( @@ -403,6 +501,12 @@ macro_rules! create_interface { } } + // `symmetry_merge` only makes sense on non-loss variants — the + // canonicalization permutes qubit positions and the loss + // bitmap would need a parallel permutation that we don't + // attempt here. + create_interface_symmetry_methods!($name, $type, $loss); + create_interface_loss_methods!($name, $type, $loss); }; } diff --git a/crates/ppvm-python-native/src/lib.rs b/crates/ppvm-python-native/src/lib.rs index b323c1adc..e4f9228c8 100644 --- a/crates/ppvm-python-native/src/lib.rs +++ b/crates/ppvm-python-native/src/lib.rs @@ -1,13 +1,23 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 +// mimalloc returns freed pages to the kernel more aggressively than the +// default system allocator. This materially reduces peak RSS for the +// allocation-heavy adaptive-Pauli paths (leakage + generator each +// allocate hundreds of MB of transient Vec data per pc_step). +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + use pyo3::exceptions::PyValueError; use pyo3::prelude::*; pub mod interface; pub mod interface_tableau; pub mod interface_tableau_sum; +pub mod lindblad; +pub mod pauli_arr; pub mod stim_program; +pub mod symmetry; pub(crate) fn flat_pairs(targets: &[usize]) -> PyResult> { if !targets.len().is_multiple_of(2) { @@ -298,4 +308,18 @@ pub mod _core { // Stim #[pymodule_export] pub use crate::stim_program::PyStimProgram; + + // Lindbladian time evolution + #[pymodule_export] + pub use crate::lindblad::LindbladSpec; + + // Symmetry merging + #[pymodule_export] + pub use crate::symmetry::TranslationGroup; + #[pymodule_export] + pub use crate::symmetry::canonicalize_basis_arr; + #[pymodule_export] + pub use crate::symmetry::canonicalize_basis_arr_complex; + #[pymodule_export] + pub use crate::symmetry::check_momentum_sector_arr; } diff --git a/crates/ppvm-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs new file mode 100644 index 000000000..796fcb995 --- /dev/null +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -0,0 +1,532 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! PyO3 wrapper around [`ppvm_lindblad::LindbladSpec`]. +//! +//! All algorithmic work — Pauli arithmetic, active-site iteration, and the +//! dissipator branches (Hermitian Pauli fast path vs general complex Pauli +//! sum) — lives in the [`ppvm_lindblad`] crate. This module is responsible +//! only for the Python boundary: decoding the `(N, n_qubits)` numpy uint8 +//! arrays into [`ppvm_lindblad::Word`] vectors, and re-encoding outputs +//! back into numpy. +//! +//! The core crate is const-generic in the Pauli-word width. [`AnySpec`] +//! holds a spec at the narrowest of the 128/256/512-qubit widths that fits +//! `n_qubits` (picked once, at construction), and every method dispatches +//! on it with [`with_spec!`]. Registers of at most 128 qubits use exactly +//! the historical word layout, so nothing changes for them. + +use std::collections::HashMap; + +use num::Complex; +use numpy::{Complex64, IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use ppvm_lindblad::{ + JumpInput, LindbladSpec as CoreSpec, MAX_SUPPORTED_QUBITS, WIDTHS, Word, chunks_for, + word_from_codes, +}; +use pyo3::{exceptions::PyValueError, prelude::*}; + +type PyPauliMap<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); +type PyPauliMapComplex<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); +type PyCoo<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +pub(crate) fn map_err(e: ppvm_lindblad::Error) -> PyErr { + PyValueError::new_err(e.to_string()) +} + +/// Reject a basis that contains the same Pauli word at two distinct rows. +/// Duplicate rows would silently overwrite each other in the generator's +/// row-index map and produce an incorrect sparse matrix. +fn assert_basis_unique(basis: &[Word]) -> PyResult<()> { + let mut seen: HashMap<&Word, usize> = HashMap::with_capacity(basis.len()); + for (i, w) in basis.iter().enumerate() { + if let Some(prev) = seen.insert(w, i) { + return Err(PyValueError::new_err(format!( + "basis contains duplicate Pauli word at row {prev} and row {i}" + ))); + } + } + Ok(()) +} + +use crate::pauli_arr::{ + check_coeffs_len, check_group_qubits, check_momentum_len, decode_basis, encode_basis, +}; + +/// Convert the `(pauli_string, re, im)` triple encoding used across the +/// Python boundary into a complex Pauli linear combination. +fn to_lincomb(terms: Vec<(String, f64, f64)>) -> Vec<(String, Complex)> { + terms + .into_iter() + .map(|(s, re, im)| (s, Complex::new(re, im))) + .collect() +} + +/// Pack `Vec<(Word, f64)>` into the standard PyO3 return shape. +fn pack_pauli_map<'py, const C: usize>( + py: Python<'py>, + pairs: Vec<(Word, f64)>, + n_qubits: usize, +) -> PyResult> { + let (words, coeffs): (Vec>, Vec) = pairs.into_iter().unzip(); + let basis_arr = encode_basis(py, &words, n_qubits)?; + Ok((basis_arr, coeffs.into_pyarray(py))) +} + +/// A [`CoreSpec`] at one of the [`WIDTHS`] (128, 256 or 512 qubits). +enum AnySpec { + W128(CoreSpec<{ WIDTHS[0] }>), + W256(CoreSpec<{ WIDTHS[1] }>), + W512(CoreSpec<{ WIDTHS[2] }>), +} + +/// Run `$body` with `$spec` bound to the concrete-width spec inside +/// `$inner: &AnySpec` and `$C` to its chunk count. +macro_rules! with_spec { + ($inner:expr, $spec:ident, $C:ident => $body:expr) => { + match $inner { + AnySpec::W128($spec) => { + #[allow(dead_code)] + const $C: usize = WIDTHS[0]; + $body + } + AnySpec::W256($spec) => { + #[allow(dead_code)] + const $C: usize = WIDTHS[1]; + $body + } + AnySpec::W512($spec) => { + #[allow(dead_code)] + const $C: usize = WIDTHS[2]; + $body + } + } + }; +} + +/// Kossakowski operators `A_n` (Pauli lincombs) and the pair matrix `K`. +type Kossakowski<'a> = (&'a [Vec<(String, Complex)>], &'a [Vec>]); + +/// Build the core spec at width `C` and attach the optional Kossakowski +/// dissipator. +fn build_spec( + n_qubits: usize, + h: &[(String, f64)], + jumps: &[JumpInput], + koss: Option>, +) -> PyResult> { + let mut spec = CoreSpec::::new(n_qubits, h, jumps).map_err(map_err)?; + if let Some((ops, k)) = koss { + spec.add_kossakowski(ops, k).map_err(map_err)?; + } + Ok(spec) +} + +/// PyO3 facade exposing [`ppvm_lindblad::LindbladSpec`] to Python. +#[pyclass] +pub struct LindbladSpec { + inner: AnySpec, +} + +#[pymethods] +impl LindbladSpec { + /// Construct a Lindbladian spec from Hamiltonian terms and jump operators. + /// + /// `jump_lincombs[k]` is a list of `(pauli_string, real, imag)` triples + /// encoding `L_k = Σ_a (re + i·im) P_a`. A length-1 jump with `im == 0` + /// is routed to the Hermitian-Pauli fast path (with rate scaled by `re²`). + /// + /// `kossakowski_ops` / `kossakowski_k` optionally add a Kossakowski-form + /// dissipator `D*(O) = Σ_nm K_nm (A_n† O A_m − ½{A_n†A_m, O})`: + /// `kossakowski_ops[i]` is the Pauli lincomb of `A_i` in the same triple + /// encoding, `kossakowski_k[n][m] = (re, im)` the Hermitian pair matrix. + #[new] + #[pyo3(signature = (n_qubits, h_terms, h_coeffs, jump_lincombs, jump_rates, + kossakowski_ops = vec![], kossakowski_k = vec![]))] + fn new( + n_qubits: usize, + h_terms: Vec, + h_coeffs: Vec, + jump_lincombs: Vec>, + jump_rates: Vec, + kossakowski_ops: Vec>, + kossakowski_k: Vec>, + ) -> PyResult { + if h_terms.len() != h_coeffs.len() { + return Err(PyValueError::new_err( + "h_terms and h_coeffs must have the same length", + )); + } + if jump_lincombs.len() != jump_rates.len() { + return Err(PyValueError::new_err( + "jump_lincombs and jump_rates must have the same length", + )); + } + let h: Vec<(String, f64)> = h_terms.into_iter().zip(h_coeffs).collect(); + let jumps: Vec = jump_lincombs + .into_iter() + .zip(jump_rates) + .map(|(lincomb, rate)| JumpInput { + lincomb: to_lincomb(lincomb), + rate, + }) + .collect(); + let ops: Vec)>> = + kossakowski_ops.into_iter().map(to_lincomb).collect(); + let k: Vec>> = kossakowski_k + .into_iter() + .map(|row| { + row.into_iter() + .map(|(re, im)| Complex::new(re, im)) + .collect() + }) + .collect(); + let koss = (!ops.is_empty() || !k.is_empty()).then_some((&ops[..], &k[..])); + let inner = match chunks_for(n_qubits) { + Some(c) if c == WIDTHS[0] => AnySpec::W128(build_spec(n_qubits, &h, &jumps, koss)?), + Some(c) if c == WIDTHS[1] => AnySpec::W256(build_spec(n_qubits, &h, &jumps, koss)?), + Some(_) => AnySpec::W512(build_spec(n_qubits, &h, &jumps, koss)?), + None => { + return Err(PyValueError::new_err(format!( + "LindbladSpec supports n_qubits ≤ {MAX_SUPPORTED_QUBITS}; got {n_qubits}" + ))); + } + }; + Ok(Self { inner }) + } + + #[getter] + fn n_qubits(&self) -> usize { + with_spec!(&self.inner, inner, C => { + inner.n_qubits() + }) + } + + #[getter] + fn num_h_terms(&self) -> usize { + with_spec!(&self.inner, inner, C => { + inner.num_h_terms() + }) + } + + #[getter] + fn num_jump_terms(&self) -> usize { + with_spec!(&self.inner, inner, C => { + inner.num_jump_terms() + }) + } + + /// Apply `L*` to a single Pauli string `p`. + fn action<'py>( + &self, + py: Python<'py>, + p: PyReadonlyArray1<'py, u8>, + ) -> PyResult> { + with_spec!(&self.inner, inner, C => { + let p_slice = p.as_slice()?; + let p_word = word_from_codes::(p_slice).map_err(map_err)?; + let pairs = inner.action(&p_word); + pack_pauli_map(py, pairs, inner.n_qubits()) + }) + } + + /// Off-basis component of `L*( Σ_j coeffs[j] · basis[j] )`. + #[pyo3(signature = (basis, coeffs, protected = None))] + fn leakage<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + protected: Option>, + ) -> PyResult> { + with_spec!(&self.inner, inner, C => { + let n_q = inner.n_qubits(); + let basis_view = basis.as_array(); + let basis_words = decode_basis::(&basis_view, n_q)?; + let coeffs_slice = coeffs.as_slice()?; + check_coeffs_len(coeffs_slice.len(), basis_words.len())?; + let protected_words: Vec> = if let Some(ref prot) = protected { + let pv = prot.as_array(); + decode_basis::(&pv, n_q)? + } else { + Vec::new() + }; + let pairs = inner + .leakage(&basis_words, coeffs_slice, &protected_words) + .map_err(map_err)?; + pack_pauli_map(py, pairs, n_q) + }) + } + + /// One predictor-corrector adaptive step. + /// + /// Internally: expand basis with first-hop leakage, predictor step + /// (`exp(dt·M)`), expand again with second-hop leakage from the + /// predicted state, then redo the step from the pre-step coefficients + /// on the doubly-enlarged basis. The matrix exponential is computed in + /// Rust via `quspin-expm`; no scipy required. + /// + /// Returns `(new_basis, new_coeffs)`. + /// + /// `max_basis` is a hard rank cap on the retained basis; `admit_basis` + /// (when `> max_basis`) bounds in-step enrichment instead, so the final + /// cap selects the top-`max_basis` strings over the whole union + /// (displacement truncation). `drop_tol` prunes by magnitude after the + /// step; `tau_add` filters leakage admission by inflow rate. Protected + /// words are never dropped. + #[pyo3(signature = ( + basis, coeffs, dt, max_basis, + drop_tol = 0.0, + protected = None, + num_threads = None, + admit_basis = None, + tau_add = None, + ))] + #[allow(clippy::too_many_arguments)] + fn pc_step<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + dt: f64, + max_basis: usize, + drop_tol: f64, + protected: Option>, + num_threads: Option, + admit_basis: Option, + tau_add: Option, + ) -> PyResult> { + with_spec!(&self.inner, inner, C => { + let n_q = inner.n_qubits(); + let basis_view = basis.as_array(); + let mut basis_words = decode_basis::(&basis_view, n_q)?; + assert_basis_unique(&basis_words)?; + let mut coeffs_vec = coeffs.as_slice()?.to_vec(); + check_coeffs_len(coeffs_vec.len(), basis_words.len())?; + let protected_words: Vec> = if let Some(ref p) = protected { + decode_basis::(&p.as_array(), n_q)? + } else { + Vec::new() + }; + inner + .pc_step( + &mut basis_words, + &mut coeffs_vec, + dt, + &protected_words, + &ppvm_lindblad::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + num_threads, + }, + ) + .map_err(map_err)?; + + // Pack output. Basis may have grown; coeffs has the same new length. + let pairs: Vec<(Word, f64)> = basis_words.into_iter().zip(coeffs_vec).collect(); + pack_pauli_map(py, pairs, n_q) + }) + } + + /// Same as [`Self::pc_step`] but also returns a dict mapping phase + /// name → microseconds spent in that phase, for profiling. + #[pyo3(signature = ( + basis, coeffs, dt, max_basis, + drop_tol = 0.0, + protected = None, + num_threads = None, + admit_basis = None, + tau_add = None, + ))] + #[allow(clippy::too_many_arguments)] + fn pc_step_timed<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + dt: f64, + max_basis: usize, + drop_tol: f64, + protected: Option>, + num_threads: Option, + admit_basis: Option, + tau_add: Option, + ) -> PyResult<(PyPauliMap<'py>, Bound<'py, pyo3::types::PyDict>)> { + with_spec!(&self.inner, inner, C => { + let n_q = inner.n_qubits(); + let basis_view = basis.as_array(); + let mut basis_words = decode_basis::(&basis_view, n_q)?; + assert_basis_unique(&basis_words)?; + let mut coeffs_vec = coeffs.as_slice()?.to_vec(); + check_coeffs_len(coeffs_vec.len(), basis_words.len())?; + let protected_words: Vec> = if let Some(ref p) = protected { + decode_basis::(&p.as_array(), n_q)? + } else { + Vec::new() + }; + let timings = inner + .pc_step_timed( + &mut basis_words, + &mut coeffs_vec, + dt, + &protected_words, + &ppvm_lindblad::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + num_threads, + }, + ) + .map_err(map_err)?; + + let pairs: Vec<(Word, f64)> = basis_words.into_iter().zip(coeffs_vec).collect(); + let map = pack_pauli_map(py, pairs, n_q)?; + let d = pyo3::types::PyDict::new(py); + d.set_item("leakage1_us", timings.leakage1_us)?; + d.set_item("expand1_us", timings.expand1_us)?; + d.set_item("expm1_us", timings.expm1_us)?; + d.set_item("leakage2_us", timings.leakage2_us)?; + d.set_item("expand2_us", timings.expand2_us)?; + d.set_item("expm2_us", timings.expm2_us)?; + Ok((map, d)) + }) + } + + /// Per-step orbit-rep predictor-corrector evolution under + /// translation symmetry. State lives entirely in **orbit-rep form**: + /// basis contains only canonical orbit representatives, coefficients + /// are complex. The action is phase-aware: output Paulis canonicalize + /// to their orbit rep with momentum-character weight. + /// + /// Per-step memory benefit: basis is ~|group|× smaller than the + /// full-basis representation, and the reduction persists through + /// every step. + /// + /// **Pre-condition**: every row of `basis` must be the canonical + /// orbit representative of its translation orbit under `group`. + /// Pass `canonicalize_first=True` to enforce this on entry (rewrites + /// each basis row to its canonical rep; coefficients unchanged). + /// Default `False` — the caller is trusted. + /// + /// `max_basis` is a hard rank cap on the live orbit-rep basis: + /// enrichment adds at most `max_basis − basis.len()` of the largest + /// leakage reps and the post-step basis is trimmed to the top-`max_basis` + /// by `|c|` (protected reps always kept). Pass a large value for the + /// near-exact case. `drop_tol` additionally prunes by magnitude. + #[pyo3(signature = ( + basis, coeffs, dt, max_basis, + group, momentum, + drop_tol = 0.0, + protected = None, + canonicalize_first = false, + admit_basis = None, + tau_add = None, + num_threads = None, + ))] + #[allow(clippy::too_many_arguments)] + fn pc_step_orbit_rep<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, Complex64>, + dt: f64, + max_basis: usize, + group: &crate::symmetry::TranslationGroup, + momentum: PyReadonlyArray1<'py, i32>, + drop_tol: f64, + protected: Option>, + canonicalize_first: bool, + admit_basis: Option, + tau_add: Option, + num_threads: Option, + ) -> PyResult> { + with_spec!(&self.inner, inner, C => { + use num::Complex; + use ppvm_lindblad::{Sector, canonicalize_basis_to_rep}; + + let n_q = inner.n_qubits(); + let basis_view = basis.as_array(); + let mut basis_words = decode_basis::(&basis_view, n_q)?; + let coeffs_slice = coeffs.as_slice()?; + check_coeffs_len(coeffs_slice.len(), basis_words.len())?; + let mut coeffs_vec: Vec> = coeffs_slice + .iter() + .map(|c| Complex::new(c.re, c.im)) + .collect(); + let protected_words: Vec> = if let Some(ref p) = protected { + decode_basis::(&p.as_array(), n_q)? + } else { + Vec::new() + }; + let k_slice = momentum.as_slice()?; + check_momentum_len(k_slice.len(), group.core().n_generators())?; + check_group_qubits(n_q, group.core().n_qubits())?; + if canonicalize_first { + canonicalize_basis_to_rep(&mut basis_words, group.core()); + } + // Canonicalization can collapse several input rows onto one rep, + // and the step indexes the basis by Pauli word — so uniqueness is + // checked after the rewrite, not before. + assert_basis_unique(&basis_words)?; + inner + .pc_step_orbit_rep( + &mut basis_words, + &mut coeffs_vec, + dt, + &protected_words, + &Sector::new(group.core(), k_slice), + &ppvm_lindblad::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + num_threads, + }, + ) + .map_err(map_err)?; + + let out_coeffs: Vec = coeffs_vec + .iter() + .map(|c| Complex64::new(c.re, c.im)) + .collect(); + let basis_arr = encode_basis(py, &basis_words, n_q)?; + Ok((basis_arr, out_coeffs.into_pyarray(py))) + }) + } + + /// Sparse generator matrix in COO form: `(rows, cols, vals)`. + fn generator<'py>( + &self, + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + ) -> PyResult> { + with_spec!(&self.inner, inner, C => { + let n_q = inner.n_qubits(); + let basis_view = basis.as_array(); + let basis_words = decode_basis::(&basis_view, n_q)?; + assert_basis_unique(&basis_words)?; + let triplets = inner.generator(&basis_words); + let total = triplets.len(); + let mut rows = Vec::with_capacity(total); + let mut cols = Vec::with_capacity(total); + let mut vals = Vec::with_capacity(total); + for (r, c, v) in triplets { + rows.push(r as u64); + cols.push(c as u64); + vals.push(v); + } + Ok(( + rows.into_pyarray(py), + cols.into_pyarray(py), + vals.into_pyarray(py), + )) + }) + } +} diff --git a/crates/ppvm-python-native/src/pauli_arr.rs b/crates/ppvm-python-native/src/pauli_arr.rs new file mode 100644 index 000000000..c7d08b0e9 --- /dev/null +++ b/crates/ppvm-python-native/src/pauli_arr.rs @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Shared codec and argument validation for the `(N, n_qubits)` uint8 +//! Pauli-basis array representation used across the Lindblad and +//! symmetry bindings. +//! +//! Every `*_arr` entry point decodes an incoming basis array into packed +//! [`Word`]s, validates the companion `coeffs` / `momentum` lengths, and +//! re-encodes the result on the way out. These are those three steps. + +use numpy::{IntoPyArray, PyArray2, PyArrayMethods}; +use ppvm_lindblad::{Word, codes_from_word, word_from_codes}; +use pyo3::{exceptions::PyValueError, prelude::*}; + +use crate::lindblad::map_err; + +/// Run `$body` with `$C` bound to the narrowest Pauli-word chunk count +/// that holds `$n` qubits (see [`ppvm_lindblad::chunks_for`]); a +/// `ValueError` above [`ppvm_lindblad::MAX_SUPPORTED_QUBITS`]. +/// +/// Mirrors the width `LindbladSpec` picks, so `n <= 128` keeps the +/// original 128-qubit word layout. +macro_rules! with_width { + ($n:expr, $C:ident => $body:expr) => {{ + let n: usize = $n; + match ppvm_lindblad::chunks_for(n) { + Some(c) if c == ppvm_lindblad::WIDTHS[0] => { + const $C: usize = ppvm_lindblad::WIDTHS[0]; + $body + } + Some(c) if c == ppvm_lindblad::WIDTHS[1] => { + const $C: usize = ppvm_lindblad::WIDTHS[1]; + $body + } + Some(_) => { + const $C: usize = ppvm_lindblad::WIDTHS[2]; + $body + } + None => Err(pyo3::exceptions::PyValueError::new_err(format!( + "Pauli words support n_qubits ≤ {}; got {n}", + ppvm_lindblad::MAX_SUPPORTED_QUBITS + ))), + } + }}; +} +pub(crate) use with_width; + +/// Decode a `(N, n_qubits)` uint8 ndarray view into `N` packed [`Word`]s. +pub(crate) fn decode_basis( + view: &numpy::ndarray::ArrayView2, + n_qubits: usize, +) -> PyResult>> { + let n_basis = view.shape()[0]; + let n_cols = view.shape()[1]; + if n_cols != n_qubits { + return Err(PyValueError::new_err(format!( + "basis has {n_cols} columns but spec.n_qubits = {n_qubits}" + ))); + } + let mut out = Vec::with_capacity(n_basis); + let mut row_buf = vec![0u8; n_qubits]; + for i in 0..n_basis { + let row = view.row(i); + for (q, slot) in row_buf.iter_mut().enumerate() { + *slot = row[q]; + } + out.push(word_from_codes(&row_buf).map_err(map_err)?); + } + Ok(out) +} + +/// Encode packed [`Word`]s back into an `(M, n_qubits)` uint8 array. +pub(crate) fn encode_basis<'py, const C: usize>( + py: Python<'py>, + words: &[Word], + n_qubits: usize, +) -> PyResult>> { + let m = words.len(); + let mut flat = vec![0u8; m * n_qubits]; + for (i, w) in words.iter().enumerate() { + codes_from_word(w, &mut flat[i * n_qubits..(i + 1) * n_qubits]); + } + flat.into_pyarray(py) + .reshape([m, n_qubits]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}"))) +} + +/// Check the row width of a basis array against the qubit count a +/// [`crate::symmetry::TranslationGroup`] acts on. Reported separately from +/// [`decode_basis`]'s own width check so the error names the group rather +/// than the spec. +pub(crate) fn check_group_width( + view: &numpy::ndarray::ArrayView2, + n_qubits: usize, +) -> PyResult<()> { + let width = view.shape().get(1).copied(); + if width != Some(n_qubits) { + return Err(PyValueError::new_err(format!( + "basis has {} qubits per row but group acts on {n_qubits}", + width.unwrap_or(0) + ))); + } + Ok(()) +} + +/// Check that a [`crate::symmetry::TranslationGroup`] acts on the same +/// qubit count as the object being evolved. The core group routines +/// assert this on the first Pauli word they see, so without this the +/// mismatch surfaces as a panic from deep inside the step. +pub(crate) fn check_group_qubits(n_qubits: usize, group_n_qubits: usize) -> PyResult<()> { + if n_qubits != group_n_qubits { + return Err(PyValueError::new_err(format!( + "spec has {n_qubits} qubits but the TranslationGroup acts on {group_n_qubits}" + ))); + } + Ok(()) +} + +/// Check that a coefficient vector has one entry per basis row. +pub(crate) fn check_coeffs_len(n_coeffs: usize, n_rows: usize) -> PyResult<()> { + if n_coeffs != n_rows { + return Err(PyValueError::new_err(format!( + "coeffs has length {n_coeffs} but basis has {n_rows} rows" + ))); + } + Ok(()) +} + +/// Check that a momentum vector has one mode index per group generator. +pub(crate) fn check_momentum_len(n_modes: usize, n_generators: usize) -> PyResult<()> { + if n_modes != n_generators { + return Err(PyValueError::new_err(format!( + "momentum has {n_modes} entries but group has {n_generators} generators" + ))); + } + Ok(()) +} diff --git a/crates/ppvm-python-native/src/symmetry.rs b/crates/ppvm-python-native/src/symmetry.rs new file mode 100644 index 000000000..4d8129a8a --- /dev/null +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Python bindings for the symmetry-merging primitive. +//! +//! Exposes: +//! - [`TranslationGroup`] PyO3 class with constructors for 1D, 2D, 3D +//! tori and multi-leg ladders, plus a generic generator-list path. +//! - [`canonicalize_basis_arr`] / [`canonicalize_basis_arr_complex`] free +//! functions that merge the numpy `(basis_arr, coeffs)` representation +//! used by `Lindbladian.pc_step_arr`. + +use num::Complex; +use numpy::{Complex64, IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use ppvm_lindblad::{codes_from_word, word_from_codes}; +use ppvm_pauli_sum::symmetry as core_sym; +use pyo3::{exceptions::PyValueError, prelude::*}; + +use crate::pauli_arr::{ + check_coeffs_len, check_group_width, check_momentum_len, decode_basis, encode_basis, with_width, +}; + +type PyPauliMap<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); +type PyPauliMapComplex<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); + +/// A finite abelian symmetry group acting on qubit positions by +/// permutations. Use this to merge translation-equivalent Pauli strings +/// in either the `Lindbladian.pc_step_arr` basis or the `PauliSum` +/// dictionary, reducing per-step memory by up to `|G|×`. +/// +/// Build via the static methods: +/// - `TranslationGroup.chain_1d(n)` — 1D chain of `n` sites with PBC. +/// - `TranslationGroup.torus_2d(lx, ly)` — 2D torus; qubit `(i, j)` at +/// index `j*lx + i`. +/// - `TranslationGroup.torus_3d(lx, ly, lz)` — 3D torus; qubit +/// `(i, j, k)` at index `k*lx*ly + j*lx + i`. +/// - `TranslationGroup.ladder(l, n_legs)` — `n_legs`-leg ladder of `l` +/// sites, translation only along chain direction; qubit `(leg, j)` at +/// index `leg*l + j`. +/// - `TranslationGroup.from_generators(n_qubits, perms, orders)` — +/// arbitrary list of generator permutations + cyclic orders. +#[pyclass(frozen)] +pub struct TranslationGroup { + pub(crate) inner: core_sym::TranslationGroup, +} + +impl TranslationGroup { + /// Accessor for the underlying [`ppvm_pauli_sum::symmetry::TranslationGroup`]. + /// Used by other crate-internal modules (e.g. the PauliSum interface + /// macro) to call into the core merging API. + pub fn core(&self) -> &core_sym::TranslationGroup { + &self.inner + } +} + +/// Validate lattice extents before handing them to a core constructor, +/// which asserts these preconditions rather than reporting them. Each +/// extent must be positive and `u32`-addressable, and the qubit count +/// (their product) must not overflow. +fn check_lattice(dims: &[(&str, usize)]) -> PyResult<()> { + let mut n_qubits = 1usize; + for &(name, dim) in dims { + if dim == 0 { + return Err(PyValueError::new_err(format!("{name} must be positive"))); + } + n_qubits = n_qubits + .checked_mul(dim) + .ok_or_else(|| PyValueError::new_err(format!("qubit count overflows: {name}={dim}")))?; + } + u32::try_from(n_qubits - 1).map_err(|_| { + PyValueError::new_err(format!( + "qubit count {n_qubits} exceeds the u32-addressable range" + )) + })?; + Ok(()) +} + +#[pymethods] +impl TranslationGroup { + #[staticmethod] + pub fn chain_1d(n: usize) -> PyResult { + check_lattice(&[("n", n)])?; + Ok(Self { + inner: core_sym::TranslationGroup::chain_1d(n), + }) + } + + #[staticmethod] + pub fn torus_2d(lx: usize, ly: usize) -> PyResult { + check_lattice(&[("lx", lx), ("ly", ly)])?; + Ok(Self { + inner: core_sym::TranslationGroup::torus_2d(lx, ly), + }) + } + + #[staticmethod] + pub fn torus_3d(lx: usize, ly: usize, lz: usize) -> PyResult { + check_lattice(&[("lx", lx), ("ly", ly), ("lz", lz)])?; + Ok(Self { + inner: core_sym::TranslationGroup::torus_3d(lx, ly, lz), + }) + } + + #[staticmethod] + pub fn ladder(l: usize, n_legs: usize) -> PyResult { + check_lattice(&[("l", l), ("n_legs", n_legs)])?; + Ok(Self { + inner: core_sym::TranslationGroup::ladder(l, n_legs), + }) + } + + /// Every precondition — permutation shape and validity, exact cyclic + /// orders, pairwise commutation — is checked by the core's fallible + /// constructor, so bad generators raise `ValueError` here instead of + /// aborting. + #[staticmethod] + pub fn from_generators( + n_qubits: usize, + perms: Vec>, + orders: Vec, + ) -> PyResult { + let inner = core_sym::TranslationGroup::try_from_generators(n_qubits, perms, orders) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) + } + + /// Number of qubits this group acts on. + #[getter] + pub fn n_qubits(&self) -> usize { + self.inner.n_qubits() + } + + /// Number of generators (rank as an abelian product group). + #[getter] + pub fn n_generators(&self) -> usize { + self.inner.n_generators() + } + + /// Total group order: product of generator orders. + #[getter] + pub fn order(&self) -> usize { + self.inner.order() + } + + /// Return the canonical (lex-min) orbit representative of `pauli`. + /// `pauli` is a length-`n_qubits` uint8 array with the encoding + /// `0=I, 1=X, 2=Z, 3=Y`. Result is the same shape. + pub fn canonicalize<'py>( + &self, + py: Python<'py>, + pauli: PyReadonlyArray1<'py, u8>, + ) -> PyResult>> { + let codes = pauli.as_slice()?; + if codes.len() != self.inner.n_qubits() { + return Err(PyValueError::new_err(format!( + "pauli has length {} but group expects {} qubits", + codes.len(), + self.inner.n_qubits() + ))); + } + let mut out = vec![0u8; codes.len()]; + with_width!(codes.len(), C => { + let w = word_from_codes::(codes).map_err(|e| PyValueError::new_err(e.to_string()))?; + let canon = self.inner.canonicalize(&w); + codes_from_word(&canon, &mut out); + PyResult::Ok(()) + })?; + Ok(out.into_pyarray(py)) + } +} + +/// Phase-aware merge of a complex-coefficient `(basis_arr, coeffs)` +/// Pauli sum into orbit-rep form, projected onto momentum sector +/// `momentum`. +/// +/// `momentum` is a length-`group.n_generators` integer array of mode +/// indices; the wavenumber along generator `g` is +/// `2π · momentum[g] / group.generator_order(g)`. Use `momentum=[0, …]` +/// for the trivial (k=0) sector — equivalent to plain merging modulo the +/// `1/|orbit|` normalization this projection applies (it *averages* over +/// each orbit's distinct members; `PauliSum.momentum_merge` sums). +/// +/// If the input is **not** in sector `momentum`, the projection +/// silently throws away the other components. Use +/// [`check_momentum_sector_arr`] beforehand to validate. +#[pyfunction] +pub fn canonicalize_basis_arr_complex<'py>( + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, Complex64>, + group: &TranslationGroup, + momentum: PyReadonlyArray1<'py, i32>, +) -> PyResult> { + let basis_view = basis.as_array(); + let n_q = group.inner.n_qubits(); + check_group_width(&basis_view, n_q)?; + let coeffs_slice = coeffs.as_slice()?; + check_coeffs_len(coeffs_slice.len(), basis_view.shape()[0])?; + let k_slice = momentum.as_slice()?; + check_momentum_len(k_slice.len(), group.inner.n_generators())?; + with_width!(n_q, C => { + let mut basis_words = decode_basis::(&basis_view, n_q)?; + let mut coeffs_vec: Vec> = coeffs_slice + .iter() + .map(|c| Complex::new(c.re, c.im)) + .collect(); + + core_sym::canonicalize_pauli_sum_complex( + &mut basis_words, + &mut coeffs_vec, + &group.inner, + k_slice, + ); + + let out_coeffs: Vec = coeffs_vec + .iter() + .map(|c| Complex64::new(c.re, c.im)) + .collect(); + let basis_arr = encode_basis(py, &basis_words, n_q)?; + Ok((basis_arr, out_coeffs.into_pyarray(py))) + }) +} + +/// Verify that a `(basis_arr, complex_coeffs)` Pauli sum lies in the +/// momentum sector `momentum` under `group`. Returns `None` on pass, +/// raises a `ValueError` with diagnostic info on fail. +/// +/// `tol` is the relative tolerance on coefficient comparison; default +/// `1e-8`. +#[pyfunction] +#[pyo3(signature = (basis, coeffs, group, momentum, tol = 1e-8))] +pub fn check_momentum_sector_arr<'py>( + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, Complex64>, + group: &TranslationGroup, + momentum: PyReadonlyArray1<'py, i32>, + tol: f64, +) -> PyResult<()> { + let basis_view = basis.as_array(); + let n_q = group.inner.n_qubits(); + check_group_width(&basis_view, n_q)?; + let coeffs_slice = coeffs.as_slice()?; + check_coeffs_len(coeffs_slice.len(), basis_view.shape()[0])?; + let k_slice = momentum.as_slice()?; + check_momentum_len(k_slice.len(), group.inner.n_generators())?; + with_width!(n_q, C => { + let basis_words = decode_basis::(&basis_view, n_q)?; + let coeffs_vec: Vec> = coeffs_slice + .iter() + .map(|c| Complex::new(c.re, c.im)) + .collect(); + core_sym::check_momentum_sector(&basis_words, &coeffs_vec, &group.inner, k_slice, tol) + .map_err(|e| PyValueError::new_err(format!("{e}"))) + }) +} + +/// Merge a `(basis_arr, coeffs)` Pauli sum (the representation used by +/// `Lindbladian.pc_step_arr`) into orbit-representative form. +/// Each row of `basis_arr` is replaced by its canonical +/// representative; coefficients of rows collapsing to the same rep are +/// summed. +/// +/// Returns `(merged_basis_arr, merged_coeffs)`. Output length ≤ input +/// length. +/// +/// For dynamics that commute with `group` and initial states that are +/// `group`-invariant, this preserves all `group`-invariant expectation +/// values (Theorem 1 of Teng et al., arXiv:2512.12094). +#[pyfunction] +pub fn canonicalize_basis_arr<'py>( + py: Python<'py>, + basis: PyReadonlyArray2<'py, u8>, + coeffs: PyReadonlyArray1<'py, f64>, + group: &TranslationGroup, +) -> PyResult> { + let basis_view = basis.as_array(); + let n_q = group.inner.n_qubits(); + check_group_width(&basis_view, n_q)?; + let coeffs_slice = coeffs.as_slice()?; + check_coeffs_len(coeffs_slice.len(), basis_view.shape()[0])?; + + with_width!(n_q, C => { + let mut basis_words = decode_basis::(&basis_view, n_q)?; + let mut coeffs_vec = coeffs_slice.to_vec(); + + core_sym::canonicalize_pauli_sum(&mut basis_words, &mut coeffs_vec, &group.inner); + + let basis_arr = encode_basis(py, &basis_words, n_q)?; + Ok((basis_arr, coeffs_vec.into_pyarray(py))) + }) +} diff --git a/ppvm-python/demo/lindblad_adaptive.py b/ppvm-python/demo/lindblad_adaptive.py new file mode 100644 index 000000000..e6ee9dc1a --- /dev/null +++ b/ppvm-python/demo/lindblad_adaptive.py @@ -0,0 +1,313 @@ +# --- +# jupyter: +# jupytext: +# cell_metadata_filter: -all +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.1 +# kernelspec: +# display_name: ppvm (3.12.12) +# language: python +# name: python3 +# --- + +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +# %% [markdown] +# # Adaptive Pauli-Lindbladian time evolution +# +# Direct Heisenberg-picture evolution of a transport observable on a growing +# Pauli-string basis, without Trotterisation. Composes the three +# `ppvm.Lindbladian` primitives — `leakage`, `generator`, and the matrix +# exponential — into a predictor-corrector integrator on an all-to-all XY +# model (Kac-normalised $1/r^\alpha$ couplings) with single-site Z dephasing. +# +# The observable is kept as a finite sum over Pauli strings (the *basis*), +# and each step: +# +# 1. measures the leakage $(\mathbf 1 - P_B)\,\mathcal L^\dagger(\mathcal O)$ +# out of the current basis $B$; +# 2. adds the leakage strings above `add_tol` to $B$; +# 3. advances the coefficient vector by $\exp(dt\,M)$, where +# $M = P_B \mathcal L^\dagger P_B$ is the Lindbladian restricted to $B$; +# 4. prunes coefficients below `drop_tol`; the protected set (the target +# observable's own support) is never dropped. +# +# The matrix exponential is exact in `dt` within the basis, so there is no +# Trotter splitting error; the only approximation is the finite basis, +# monitored by the cumulative discarded weight. +# +# With `predictor_corrector=True`, the predicted state is fed back as a +# second leakage probe — enriching $B$ with the strings the predictor flows +# into before re-running the step from the pre-step state. This lifts the +# per-step adaptive-integration error from $\mathcal O(dt^2)$ to $\mathcal O(dt^3)$. +# +# The observable is the single-Z Fourier mode +# $\mathcal O_k = \sum_j \cos(k x_j) Z_j$; its decay +# $C_k(t)/C_k(0)$ is the (infinite-temperature) spin transport coefficient. + +# %% +import matplotlib.pyplot as plt +import numpy as np +import scipy.sparse as sp +from scipy.sparse.linalg import expm_multiply + +from ppvm import Lindbladian + +# %% [markdown] +# ## Parameters + +# %% +L = 8 +alpha = 3.0 +gamma = 0.1 +dt = 0.05 +steps = 20 +kmax = 3 +add_tol = 1e-8 +drop_tol = 1e-10 +max_pauli_weight = L +max_basis = 0 # 0 = no cap +predictor_corrector = True + +times = np.arange(steps + 1) * dt +k_indices = np.arange(1, kmax + 1) +k_modes = 2 * np.pi * k_indices / L +x = (np.arange(L) - L // 2 + L // 2) % L - L // 2 + + +# %% [markdown] +# ## Model +# +# All-to-all XY Hamiltonian $H = \sum_{a drop_tol: + rows.append(zterm_codes(j)) + coeffs.append(c) + basis_arr = np.array(rows, dtype=np.uint8) + coeff = np.array(coeffs, dtype=float) + index = {row.tobytes(): i for i, row in enumerate(basis_arr)} + protected_keys = set(index) + return basis_arr, coeff, index, protected_keys + + +def add_leakage_to_basis(basis_arr, probe_coeff, extend_coeffs, protected_arr, index): + """Compute leakage from `probe_coeff`, add above-threshold strings to + `basis_arr`, and pad each vector in `extend_coeffs` with zeros for the + new rows. Returns ``(basis_arr, [extended...], index, rate_below)`` where + `rate_below` is the l2 norm of the leakage too small to add.""" + leak_basis, leak_coeffs = L_op.leakage_arr(basis_arr, probe_coeff, protected_arr) + if not len(leak_coeffs): + return basis_arr, list(extend_coeffs), index, 0.0 + weights = weights_of(leak_basis) + add_mask = (np.abs(leak_coeffs) > add_tol) & (weights <= max_pauli_weight) + rate_below = float(np.linalg.norm(leak_coeffs[~add_mask])) + if not add_mask.any(): + return basis_arr, list(extend_coeffs), index, rate_below + cand = leak_basis[add_mask] + cand = cand[np.argsort(np.abs(leak_coeffs[add_mask]))[::-1]] + cand = cand[np.array([row.tobytes() not in index for row in cand])] + if max_basis: + cand = cand[: max(max_basis - len(basis_arr), 0)] + if not len(cand): + return basis_arr, list(extend_coeffs), index, rate_below + n0 = len(basis_arr) + basis_arr = np.vstack([basis_arr, cand]) + extended = [np.r_[c, np.zeros(len(cand))] for c in extend_coeffs] + for i, row in enumerate(cand): + index[row.tobytes()] = n0 + i + return basis_arr, extended, index, rate_below + + +def prune_and_cap(basis_arr, coeff, protected_keys): + """Drop below-`drop_tol` coefficients; if `max_basis` is set, cap by + keeping protected rows + largest-magnitude others.""" + keep = np.array( + [(row.tobytes() in protected_keys) or (abs(v) >= drop_tol) + for row, v in zip(basis_arr, coeff)] + ) + basis_arr = basis_arr[keep] + coeff = coeff[keep] + keys = [row.tobytes() for row in basis_arr] + index = {kk: i for i, kk in enumerate(keys)} + protected_keys = {pk for pk in protected_keys if pk in index} + if max_basis and len(basis_arr) > max_basis: + slots = max(max_basis - len(protected_keys), 0) + is_protected = np.array([kk in protected_keys for kk in keys]) + order = sorted(np.where(~is_protected)[0].tolist(), + key=lambda i: abs(coeff[i]), reverse=True) + keep2 = is_protected.copy() + keep2[order[:slots]] = True + basis_arr = basis_arr[keep2] + coeff = coeff[keep2] + keys = [row.tobytes() for row in basis_arr] + index = {kk: i for i, kk in enumerate(keys)} + protected_keys = {pk for pk in protected_keys if pk in index} + return basis_arr, coeff, index, protected_keys + + +# %% [markdown] +# ## Run one $k$-mode +# +# Returns per-step $C_k(t)$, basis size, max weight, and cumulative discarded +# weight (trapezoidal a-posteriori error estimate normalised by +# $\|\mathcal O_k\|$). + +# %% +def run_mode(kk): + basis_arr, coeff, index, protected_keys = init_mode(kk) + # `basis_arr` rebinds to a new ndarray on every vstack/slice; nothing + # mutates the initial array in place, so the target/protected views can + # simply alias it without a copy. + target_arr = basis_arr + target_coeff = coeff + protected_arr = basis_arr + norm0 = float(np.dot(coeff, coeff)) + norm_target = np.sqrt(norm0) + + ck = np.empty(steps + 1) + n_basis_t = np.empty(steps + 1, dtype=np.int64) + max_w_t = np.empty(steps + 1, dtype=np.int64) + discarded_cum = np.zeros(steps + 1) + + for nt in range(steps + 1): + # Overlap with the (fixed) initial target. + c_t = sum(coeff[index[trow.tobytes()]] * tc + for trow, tc in zip(target_arr, target_coeff) + if trow.tobytes() in index) + ck[nt] = c_t / norm0 + n_basis_t[nt] = len(basis_arr) + max_w_t[nt] = int(weights_of(basis_arr).max()) + if nt == steps: + break + + # Predictor: enrich basis with leakage from current state, then + # advance by exp(dt · M). + basis_arr, [coeff], index, rate_before = add_leakage_to_basis( + basis_arr, coeff, [coeff], protected_arr, index + ) + coeff_pre = coeff.copy() + coeff = expm_multiply(dt * generator_sparse(L_op, basis_arr), coeff) + + if predictor_corrector: + # Probe leakage with the predicted state; extend the pre-step + # vector with zeros for the new rows and re-run on the enlarged + # basis. Lifts O(dt²) -> O(dt³). + basis_arr, [coeff_pre], index, _ = add_leakage_to_basis( + basis_arr, coeff, [coeff_pre], protected_arr, index + ) + coeff = expm_multiply(dt * generator_sparse(L_op, basis_arr), coeff_pre) + + # Post-step leakage rate, for the trapezoidal error estimate. + norm2_pre_prune = float(np.dot(coeff, coeff)) + _, leak_after = L_op.leakage_arr(basis_arr, coeff, protected_arr) + rate_after = float(np.linalg.norm(leak_after)) if len(leak_after) else 0.0 + + basis_arr, coeff, index, protected_keys = prune_and_cap( + basis_arr, coeff, protected_keys + ) + # Pruning only removes entries, so the discarded l2 weight is + # sqrt(‖c‖²_pre − ‖c‖²_post). + dropped_w = np.sqrt(max(norm2_pre_prune - float(np.dot(coeff, coeff)), 0.0)) + d_total = 0.5 * dt * (rate_before + rate_after) + dropped_w + discarded_cum[nt + 1] = discarded_cum[nt] + d_total / norm_target + + return ck, n_basis_t, max_w_t, discarded_cum + + +# %% [markdown] +# ## Run all $k$-modes and plot + +# %% +Ck = np.empty((steps + 1, kmax)) +n_basis = np.empty((steps + 1, kmax), dtype=np.int64) +max_weight = np.empty((steps + 1, kmax), dtype=np.int64) +discarded_cum = np.empty((steps + 1, kmax)) +for m, kk in enumerate(k_modes): + Ck[:, m], n_basis[:, m], max_weight[:, m], discarded_cum[:, m] = run_mode(kk) + +# %% +fig, ax = plt.subplots() +for m in range(kmax): + ax.plot(times, Ck[:, m], "o-", ms=3, label=rf"$k = 2\pi\cdot{k_indices[m]}/L$") +ax.set_xlabel("$t$") +ax.set_ylabel(r"$C_k(t)/C_k(0)$") +ax.set_title(f"Adaptive Lindbladian evolution L={L} γ={gamma} α={alpha}") +ax.legend() +plt.tight_layout() +plt.show() + +# %% +fig, ax = plt.subplots(1, 2, figsize=(10, 4)) +for m in range(kmax): + ax[0].plot(times, n_basis[:, m], "o-", ms=3, label=rf"$k_{k_indices[m]}$") + ax[1].semilogy(times, discarded_cum[:, m] + 1e-16, "o-", ms=3, + label=rf"$k_{k_indices[m]}$") +ax[0].set(xlabel="$t$", ylabel="|basis|", title="Basis-size growth") +ax[1].set(xlabel="$t$", ylabel=r"cum. discarded / $||O_k||$", + title="A-posteriori error estimate") +for a in ax: + a.legend() +plt.tight_layout() +plt.show() diff --git a/ppvm-python/demo/lindblad_pc_scaling.py b/ppvm-python/demo/lindblad_pc_scaling.py new file mode 100644 index 000000000..528442155 --- /dev/null +++ b/ppvm-python/demo/lindblad_pc_scaling.py @@ -0,0 +1,185 @@ +# --- +# jupyter: +# jupytext: +# cell_metadata_filter: -all +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.1 +# kernelspec: +# display_name: ppvm (3.12.12) +# language: python +# name: python3 +# --- + +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +# %% [markdown] +# # `pc_step` parallel scaling +# +# End-to-end wall-time scaling of the pure-Rust predictor-corrector step +# (`ppvm.Lindbladian.pc_step`) with rayon thread count. The entire `pc_step` +# body — both leakage calls, the action-cache build, and both matrix +# exponentials — runs inside a rayon pool of the requested size: +# +# * leakage and the action cache parallelise over basis elements; +# * the matrix exponential parallelises over SpMV columns. +# +# So the speedup numbers reflect overall PC throughput, not just SpMV. + +# %% +from statistics import median +import time + +import matplotlib.pyplot as plt +import numpy as np + +from ppvm import Lindbladian + + +# %% [markdown] +# ## Parameters + +# %% +L = 51 +J = 1.0 +gamma = 1.0 +alpha = 1.0 +dt = 0.05 +n_steps = 20 +max_basis = 10_000_000 # large: rank cap never binds (full enrichment) +max_cores = 4 +warmup_steps = 4 +model = "long-range" # "nn" or "long-range" + + +# %% [markdown] +# ## Model +# +# All-to-all XY with $1/r^\alpha$ couplings (Kac-normalised) and per-site Z +# dephasing. Long-range activates every bond every step, giving a basis +# size that meaningfully exercises parallel scaling. + +# %% +def build_nn_xy_dephasing(L, J, gamma): + h_terms = [] + for i in range(L - 1): + a, b = i, i + 1 + xs = ["I"] * L + xs[a] = xs[b] = "X" + ys = ["I"] * L + ys[a] = ys[b] = "Y" + h_terms += [("".join(xs), J), ("".join(ys), J)] + jump_terms = [("I" * j + "Z" + "I" * (L - j - 1), gamma) for j in range(L)] + return h_terms, jump_terms + + +def build_long_range_xy_dephasing(L, J, alpha, gamma): + pairs = [ + (a, b, 1.0 / min(b - a, L - b + a) ** alpha) + for a in range(L) + for b in range(a + 1, L) + ] + kac = sum(j for _, _, j in pairs) / L + pairs = [(a, b, j / kac) for a, b, j in pairs] + h_terms = [] + for a, b, j in pairs: + for q in "XY": + term = ["I"] * L + term[a] = term[b] = q + h_terms.append(("".join(term), J * j)) + jump_terms = [("I" * j + "Z" + "I" * (L - j - 1), gamma) for j in range(L)] + return h_terms, jump_terms + + +if model == "nn": + h_terms, jump_terms = build_nn_xy_dephasing(L, J, gamma) +else: + h_terms, jump_terms = build_long_range_xy_dephasing(L, J, alpha, gamma) +L_op = Lindbladian(L, h_terms, jump_terms) + + +# %% [markdown] +# ## Timing harness +# +# Each call to `run_pc_steps` runs `n_steps` consecutive PC steps from +# $Z_{L/2}$, returning the per-step wall times and the final basis size. +# The `num_threads` kwarg pins this call to a freshly-built rayon pool of +# that size, isolating thread-count effects from JIT cache state. + +# %% +def run_pc_steps(L_op, L, site0, dt, n_steps, max_basis, num_threads): + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + times = [] + for _ in range(n_steps): + t0 = time.perf_counter() + basis, coeffs = L_op.pc_step( + basis, + coeffs, + dt, + max_basis, + protected=protected, + num_threads=num_threads, + ) + times.append(time.perf_counter() - t0) + return times, len(basis) + + +# %% [markdown] +# ## Warmup +# +# Each thread count pre-builds its rayon pool and amortises one-time setup +# before the real timing pass. + +# %% +site0 = L // 2 +for n in range(1, max_cores + 1): + run_pc_steps(L_op, L, site0, dt, warmup_steps, max_basis, n) + + +# %% [markdown] +# ## Scaling sweep + +# %% +results = [] +for n in range(1, max_cores + 1): + times, basis_size = run_pc_steps(L_op, L, site0, dt, n_steps, max_basis, n) + first = times[0] * 1000.0 + steady = median(times[1:]) * 1000.0 + results.append({"threads": n, "first_ms": first, "steady_ms": steady, + "basis": basis_size}) + +baseline = results[0]["steady_ms"] +print(f"{'threads':>8s} {'first-step (ms)':>16s} {'steady (ms)':>12s} " + f"{'speedup':>9s} {'|basis|':>8s}") +for r in results: + speedup = baseline / r["steady_ms"] + print(f"{r['threads']:>8d} {r['first_ms']:>16.1f} {r['steady_ms']:>12.2f} " + f"{speedup:>8.2f}x {r['basis']:>8d}") + + +# %% [markdown] +# ## Plot +# +# Steady-state speedup vs thread count, with the linear-scaling reference. + +# %% +threads = np.array([r["threads"] for r in results]) +steady_ms = np.array([r["steady_ms"] for r in results]) +speedup = steady_ms[0] / steady_ms + +fig, ax = plt.subplots() +ax.plot(threads, speedup, "o-", label="measured") +ax.plot(threads, threads, "k--", alpha=0.4, label="linear") +ax.set_xlabel("threads") +ax.set_ylabel("speedup (vs 1 thread)") +ax.set_title(f"pc_step parallel scaling L={L} model={model} " + f"|basis|={results[-1]['basis']}") +ax.legend() +plt.tight_layout() +plt.show() diff --git a/ppvm-python/pyproject.toml b/ppvm-python/pyproject.toml index 5189852df..9afd25b59 100644 --- a/ppvm-python/pyproject.toml +++ b/ppvm-python/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.10" dependencies = [ "bloqade-circuit>=0.14.1", "kirin-toolchain~=0.22.2", + "numpy>=1.26", ] [build-system] @@ -58,3 +59,8 @@ dev = [ "pytest>=9.0.2", "pytest-benchmark>=5.2.3", ] +# Optional: only the `demo/` scripts use it (`expm_multiply` for the reference +# matrix exponential). Runtime ppvm and the test suite have no scipy dep. +demo = [ + "scipy>=1.13", +] diff --git a/ppvm-python/src/ppvm/__init__.py b/ppvm-python/src/ppvm/__init__.py index 8eaff9490..7c1b0fa8b 100644 --- a/ppvm-python/src/ppvm/__init__.py +++ b/ppvm-python/src/ppvm/__init__.py @@ -5,6 +5,9 @@ from .generalized_tableau import GeneralizedTableau as GeneralizedTableau from .generalized_tableau import MeasurementResult as MeasurementResult from .generalized_tableau import sample_stim as sample_stim +from .lindblad import Lindbladian as Lindbladian +from .lindblad import sigma_minus as sigma_minus +from .lindblad import sigma_plus as sigma_plus from .paulisum import LossyPauliSum as LossyPauliSum from .paulisum import PauliSum as PauliSum from .squin_interpreter.device import ( @@ -13,3 +16,7 @@ from .squin_interpreter.device import ( GeneralizedTableauSimulatorTask as GeneralizedTableauSimulatorTask, ) +from .symmetry import TranslationGroup as TranslationGroup +from .symmetry import canonicalize_basis_arr as canonicalize_basis_arr +from .symmetry import canonicalize_basis_arr_complex as canonicalize_basis_arr_complex +from .symmetry import check_momentum_sector_arr as check_momentum_sector_arr diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index bd890adc7..86819da03 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -1,5 +1,7 @@ from collections.abc import Sequence +import numpy as np + class _PauliSumBase: def __init__( self, @@ -61,6 +63,17 @@ class _PauliSumBase: def weights(self) -> list[tuple[str, int]]: ... def current_max_weight(self) -> int: ... +class _PauliSumNoLossBase(_PauliSumBase): + """Methods the interface macros expand only for non-loss variants.""" + + def symmetry_merge(self, group: TranslationGroup) -> None: ... + def momentum_merge( + self, + other: _PauliSumNoLossBase, + group: TranslationGroup, + momentum: list[int], + ) -> None: ... + class _PauliSumLossBase(_PauliSumBase): def loss_channel(self, addr0: int, p: float, truncate: bool = True) -> None: ... def correlated_loss_channel( @@ -68,22 +81,22 @@ class _PauliSumLossBase(_PauliSumBase): ) -> None: ... def reset_loss_channel(self, addr0: int, truncate: bool = True) -> None: ... -class PauliSumIndexMapFxHash0(_PauliSumBase): ... -class PauliSumIndexMapFxHash1(_PauliSumBase): ... -class PauliSumIndexMapFxHash2(_PauliSumBase): ... -class PauliSumIndexMapFxHash3(_PauliSumBase): ... -class PauliSumIndexMapFxHash4(_PauliSumBase): ... -class PauliSumIndexMapFxHash5(_PauliSumBase): ... -class PauliSumIndexMapFxHash6(_PauliSumBase): ... -class PauliSumIndexMapFxHash7(_PauliSumBase): ... -class PauliSumIndexMapFxHash8(_PauliSumBase): ... -class PauliSumIndexMapFxHash9(_PauliSumBase): ... -class PauliSumIndexMapFxHash10(_PauliSumBase): ... -class PauliSumIndexMapFxHash11(_PauliSumBase): ... -class PauliSumIndexMapFxHash12(_PauliSumBase): ... -class PauliSumIndexMapFxHash13(_PauliSumBase): ... -class PauliSumIndexMapFxHash14(_PauliSumBase): ... -class PauliSumIndexMapFxHash15(_PauliSumBase): ... +class PauliSumIndexMapFxHash0(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash1(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash2(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash3(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash4(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash5(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash6(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash7(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash8(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash9(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash10(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash11(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash12(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash13(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash14(_PauliSumNoLossBase): ... +class PauliSumIndexMapFxHash15(_PauliSumNoLossBase): ... class PauliSumLossIndexMapFxHash0(_PauliSumLossBase): ... class PauliSumLossIndexMapFxHash1(_PauliSumLossBase): ... class PauliSumLossIndexMapFxHash2(_PauliSumLossBase): ... @@ -343,3 +356,106 @@ class TableauSumSampler29(_TableauSumSamplerBase): ... class TableauSumSampler30(_TableauSumSamplerBase): ... class TableauSumSampler31(_TableauSumSamplerBase): ... class TableauSumSampler32(_TableauSumSamplerBase): ... + +class LindbladSpec: + def __init__( + self, + n_qubits: int, + h_terms: list[str], + h_coeffs: list[float], + jump_lincombs: list[list[tuple[str, float, float]]], + jump_rates: list[float], + kossakowski_ops: list[list[tuple[str, float, float]]] = ..., + kossakowski_k: list[list[tuple[float, float]]] = ..., + ) -> None: ... + @property + def n_qubits(self) -> int: ... + @property + def num_h_terms(self) -> int: ... + @property + def num_jump_terms(self) -> int: ... + def action(self, p: np.ndarray) -> tuple[np.ndarray, np.ndarray]: ... + def leakage( + self, + basis: np.ndarray, + coeffs: np.ndarray, + protected: np.ndarray | None = None, + ) -> tuple[np.ndarray, np.ndarray]: ... + def pc_step( + self, + basis: np.ndarray, + coeffs: np.ndarray, + dt: float, + max_basis: int, + drop_tol: float = 0.0, + protected: np.ndarray | None = None, + num_threads: int | None = None, + admit_basis: int | None = None, + tau_add: float | None = None, + ) -> tuple[np.ndarray, np.ndarray]: ... + def pc_step_timed( + self, + basis: np.ndarray, + coeffs: np.ndarray, + dt: float, + max_basis: int, + drop_tol: float = 0.0, + protected: np.ndarray | None = None, + num_threads: int | None = None, + admit_basis: int | None = None, + tau_add: float | None = None, + ) -> tuple[tuple[np.ndarray, np.ndarray], dict[str, int]]: ... + def pc_step_orbit_rep( + self, + basis: np.ndarray, + coeffs: np.ndarray, + dt: float, + max_basis: int, + group: TranslationGroup, + momentum: np.ndarray, + drop_tol: float = 0.0, + protected: np.ndarray | None = None, + canonicalize_first: bool = False, + admit_basis: int | None = None, + tau_add: float | None = None, + num_threads: int | None = None, + ) -> tuple[np.ndarray, np.ndarray]: ... + def generator(self, basis: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: ... + +class TranslationGroup: + @staticmethod + def chain_1d(n: int) -> TranslationGroup: ... + @staticmethod + def torus_2d(lx: int, ly: int) -> TranslationGroup: ... + @staticmethod + def torus_3d(lx: int, ly: int, lz: int) -> TranslationGroup: ... + @staticmethod + def ladder(l: int, n_legs: int) -> TranslationGroup: ... + @staticmethod + def from_generators( + n_qubits: int, perms: list[list[int]], orders: list[int] + ) -> TranslationGroup: ... + @property + def n_qubits(self) -> int: ... + @property + def n_generators(self) -> int: ... + @property + def order(self) -> int: ... + def canonicalize(self, pauli: np.ndarray) -> np.ndarray: ... + +def canonicalize_basis_arr( + basis: np.ndarray, coeffs: np.ndarray, group: TranslationGroup +) -> tuple[np.ndarray, np.ndarray]: ... +def canonicalize_basis_arr_complex( + basis: np.ndarray, + coeffs: np.ndarray, + group: TranslationGroup, + momentum: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: ... +def check_momentum_sector_arr( + basis: np.ndarray, + coeffs: np.ndarray, + group: TranslationGroup, + momentum: np.ndarray, + tol: float = 1e-8, +) -> None: ... diff --git a/ppvm-python/src/ppvm/lindblad.py b/ppvm-python/src/ppvm/lindblad.py new file mode 100644 index 000000000..501eb7d12 --- /dev/null +++ b/ppvm-python/src/ppvm/lindblad.py @@ -0,0 +1,508 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Direct Pauli-Lindbladian time evolution on an adaptive Pauli-string basis. + +Given a Hermitian Pauli Hamiltonian H = Σ c_i P_i and jump operators +L_k = Σ_a λ_{k,a} P_{k,a} (each a complex linear combination of Pauli +strings) with rates γ_k ≥ 0, this module exposes the primitives for +adaptive Heisenberg-picture evolution: + +- ``pc_step(...)`` / ``pc_step_arr(...)``: one adaptive predictor-corrector + step ``O ← exp(dt·L*) O``; ``pc_step_orbit_rep(...)`` is the + translation-symmetric (momentum-sector) variant +- ``action(p)`` / ``action_arr(p)``: L*(p) for one Pauli string p +- ``leakage(basis, coeffs)`` / ``leakage_arr(...)``: off-basis component of + L*(Σ c_j p_j), driving basis expansion +- ``generator(basis)``: COO triples ``(rows, cols, vals)`` for the generator + matrix M such that L* restricted to ``basis`` is ``M @ coeffs``. Users + wanting a sparse matrix can wrap them — e.g. + ``scipy.sparse.coo_matrix((vals, (rows, cols)), shape=(N, N)).tocsc()`` + +The ``*_arr`` variants pass Pauli strings as ``(N, n_qubits)`` ``uint8`` +arrays of Pauli codes (``0=I, 1=X, 2=Z, 3=Y``) and skip string +construction entirely — at ~10^5 basis rows per evolution step, per-row +``str.join`` dominates wall time. + +Each jump term can be either: + +- a single Hermitian Pauli (`("ZZII", γ)`), routed to a fast diagonal path, + or +- a complex Pauli sum (`([("XIII", 0.5+0j), ("YIII", 0+0.5j)], γ)`) to + describe e.g. amplitude-damping (`σ⁻`) and excitation (`σ⁺`) operators. + +For the general case the dissipator +``γ ( L† p L − ½ {L†L, p} )`` is evaluated directly; the L†L Pauli +expansion is precomputed once at construction. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from typing import Union + +import numpy as np +import numpy.typing as npt + +from . import _core +from ._core import LindbladSpec as _LindbladSpec + +_PAULI_CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} +# Lookup table mapping code -> ASCII byte for vectorised string output. +_CODE_TO_ASCII = np.array([ord("I"), ord("X"), ord("Z"), ord("Y")], dtype=np.uint8) + +# A jump operator is either a Hermitian Pauli (single string) or a complex +# linear combination of Pauli strings. +PauliLincomb = Iterable[tuple[str, complex]] +JumpSpec = Union[tuple[str, float], tuple[PauliLincomb, float]] + + +def _string_to_codes(s: str, n_qubits: int) -> np.ndarray: + """Encode a Pauli string ``"IXYZ..."`` as a length-``n_qubits`` uint8 array. + + Underscores in the input are ignored, matching the Rust parser + (``parse_pauli_string`` in `ppvm-lindblad`) so users can write + ``"X_Y_Z"`` for readability. + """ + s_clean = s.replace("_", "") + if len(s_clean) != n_qubits: + raise ValueError( + f"Pauli string {s!r} has length {len(s_clean)} (after stripping '_') " + f"!= n_qubits {n_qubits}" + ) + try: + return np.array([_PAULI_CODE[c] for c in s_clean], dtype=np.uint8) + except KeyError as exc: + bad = exc.args[0] + raise ValueError( + f"Pauli string {s!r} contains invalid character {bad!r}; " + f"expected one of 'I', 'X', 'Y', 'Z' (and '_' is allowed as a separator)" + ) from None + + +def _codes_to_string(codes: np.ndarray) -> str: + """Decode one length-``n_qubits`` row of Pauli codes back to a string.""" + return _CODE_TO_ASCII[codes].tobytes().decode("ascii") + + +def _basis_to_codes(basis: Sequence[str], n_qubits: int) -> np.ndarray: + """Stack a sequence of Pauli strings into an ``(N, n_qubits)`` uint8 array.""" + arr = np.zeros((len(basis), n_qubits), dtype=np.uint8) + for i, s in enumerate(basis): + arr[i] = _string_to_codes(s, n_qubits) + return arr + + +def _codes_to_basis(arr: np.ndarray) -> list[str]: + """Inverse of `_basis_to_codes`. One call into C per row.""" + bytes_per_row = _CODE_TO_ASCII[arr].tobytes() + n = arr.shape[1] + return [bytes_per_row[i * n : (i + 1) * n].decode("ascii") for i in range(arr.shape[0])] + + +def sigma_plus(site: int, n_qubits: int) -> list[tuple[str, complex]]: + """``σ⁺_q = (X_q + i Y_q) / 2``. Use as a Lindblad jump for excitation.""" + if not 0 <= site < n_qubits: + raise ValueError(f"site {site} out of range for n_qubits={n_qubits}") + x_str = "I" * site + "X" + "I" * (n_qubits - site - 1) + y_str = "I" * site + "Y" + "I" * (n_qubits - site - 1) + return [(x_str, 0.5 + 0.0j), (y_str, 0.0 + 0.5j)] + + +def sigma_minus(site: int, n_qubits: int) -> list[tuple[str, complex]]: + """``σ⁻_q = (X_q − i Y_q) / 2``. Use as a Lindblad jump for amplitude damping.""" + if not 0 <= site < n_qubits: + raise ValueError(f"site {site} out of range for n_qubits={n_qubits}") + x_str = "I" * site + "X" + "I" * (n_qubits - site - 1) + y_str = "I" * site + "Y" + "I" * (n_qubits - site - 1) + return [(x_str, 0.5 + 0.0j), (y_str, 0.0 - 0.5j)] + + +def _normalize_jump(jump_op: str | PauliLincomb) -> list[tuple[str, float, float]]: + """Convert a user-supplied jump operator to ``[(pauli_str, re, im), ...]``. + + Accepts either a single Pauli string (treated as a Hermitian-Pauli jump + with coefficient 1) or an iterable of ``(pauli_str, complex_coeff)`` + pairs. + """ + if isinstance(jump_op, str): + return [(jump_op, 1.0, 0.0)] + out: list[tuple[str, float, float]] = [] + for term in jump_op: + s, c = term + cc = complex(c) + out.append((str(s), float(cc.real), float(cc.imag))) + if not out: + raise ValueError("jump operator lincomb must contain at least one Pauli term") + return out + + +class Lindbladian: + """Pre-compiled adjoint Pauli-Lindbladian acting on Pauli strings. + + Parameters + ---------- + n_qubits: + Number of qubits, at most 512. The Pauli word width is chosen + automatically: up to 128 qubits uses the original 128-bit layout, + then 256- and 512-bit words. Narrow problems are unaffected by the + wider layouts being available. + h_terms: + Iterable of ``(pauli_string, coefficient)`` pairs for the + Hermitian Hamiltonian ``H = Σ c_i P_i``. Each ``pauli_string`` is + a length-``n_qubits`` ``str`` over ``"IXYZ"``. + jump_terms: + Iterable of ``(jump_op, rate)`` pairs. ``jump_op`` is either a + Pauli string ``"XYZI..."`` (treated as a Hermitian-Pauli jump + with coefficient 1, hitting the fast path) or an iterable of + ``(pauli_string, complex_coeff)`` pairs for a general complex + Pauli linear combination such as `sigma_plus` or + `sigma_minus`. ``rate`` is the non-negative GKSL rate + ``γ_k``. + kossakowski: + Optional ``(ops, K)`` pair adding a Kossakowski-form dissipator + ``D*(O) = Σ_nm K_nm (A_n† O A_m − ½{A_n†A_m, O})``. ``ops`` is a + length-``M`` sequence of Pauli lincombs (same format as a + ``jump_op``, e.g. ``[sigma_minus(j, n) for j in range(n)]``); + ``K`` is an ``(M, M)`` Hermitian positive-semidefinite array + (e.g. the collective-decay pair matrix ``Γ_nm``). Mathematically + identical to passing the eigenmode jumps + ``L_ν = √γ_ν Σ_j V*_jν A_j`` of ``K = V diag(γ) V†``, but the + per-string action cost scales with the number of nonzero + ``K_nm`` entries instead of carrying an extra factor of ``M``. + May coexist with ``jump_terms`` (both contribute). Raises + ``ValueError`` if ``K`` is not Hermitian or has an eigenvalue + below ``−tol·‖K‖`` (a non-PSD ``K`` is not a valid GKSL + generator; no silent clipping). + + Examples + -------- + Dephasing (Hermitian Pauli): + + >>> Lindbladian(2, [("XX", 1.0)], [("ZI", 0.3), ("IZ", 0.3)]) + + Amplitude damping on site 0 (non-Hermitian): + + >>> jumps = [(sigma_minus(0, 2), 0.5)] + >>> Lindbladian(2, [("XX", 1.0)], jumps) + + Collective decay from a pair matrix: + + >>> ops = [sigma_minus(j, 2) for j in range(2)] + >>> gamma = [[1.0, 0.6], [0.6, 1.0]] + >>> Lindbladian(2, [("XX", 1.0)], kossakowski=(ops, gamma)) + """ + + #: Relative tolerance for the Hermiticity and PSD checks on ``K``. + _K_TOL = 1e-10 + + def __init__( + self, + n_qubits: int, + h_terms: Iterable[tuple[str, float]], + jump_terms: Iterable[tuple[str | PauliLincomb, float]] = (), + kossakowski: tuple[Sequence[str | PauliLincomb], npt.ArrayLike] | None = None, + ): + self.n_qubits = int(n_qubits) + h_strs: list[str] = [] + h_coeffs: list[float] = [] + for s, c in h_terms: + h_strs.append(s) + h_coeffs.append(float(c)) + j_lincombs: list[list[tuple[str, float, float]]] = [] + j_rates: list[float] = [] + for jump_op, rate in jump_terms: + j_lincombs.append(_normalize_jump(jump_op)) + j_rates.append(float(rate)) + k_ops, k_mat = self._normalize_kossakowski(kossakowski) + self._spec = _LindbladSpec( + self.n_qubits, h_strs, h_coeffs, j_lincombs, j_rates, k_ops, k_mat + ) + + @classmethod + def _normalize_kossakowski( + cls, kossakowski: tuple[Sequence[str | PauliLincomb], npt.ArrayLike] | None + ) -> tuple[list[list[tuple[str, float, float]]], list[list[tuple[float, float]]]]: + """Validate and flatten the ``(ops, K)`` pair for the native constructor. + + The core rejects a non-Hermitian ``K`` too, but the check is repeated + here because ``eigvalsh`` reads only one triangle: without it a + non-Hermitian ``K`` would pass the PSD test on its symmetric part. + """ + if kossakowski is None: + return [], [] + ops, k = kossakowski + k_ops = [_normalize_jump(op) for op in ops] + k_arr = np.asarray(k, dtype=np.complex128) + if k_arr.shape != (len(k_ops), len(k_ops)): + raise ValueError( + f"kossakowski K has shape {k_arr.shape} but ops has length {len(k_ops)}" + ) + scale = max(float(np.abs(k_arr).max(initial=0.0)), 1.0) + if float(np.abs(k_arr - k_arr.conj().T).max(initial=0.0)) > cls._K_TOL * scale: + raise ValueError("kossakowski K is not Hermitian") + evals = np.linalg.eigvalsh(k_arr) + if float(evals.min(initial=0.0)) < -cls._K_TOL * scale: + raise ValueError( + f"kossakowski K is not positive semidefinite " + f"(min eigenvalue {evals.min():.3e}); a non-PSD pair matrix " + f"is not a valid GKSL generator" + ) + k_mat = [[(float(v.real), float(v.imag)) for v in row] for row in k_arr] + return k_ops, k_mat + + @property + def num_h_terms(self) -> int: + return self._spec.num_h_terms + + @property + def num_jump_terms(self) -> int: + return self._spec.num_jump_terms + + # ── Pure-ndarray hot path ── + + def action_arr(self, p: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Apply ``L*`` to a single Pauli string given as uint8 codes. + + Returns ``(out_basis, out_coeffs)``: a ``(M, n_qubits)`` uint8 + array and a length-``M`` float64 array. + """ + return self._spec.action(np.ascontiguousarray(p, dtype=np.uint8)) + + def leakage_arr( + self, + basis_arr: np.ndarray, + coeffs: np.ndarray, + protected_arr: np.ndarray | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + """Off-basis component of ``L*( Σ_j coeffs[j] basis[j] )``. + + ``basis_arr``: ``(N, n_qubits)`` uint8. ``coeffs``: length-N float64. + ``protected_arr``: optional ``(K, n_qubits)`` uint8 of Pauli strings + that must NEVER appear in the leakage output. + + Returns ``(out_basis, out_coeffs)`` packed the same way as + `action_arr`. + """ + n = self.n_qubits + if protected_arr is None: + protected_arr = np.zeros((0, n), dtype=np.uint8) + return self._spec.leakage( + np.ascontiguousarray(basis_arr, dtype=np.uint8), + np.ascontiguousarray(coeffs, dtype=np.float64), + np.ascontiguousarray(protected_arr, dtype=np.uint8), + ) + + def pc_step_arr( + self, + basis_arr: np.ndarray, + coeffs: np.ndarray, + dt: float, + max_basis: int, + drop_tol: float = 1e-12, + protected_arr: np.ndarray | None = None, + num_threads: int | None = None, + admit_basis: int | None = None, + tau_add: float | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + """One predictor-corrector adaptive step. + + All work — leakage expansion, matrix-exponential step, second-hop + re-expansion, corrector — runs in Rust; SciPy is not required. + The matrix-exponential action is computed matrix-free via the + external ``quspin-expm`` crate. + + Truncation. ``max_basis`` is a hard rank cap on the live basis: + enrichment adds at most ``max_basis - len(basis)`` of the largest + leakage strings, and the post-step basis is trimmed to the + top-``max_basis`` entries by ``|coeff|`` (``protected`` words always + kept). Pass a large value (e.g. ``10_000_000``) for the near-exact, + uncapped case. ``drop_tol`` additionally prunes basis entries whose + absolute coefficient is below the threshold after the corrector + (unless the word is ``protected``). + + ``num_threads``, when set, pins this call to a freshly-built rayon + pool of that size — useful for benchmarking parallel scaling. + + ``tau_add``, when set, filters leakage admission by an absolute + coefficient-rate threshold (a dt- and drop_tol-independent + parameterization). Off by default — with cap-based truncation it + is at most a modest wall optimization. + + ``admit_basis``, when set (must be >= ``max_basis``), bounds the + enriched working set during the step instead of ``max_basis``: the + step may hold up to ``admit_basis`` strings transiently, and the + final truncation keeps the top-``max_basis`` by evolved ``|coeff|`` + over the whole union (retained + newly admitted) — rank + displacement, so no ``drop_tol`` is needed to sustain membership + turnover. With the default ``None``, admission is bounded by + ``max_basis`` and turnover requires ``drop_tol > 0``. + + Returns ``(new_basis_arr, new_coeffs)``; the basis may have grown + (or shrunk, if ``max_basis`` / ``drop_tol`` pruned entries). + """ + n = self.n_qubits + if protected_arr is None: + protected_arr = np.zeros((0, n), dtype=np.uint8) + return self._spec.pc_step( + np.ascontiguousarray(basis_arr, dtype=np.uint8), + np.ascontiguousarray(coeffs, dtype=np.float64), + float(dt), + int(max_basis), + float(drop_tol), + np.ascontiguousarray(protected_arr, dtype=np.uint8), + None if num_threads is None else int(num_threads), + None if admit_basis is None else int(admit_basis), + None if tau_add is None else float(tau_add), + ) + + def pc_step_orbit_rep( + self, + basis_arr: np.ndarray, + coeffs: np.ndarray, + dt: float, + max_basis: int, + group: _core.TranslationGroup, + momentum: npt.ArrayLike, + drop_tol: float = 1e-12, + protected_arr: np.ndarray | None = None, + canonicalize_first: bool = False, + admit_basis: int | None = None, + tau_add: float | None = None, + num_threads: int | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + """Per-step orbit-representative pc evolution. + + State lives entirely in orbit-rep form throughout: ``basis_arr`` + contains only canonical translation-orbit representatives, + ``coeffs`` are complex, and the action is phase-aware. The basis + is ~``|group|×`` smaller than the equivalent full-basis complex + evolution, and the reduction persists across every step. + + Coefficients use the same convention as + `ppvm.canonicalize_basis_arr_complex`: ``coeffs[i]`` is the plain + coefficient of the representative Pauli word itself (the + orbit-*averaged* convention, not the summing one + `PauliSum.momentum_merge` uses). Reps whose orbit cannot carry + ``momentum`` — its stabilizer has a non-trivial character — are + dropped, matching that projection. + + Truncation. ``max_basis`` is a hard rank cap on the live orbit-rep + basis: enrichment adds at most ``max_basis - len(basis)`` of the + largest leakage reps, and the post-step basis is trimmed to the + top-``max_basis`` reps by ``|c|`` (``protected`` reps always kept). + Pass a large value (e.g. ``10_000_000``) for the near-exact, + uncapped case. ``drop_tol`` additionally prunes reps whose absolute + coefficient is below the threshold after the corrector. + + ``admit_basis``, when set (>= ``max_basis``), bounds the enriched + working set instead of ``max_basis``: the step may hold up to + ``admit_basis`` reps transiently and the final truncation keeps the + top-``max_basis`` by evolved ``|c|`` over the whole union — the + displacement scheme, matching the real-space ``pc_step_arr``. + + ``basis_arr`` is assumed to contain canonical reps only. Pass + ``canonicalize_first=True`` to rewrite each row to its canonical + rep on entry (coefficients unchanged). + + ``num_threads``, when set, pins this call to a freshly-built rayon + pool of that size, exactly as for `pc_step_arr`. + """ + n = self.n_qubits + if protected_arr is None: + protected_arr = np.zeros((0, n), dtype=np.uint8) + return self._spec.pc_step_orbit_rep( + np.ascontiguousarray(basis_arr, dtype=np.uint8), + np.ascontiguousarray(coeffs, dtype=np.complex128), + float(dt), + int(max_basis), + group, + np.ascontiguousarray(momentum, dtype=np.int32), + float(drop_tol), + np.ascontiguousarray(protected_arr, dtype=np.uint8), + bool(canonicalize_first), + None if admit_basis is None else int(admit_basis), + None if tau_add is None else float(tau_add), + None if num_threads is None else int(num_threads), + ) + + def pc_step( + self, + basis: Sequence[str], + coeffs: np.ndarray, + dt: float, + max_basis: int, + drop_tol: float = 1e-12, + protected: Sequence[str] | None = None, + num_threads: int | None = None, + admit_basis: int | None = None, + tau_add: float | None = None, + ) -> tuple[list[str], np.ndarray]: + """String-keyed variant of `pc_step_arr`.""" + n = self.n_qubits + basis_arr = _basis_to_codes(basis, n) + protected_arr = ( + _basis_to_codes(list(protected), n) if protected else np.zeros((0, n), dtype=np.uint8) + ) + new_basis_arr, new_coeffs = self.pc_step_arr( + basis_arr, + coeffs, + dt, + max_basis, + drop_tol, + protected_arr, + num_threads, + admit_basis, + tau_add, + ) + return _codes_to_basis(new_basis_arr), new_coeffs + + def generator_arr(self, basis_arr: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Generator matrix as COO triples ``(rows, cols, vals)``. + + Basis given as uint8 codes. To get a SciPy sparse matrix: + + >>> import scipy.sparse as sp + >>> rows, cols, vals = L_op.generator_arr(basis_arr) + >>> M = sp.coo_matrix( + ... (vals, (rows, cols)), shape=(len(basis_arr), len(basis_arr)) + ... ).tocsc() + """ + return self._spec.generator(np.ascontiguousarray(basis_arr, dtype=np.uint8)) + + # ── String-keyed convenience API (slower; for tests / display) ── + + def action(self, p: str) -> dict[str, float]: + """Apply ``L*`` to a single Pauli string ``p`` (string-keyed dict).""" + codes = _string_to_codes(p, self.n_qubits) + out_basis, out_coeffs = self._spec.action(codes) + keys = _codes_to_basis(out_basis) + return {k: float(v) for k, v in zip(keys, out_coeffs) if v != 0.0} + + def leakage( + self, + basis: Sequence[str], + coeffs: np.ndarray, + protected: Sequence[str] | None = None, + ) -> dict[str, float]: + """Off-basis leakage as a ``dict[str, float]`` (slower API).""" + n = self.n_qubits + basis_arr = _basis_to_codes(basis, n) + protected_arr = ( + _basis_to_codes(list(protected), n) if protected else np.zeros((0, n), dtype=np.uint8) + ) + out_basis, out_coeffs = self._spec.leakage( + basis_arr, + np.ascontiguousarray(coeffs, dtype=np.float64), + protected_arr, + ) + keys = _codes_to_basis(out_basis) + return {k: float(v) for k, v in zip(keys, out_coeffs) if v != 0.0} + + def generator(self, basis: Sequence[str]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Generator matrix as COO triples ``(rows, cols, vals)``, + basis given as strings. See `generator_arr` for the conversion + to a SciPy sparse matrix.""" + n = self.n_qubits + basis_arr = _basis_to_codes(basis, n) + return self.generator_arr(basis_arr) diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index dca573d8d..6d1922915 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -386,6 +386,60 @@ def trace(self, pattern: str) -> float: """ return self._interface.trace(pattern) + def symmetry_merge(self, group: _core.TranslationGroup) -> None: + """Merge entries into orbit-representative form under a translation group. + + Each Pauli word in the sum is replaced by its canonical (lex-min) + representative under the action of ``group``; coefficients of words + that collapse to the same representative are summed. Entry count + reduces by up to ``|group|×`` for translation-invariant operators. + + For a translation-invariant dynamics that you apply between + merging steps, this preserves all ``group``-invariant expectation + values (Theorem 1 of Teng et al., arXiv:2512.12094). Plain + real-coefficient merge — handles the trivial (``k=0``) momentum + sector only. + + Args: + group: A `ppvm.TranslationGroup` + (use ``TranslationGroup.chain_1d(n)``, ``.torus_2d``, + ``.torus_3d``, ``.ladder``, or ``.from_generators``). + """ + self._interface.symmetry_merge(group) + + def momentum_merge( + self, + other: "PauliSum", + group: _core.TranslationGroup, + momentum: Sequence[int], + ) -> None: + """Phase-aware (momentum-sector) merge for a complex operator stored + as a *real pair*: ``self`` is the real part and ``other`` the + imaginary part of ``O = self + i·other``. Both are overwritten in + place with the orbit-representative form projected onto momentum + sector ``momentum``. + + Generalizes `symmetry_merge` to non-trivial momentum sectors + (``k != 0``) while keeping real coefficients on both PauliSums — the + only complex arithmetic is the internal character-weighted fold. Like + `symmetry_merge` it is the *summing* projector + ``Σ_{p in orbit} χ_k(g_p)·c_p``, hence idempotent on every orbit and + safe to apply after each Trotter step; at ``momentum=[0, ...]`` it + reduces exactly to `symmetry_merge`. + + ``self`` and ``other`` must be distinct objects with the same qubit + count. Exact after a translation-covariant gate layer; under a + generic Trotter step it carries the same ``O(dt^{p+1})`` equivariance + error as the ``k=0`` merge. + + Args: + other: the PauliSum holding the imaginary component (modified in place). + group: a `ppvm.TranslationGroup`. + momentum: sequence of integer modes, one per group generator + (e.g. ``[k]`` for a 1D chain; ``[0, ...]`` is the trivial sector). + """ + self._interface.momentum_merge(other._interface, group, list(momentum)) + def amplitude_damping(self, addr0: int, gamma: float, *, truncate: bool = True): """Apply an amplitude-damping channel. @@ -497,3 +551,33 @@ def reset_loss_channel(self, addr0: int, *, truncate: bool = True) -> None: strategy after the channel; if ``False``, defer it. """ self._interface.reset_loss_channel(addr0, truncate=truncate) + + def symmetry_merge(self, group: _core.TranslationGroup) -> None: + """Not available on `LossyPauliSum`. + + Raises: + NotImplementedError: always. Canonicalizing a lossy Pauli word + would have to permute the loss bitmap along with the Pauli + alphabet, which the Rust core does not implement. + """ + raise NotImplementedError( + "symmetry_merge is not implemented for LossyPauliSum: canonicalizing a " + "lossy Pauli word would have to permute the loss bitmap too" + ) + + def momentum_merge( + self, + other: "PauliSum", + group: _core.TranslationGroup, + momentum: Sequence[int], + ) -> None: + """Not available on `LossyPauliSum`. + + Raises: + NotImplementedError: always, for the same reason as + `symmetry_merge`. + """ + raise NotImplementedError( + "momentum_merge is not implemented for LossyPauliSum: canonicalizing a " + "lossy Pauli word would have to permute the loss bitmap too" + ) diff --git a/ppvm-python/src/ppvm/symmetry.py b/ppvm-python/src/ppvm/symmetry.py new file mode 100644 index 000000000..d7a7ee9b4 --- /dev/null +++ b/ppvm-python/src/ppvm/symmetry.py @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Translation-symmetry merging of Pauli sums. + +A `TranslationGroup` is a finite abelian group acting on qubit positions by +permutation. Every Pauli word then belongs to a translation orbit, and +dynamics that commutes with the group can be tracked using **one canonical +representative per orbit** instead of all ``|G|`` members — cutting per-step +memory and compute by up to ``|G|×`` (Teng et al., arXiv:2512.12094). + +Two representations are supported: + +- `ppvm.PauliSum.symmetry_merge` / `ppvm.PauliSum.momentum_merge` for the + dictionary representation used by gate-based Trotter evolution. +- the ``*_arr`` functions here for the ``(basis_arr, coeffs)`` array + representation used by `ppvm.Lindbladian.pc_step_arr` and + `ppvm.Lindbladian.pc_step_orbit_rep`, where ``basis_arr`` is an + ``(N, n_qubits)`` uint8 array with the encoding ``0=I, 1=X, 2=Z, 3=Y``. + +These are thin wrappers over `ppvm._core` that coerce their arguments to the +dtypes the compiled entry points require (uint8 basis, float64 / complex128 +coefficients, int32 momentum), so plain Python lists and default-dtype numpy +arrays work. +""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + +from . import _core +from ._core import TranslationGroup as TranslationGroup + +__all__ = [ + "TranslationGroup", + "canonicalize_basis_arr", + "canonicalize_basis_arr_complex", + "check_momentum_sector_arr", +] + + +def _momentum(momentum: npt.ArrayLike) -> np.ndarray: + return np.ascontiguousarray(momentum, dtype=np.int32) + + +def _basis(basis_arr: npt.ArrayLike) -> np.ndarray: + return np.ascontiguousarray(basis_arr, dtype=np.uint8) + + +def canonicalize_basis_arr( + basis_arr: npt.ArrayLike, + coeffs: npt.ArrayLike, + group: TranslationGroup, +) -> tuple[np.ndarray, np.ndarray]: + """Merge a real-coefficient ``(basis_arr, coeffs)`` Pauli sum into + orbit-representative form. + + Each row of ``basis_arr`` is replaced by its canonical representative + under ``group``; coefficients of rows collapsing to the same + representative are **summed**. The output is no longer than the input. + + This is the trivial (``k=0``) symmetry sector. For dynamics that commutes + with ``group`` and a ``group``-invariant initial state, it preserves every + ``group``-invariant expectation value. Use `canonicalize_basis_arr_complex` + for non-trivial momentum sectors. + + Args: + basis_arr: ``(N, n_qubits)`` array of Pauli codes. + coeffs: length-``N`` real coefficients. + group: the symmetry group to merge under. + + Returns: + ``(merged_basis_arr, merged_coeffs)``. + """ + return _core.canonicalize_basis_arr( + _basis(basis_arr), + np.ascontiguousarray(coeffs, dtype=np.float64), + group, + ) + + +def canonicalize_basis_arr_complex( + basis_arr: npt.ArrayLike, + coeffs: npt.ArrayLike, + group: TranslationGroup, + momentum: npt.ArrayLike, +) -> tuple[np.ndarray, np.ndarray]: + """Phase-aware merge of a complex-coefficient ``(basis_arr, coeffs)`` + Pauli sum into orbit-representative form, projected onto momentum sector + ``momentum``. + + Coefficients on each orbit's distinct members are **averaged** with the + character weight ``χ_k(g)`` — a ``1/|orbit|`` normalization that + `canonicalize_basis_arr` (which sums) does not apply. Orbits whose + stabilizer cannot carry ``momentum`` project to zero and are dropped. + + If the input does not actually lie in sector ``momentum``, the projection + silently discards the other components; call `check_momentum_sector_arr` + first to validate. + + Args: + basis_arr: ``(N, n_qubits)`` array of Pauli codes. + coeffs: length-``N`` complex coefficients. + group: the symmetry group to merge under. + momentum: one integer mode index per group generator. The wavenumber + along generator ``g`` is ``2π · momentum[g] / order_g``; + ``[0, ...]`` is the trivial sector. + + Returns: + ``(merged_basis_arr, merged_coeffs)`` with complex coefficients. + """ + return _core.canonicalize_basis_arr_complex( + _basis(basis_arr), + np.ascontiguousarray(coeffs, dtype=np.complex128), + group, + _momentum(momentum), + ) + + +def check_momentum_sector_arr( + basis_arr: npt.ArrayLike, + coeffs: npt.ArrayLike, + group: TranslationGroup, + momentum: npt.ArrayLike, + tol: float = 1e-8, +) -> None: + """Verify that a ``(basis_arr, complex_coeffs)`` Pauli sum lies entirely + in momentum sector ``momentum``. + + For every orbit represented in the basis, all members must satisfy + ``c_{g·r} = χ_k(g)⁻¹ · c_r``. Orbit members absent from ``basis_arr`` + count as zero rather than being ignored, so a partially-populated orbit + fails. + + Run this on a user-supplied initial state before feeding it to + `canonicalize_basis_arr_complex` or + `ppvm.Lindbladian.pc_step_orbit_rep` — silently projecting a + wrongly-typed input throws away meaningful physics. + + Args: + basis_arr: ``(N, n_qubits)`` array of Pauli codes. + coeffs: length-``N`` complex coefficients. + group: the symmetry group. + momentum: one integer mode index per group generator. + tol: relative tolerance on the coefficient comparison. + + Raises: + ValueError: if the input is not in the sector, naming the offending + orbit representative with its expected and actual coefficient. + """ + return _core.check_momentum_sector_arr( + _basis(basis_arr), + np.ascontiguousarray(coeffs, dtype=np.complex128), + group, + _momentum(momentum), + tol, + ) diff --git a/ppvm-python/test/lindblad/__init__.py b/ppvm-python/test/lindblad/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ppvm-python/test/lindblad/_dissipative_refs.py b/ppvm-python/test/lindblad/_dissipative_refs.py new file mode 100644 index 000000000..fcf46c528 --- /dev/null +++ b/ppvm-python/test/lindblad/_dissipative_refs.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Shared references for the collective-decay (Kossakowski) tests. + +Vendored from the superradiant-burst study (free-space photon-mediated +Lindbladian, arXiv:2309.11376 Eqs. 6-8): emitter couplings from the dyadic +Green's function, the ppvm model builder, and the exact Lindblad reference +via the excitation-number cascade. `exact_rate_sector` is geometry-agnostic +(it takes the J and Gamma matrices), so the same reference covers chains +and rings. + +Conventions: excited = |0> (Z = +1), sigma^- = (X - i Y)/2; +H = sum_{n(t), the photon emission rate. +""" + +import itertools + +import numpy as np + +LAM = 1.0 +K0 = 2 * np.pi / LAM +G0 = 1.0 +POL = np.array([1.0, 1.0j, 0.0]) / np.sqrt(2.0) + + +def greens(r_vec): + """Free-space dyadic Green's function G(r, omega0).""" + r = np.linalg.norm(r_vec) + rh = np.outer(r_vec, r_vec) / r**2 + kr = K0 * r + pref = np.exp(1j * kr) / (4 * np.pi * K0**2 * r**3) + return pref * ((kr**2 + 1j * kr - 1) * np.eye(3) - (kr**2 + 3j * kr - 3) * rh) + + +def couplings(pos): + """J_nm, Gamma_nm from emitter positions; J_nn = 0, Gamma_nn = Gamma_0.""" + n = len(pos) + J = np.zeros((n, n)) + Gam = np.zeros((n, n)) + for a in range(n): + for b in range(n): + if a == b: + Gam[a, b] = G0 + continue + g = POL.conj() @ greens(pos[a] - pos[b]) @ POL + J[a, b] = -3 * np.pi * G0 / K0 * g.real + Gam[a, b] = 6 * np.pi * G0 / K0 * g.imag + return J, Gam + + +def chain_positions(n, d): + return [np.array([j * d, 0.0, 0.0]) for j in range(n)] + + +def ring_positions(n, d): + """n emitters on a circle with nearest-neighbour arc spacing ~d. + + Chord-based radius so that adjacent emitters sit exactly d apart. + The resulting J/Gamma matrices are circulant (exact C_n symmetry). + """ + radius = d / (2 * np.sin(np.pi / n)) + return [ + np.array([radius * np.cos(2 * np.pi * j / n), radius * np.sin(2 * np.pi * j / n), 0.0]) + for j in range(n) + ] + + +def pstr(n, **sites): + s = ["I"] * n + for k, v in sites.items(): + s[int(k[1:])] = v + return "".join(s) + + +def hamiltonian_terms(n, J): + """H = sum_{a 1e-14: + h_terms.append((pstr(n, **{f"q{a}": "X", f"q{b}": "X"}), J[a, b] / 2)) + h_terms.append((pstr(n, **{f"q{a}": "Y", f"q{b}": "Y"}), J[a, b] / 2)) + return h_terms + + +def eigenmode_jumps(ops, K): + """Jump list equivalent to the Kossakowski pair (ops, K). + + K = V diag(g) V^dagger; L_nu = sqrt(g_nu) sum_j conj(V_j_nu) A_j with + rate g_nu (the sqrt is absorbed into the rate as g_nu). + """ + g_nu, V = np.linalg.eigh(np.asarray(K, dtype=complex)) + jumps = [] + for nu in range(len(ops)): + if g_nu[nu] < 1e-12: + continue + lin = [] + for j, op in enumerate(ops): + v = np.conj(V[j, nu]) + if abs(v) > 1e-14: + for p, c in op: + lin.append((p, complex(c) * v)) + jumps.append((lin, float(g_nu[nu]))) + return jumps + + +def rate_observable(n, Gam): + """O = sum_nm Gamma_nm s+_n s-_m as {pauli_string: real_coeff}.""" + obs = {pstr(n): n * G0 / 2} + for a in range(n): + obs[pstr(n, **{f"q{a}": "Z"})] = G0 / 2 + for b in range(a + 1, n): + obs[pstr(n, **{f"q{a}": "X", f"q{b}": "X"})] = Gam[a, b] / 2 + obs[pstr(n, **{f"q{a}": "Y", f"q{b}": "Y"})] = Gam[a, b] / 2 + return obs + + +def exact_rate_sector(n, J, Gam, T_run, dt_out, dt_inner=2e-3): + """Exact Lindblad R(t) via the excitation-number cascade (dense blocks). + + From the fully inverted state, H conserves the excitation number M and + every jump lowers M symmetrically on both sides of rho, so rho(t) is a + direct sum of C(n, M)-sized blocks, evolved here with RK4. + """ + sectors = [] + for M in range(n, -1, -1): + confs = [frozenset(c) for c in itertools.combinations(range(n), M)] + sectors.append({c: i for i, c in enumerate(confs)}) + + def block(i, mat): + idx = sectors[i] + out = np.zeros((len(idx), len(idx)), dtype=complex) + for conf, b in idx.items(): + for m in conf: + for nn in range(n): + if nn == m: + out[b, b] += mat[m, m] + elif nn not in conf: + out[idx[(conf - {m}) | {nn}], b] += mat[nn, m] + return out + + H = [block(i, J) for i in range(n + 1)] + A = [block(i, Gam) for i in range(n + 1)] + add = [] + for i in range(1, n + 1): + idx, idx_up = sectors[i], sectors[i - 1] + amap = np.full((n, len(idx)), -1, dtype=np.int64) + for conf, b in idx.items(): + for m in range(n): + if m not in conf: + amap[m, b] = idx_up[conf | {m}] + add.append(amap) + + def feed(i, rho_up): + amap = add[i - 1] + out = np.zeros((len(sectors[i]), len(sectors[i])), dtype=complex) + padded = np.pad(rho_up, ((0, 1), (0, 1))) + for m in range(n): + for nn in range(n): + if abs(Gam[nn, m]) < 1e-14: + continue + sub = padded[amap[m][:, None], amap[nn][None, :]] + mask = (amap[m][:, None] >= 0) & (amap[nn][None, :] >= 0) + out += Gam[nn, m] * np.where(mask, sub, 0.0) + return out + + def rhs(blocks): + out = [] + for i, r in enumerate(blocks): + d = -1j * (H[i] @ r - r @ H[i]) - 0.5 * (A[i] @ r + r @ A[i]) + if i > 0: + d += feed(i, blocks[i - 1]) + out.append(d) + return out + + blocks = [np.zeros((len(s), len(s)), dtype=complex) for s in sectors] + blocks[0][0, 0] = 1.0 + n_out = round(T_run / dt_out) + sub = max(1, int(np.ceil(dt_out / dt_inner))) + h = dt_out / sub + R = np.zeros(n_out + 1) + R[0] = sum(np.trace(r @ a).real for r, a in zip(blocks, A)) + for k in range(n_out): + for _ in range(sub): + k1 = rhs(blocks) + k2 = rhs([r + h / 2 * d for r, d in zip(blocks, k1)]) + k3 = rhs([r + h / 2 * d for r, d in zip(blocks, k2)]) + k4 = rhs([r + h * d for r, d in zip(blocks, k3)]) + blocks = [ + r + h / 6 * (a + 2 * b + 2 * c + e) for r, a, b, c, e in zip(blocks, k1, k2, k3, k4) + ] + R[k + 1] = sum(np.trace(r @ a).real for r, a in zip(blocks, A)) + return np.arange(n_out + 1) * dt_out, R diff --git a/ppvm-python/test/lindblad/_helpers.py b/ppvm-python/test/lindblad/_helpers.py new file mode 100644 index 000000000..25484772e --- /dev/null +++ b/ppvm-python/test/lindblad/_helpers.py @@ -0,0 +1,356 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Shared helpers for the Lindbladian tests. + +Two reference kernels live here: + +- :func:`_reference_action` builds `L*(p)` for **Hermitian-Pauli** jumps via + the single-qubit Pauli multiplication table (cheap; only depends on `p`'s + weight). +- :func:`_dense_action` builds `L*(p)` for **arbitrary jump operators** by + constructing the full 2^L × 2^L dense Liouvillian. Only viable for L ≤ 3, + but it makes no assumption about the shape of the jumps. + +The bilinear NN-XY + Z-dephasing reference :func:`_bilinear_nn_xy_z_dephasing_obc` +gives an exact closed-form answer that the predictor-corrector tests converge +toward as dt → 0. +""" + +from __future__ import annotations + +import numpy as np + +# --- Pauli multiplication table for the Hermitian-Pauli reference ----------- +I, X, Y, Z = range(4) # noqa: E741 (I is standard Pauli notation here) +MUL = { + (I, I): (1, I), + (I, X): (1, X), + (I, Y): (1, Y), + (I, Z): (1, Z), + (X, I): (1, X), + (X, X): (1, I), + (X, Y): (1j, Z), + (X, Z): (-1j, Y), + (Y, I): (1, Y), + (Y, X): (-1j, Z), + (Y, Y): (1, I), + (Y, Z): (1j, X), + (Z, I): (1, Z), + (Z, X): (1j, Y), + (Z, Y): (-1j, X), + (Z, Z): (1, I), +} +CODE = {I: "I", X: "X", Y: "Y", Z: "Z"} +ICODE = {"I": I, "X": X, "Y": Y, "Z": Z} + + +def _str_to_tuple(s): + return tuple(ICODE[ch] for ch in s) + + +def _tuple_to_str(t): + return "".join(CODE[ch] for ch in t) + + +def _mul_pauli(p, q): + """Return ``(phase, p·q)`` for two Pauli strings given as code tuples.""" + phase = 1 + r = list(p) + for i, (pi, qi) in enumerate(zip(p, q)): + ph, rr = MUL[(pi, qi)] + phase *= ph + r[i] = rr + return phase, tuple(r) + + +def _reference_action(p_str, h_terms, jump_terms): + """``L*(p) = i[H, p] + sum_k gamma_k (L_k p L_k - p)``, term by term.""" + p = _str_to_tuple(p_str) + out: dict = {} + for h_str, coeff_h in h_terms: + h = _str_to_tuple(h_str) + ph_pp, pp = _mul_pauli(h, p) + ph_pq, _ = _mul_pauli(p, h) + # i[H, p] = i (Hp - pH) = i (ph_pp - ph_pq) r ; real for Hermitian H, p. + coeff = (1j * coeff_h * (ph_pp - ph_pq)).real + if coeff: + out[pp] = out.get(pp, 0.0) + coeff + for j_str, gamma in jump_terms: + j = _str_to_tuple(j_str) + ph_pp, _ = _mul_pauli(j, p) + # Hermitian Pauli L, p: Lp has imaginary phase iff they anti-commute, + # and then L p L = -p, contributing -2 gamma p. + if abs(ph_pp.imag) > 0.5: + out[p] = out.get(p, 0.0) + (-2.0 * gamma) + return {kk: v for kk, v in out.items() if v} + + +def _to_str_dict(d): + return {_tuple_to_str(kk): v for kk, v in d.items()} + + +# --- model builders --------------------------------------------------------- + + +def xy_dephasing(L, alpha, gamma): + """Long-range XY model with PBC + Z dephasing.""" + pairs = [ + (a, b, 1.0 / min(b - a, L - b + a) ** alpha) for a in range(L) for b in range(a + 1, L) + ] + kac = sum(j for _, _, j in pairs) / L + pairs = [(a, b, j / kac) for a, b, j in pairs] + h_terms = [] + for a, b, j in pairs: + for q in "XY": + term = ["I"] * L + term[a] = term[b] = q + h_terms.append(("".join(term), j)) + jump_terms = [("I" * i + "Z" + "I" * (L - i - 1), gamma) for i in range(L)] + return h_terms, jump_terms + + +def tfim_xdeph(L, J, h, gamma): + """TFIM (ZZ + transverse X) with X dephasing.""" + h_terms = [] + for i in range(L - 1): + term = ["I"] * L + term[i] = term[i + 1] = "Z" + h_terms.append(("".join(term), J)) + for i in range(L): + term = ["I"] * L + term[i] = "X" + h_terms.append(("".join(term), h)) + jump_terms = [("I" * i + "X" + "I" * (L - i - 1), gamma) for i in range(L)] + return h_terms, jump_terms + + +def nn_xy_z_dephasing_obc(L, J, gamma): + """Nearest-neighbour XY (OBC) + per-site Z dephasing.""" + h_terms = [] + for i in range(L - 1): + a, b = i, i + 1 + xs = ["I"] * L + xs[a] = xs[b] = "X" + ys = ["I"] * L + ys[a] = ys[b] = "Y" + h_terms += [("".join(xs), J), ("".join(ys), J)] + jump_terms = [("I" * j + "Z" + "I" * (L - j - 1), gamma) for j in range(L)] + return h_terms, jump_terms + + +def random_pauli_str(rng, L): + chars = ["I"] * L + positions = rng.choice(L, size=int(rng.integers(1, L + 1)), replace=False) + for q in positions: + chars[q] = rng.choice(["X", "Y", "Z"]) + return "".join(chars) + + +def assert_action_matches(L_op, h_terms, jump_terms, strings): + """Compare `L_op.action(p)` against :func:`_reference_action` for each `p`.""" + for p in strings: + got = L_op.action(p) + want = _to_str_dict(_reference_action(p, h_terms, jump_terms)) + for kk in set(got) | set(want): + assert abs(got.get(kk, 0.0) - want.get(kk, 0.0)) < 1e-12, ( + f"action mismatch at p={p!r} k={kk!r}: " + f"shim={got.get(kk, 0.0)} ref={want.get(kk, 0.0)}" + ) + + +# --- dense Liouvillian reference (for non-Hermitian jumps) ------------------ + +_DENSE_PAULI = { + "I": np.eye(2, dtype=complex), + "X": np.array([[0, 1], [1, 0]], dtype=complex), + "Y": np.array([[0, -1j], [1j, 0]], dtype=complex), + "Z": np.array([[1, 0], [0, -1]], dtype=complex), +} +SIGMA_MINUS_MAT = np.array([[0, 0], [1, 0]], dtype=complex) +SIGMA_PLUS_MAT = np.array([[0, 1], [0, 0]], dtype=complex) + + +def pauli_mat(s): + """Dense matrix for Pauli string ``s`` (leftmost char = leftmost factor).""" + M = np.array([[1.0]], dtype=complex) + for c in s: + M = np.kron(M, _DENSE_PAULI[c]) + return M + + +def all_strings(L): + if L == 0: + return [""] + sub = all_strings(L - 1) + return [c + s for c in "IXYZ" for s in sub] + + +def dense_action(H, jumps, p_str, L): + """`L*(p)` computed densely, returned as a real Pauli-coefficient dict.""" + p_mat = pauli_mat(p_str) + # macOS Accelerate emits spurious divide warnings on exact zeros. + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): + out_mat = 1j * (H @ p_mat - p_mat @ H) + for Lop, gamma in jumps: + Ld = Lop.conj().T + out_mat += gamma * (Ld @ p_mat @ Lop - 0.5 * (Ld @ Lop @ p_mat + p_mat @ Ld @ Lop)) + d = 2**L + out = {} + for q_str in all_strings(L): + coef = np.trace(pauli_mat(q_str) @ out_mat) / d + assert abs(coef.imag) < 1e-9, f"non-real coefficient for {q_str}: {coef}" + if abs(coef.real) > 1e-12: + out[q_str] = coef.real + return out + + +def embed_op(op_1q, site, L): + """Embed a single-qubit dense operator at ``site`` of an L-qubit register.""" + eye = _DENSE_PAULI["I"] + M = np.array([[1.0]], dtype=complex) + for j in range(L): + M = np.kron(M, op_1q if j == site else eye) + return M + + +# --- closed bilinear evolution for NN-XY + Z-dephasing (OBC) ---------------- + + +def bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0): + """Closed bilinear evolution of `C_j(t)` for the NN XY + Z-dephasing model + with open boundary conditions. + + `F_{mn}(t) = 2^{-L} Tr[B_{mn}(t) Z_i]` evolves as + `∂_t F_{mn} = i·2J·(F_{m+1,n}+F_{m-1,n}-F_{m,n+1}-F_{m,n-1}) - 4γ(1-δ_{mn})F_{mn}` + on the L×L lattice; OBC means edge terms (m=0 or m=L-1, etc.) drop. + Z_j = I - 2 n_j gives C_j = -2 F_{jj}. + """ + dim = L * L + + def idx(m, n): + return m * L + n + + gen = np.zeros((dim, dim), dtype=complex) + for m in range(L): + for n in range(L): + row = idx(m, n) + if m + 1 < L: + gen[row, idx(m + 1, n)] += 1j * 2 * J + if m - 1 >= 0: + gen[row, idx(m - 1, n)] += 1j * 2 * J + if n + 1 < L: + gen[row, idx(m, n + 1)] += -1j * 2 * J + if n - 1 >= 0: + gen[row, idx(m, n - 1)] += -1j * 2 * J + if m != n: + gen[row, row] += -4 * gamma + + evals, evecs = np.linalg.eig(gen) + evecs_inv = np.linalg.inv(evecs) + f0 = np.zeros(dim, dtype=complex) + f0[idx(site0, site0)] = -0.5 + coeffs = evecs_inv @ f0 + + corr = np.empty((len(times), L)) + diag = [idx(j, j) for j in range(L)] + for nt, t in enumerate(times): + ft = evecs @ (np.exp(evals * t) * coeffs) + corr[nt] = np.real(-2 * ft[diag]) + return corr + + +# --- numpy-only matrix exponential reference -------------------------------- + + +def coo_to_dense(triples, n_basis): + """Build a dense ``(n_basis, n_basis)`` array from COO triples.""" + rows, cols, vals = triples + M = np.zeros((n_basis, n_basis), dtype=float) + M[rows, cols] = vals + return M + + +def expm_mv_dense(M, v): + """``exp(M) @ v`` via numpy eigendecomposition. Independent of the Rust + Al-Mohy & Higham implementation; small bases only. + + The Lindbladian is generally diagonalizable, so + ``M = V diag(λ) V^{-1}`` and ``exp(M) v = V (exp(λ) ⊙ (V^{-1} v))``. + """ + evals, evecs = np.linalg.eig(M) + rhs = np.linalg.solve(evecs, v.astype(complex)) + return np.real(evecs @ (np.exp(evals) * rhs)) + + +# --- adaptive PC evolution reference (used by test_adaptive_pc.py) ---------- + + +def _generator_dense(L_op, basis): + """`L_op.generator(basis)` returns COO triples; convert to dense.""" + return coo_to_dense(L_op.generator(basis), len(basis)) + + +def adaptive_z_correlator(L_op, L, site0, dt, n_steps, tau_add): + """Adaptive Heisenberg-picture evolution of Z_{site0} on a growing basis. + + First-hop only: each step adds the strings from `L_op.leakage(...)`, + then matrix-exponentiates the (small, dense) restricted generator via + numpy eigendecomposition. Local truncation is O(dt²) per step. + """ + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + + corr = np.zeros((n_steps + 1, L)) + corr[0, site0] = 1.0 + + for step in range(n_steps): + leak = L_op.leakage(basis, coeffs, protected=protected) + new = [k for k, v in leak.items() if abs(v) > tau_add] + if new: + basis = basis + new + coeffs = np.concatenate([coeffs, np.zeros(len(new))]) + M = _generator_dense(L_op, basis) + coeffs = expm_mv_dense(dt * M, coeffs) + index = {s: i for i, s in enumerate(basis)} + for j in range(L): + if z_strings[j] in index: + corr[step + 1, j] = coeffs[index[z_strings[j]]] + return corr + + +def adaptive_z_correlator_pc(L_op, L, site0, dt, n_steps, tau_add): + """Same as :func:`adaptive_z_correlator` but with predictor-corrector + basis expansion: predict, then add the second-hop leakage strings before + redoing the step. Lifts the per-step truncation from O(dt²) to O(dt³). + """ + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + + corr = np.zeros((n_steps + 1, L)) + corr[0, site0] = 1.0 + + for step in range(n_steps): + leak = L_op.leakage(basis, coeffs, protected=protected) + new = [k for k, v in leak.items() if abs(v) > tau_add] + if new: + basis = basis + new + coeffs = np.concatenate([coeffs, np.zeros(len(new))]) + coeffs_pre = coeffs.copy() + M = _generator_dense(L_op, basis) + coeffs_predict = expm_mv_dense(dt * M, coeffs) + leak2 = L_op.leakage(basis, coeffs_predict, protected=protected) + new2 = [k for k, v in leak2.items() if abs(v) > tau_add] + if new2: + basis = basis + new2 + coeffs_pre = np.concatenate([coeffs_pre, np.zeros(len(new2))]) + M = _generator_dense(L_op, basis) + coeffs = expm_mv_dense(dt * M, coeffs_pre) + index = {s: i for i, s in enumerate(basis)} + for j in range(L): + if z_strings[j] in index: + corr[step + 1, j] = coeffs[index[z_strings[j]]] + return corr diff --git a/ppvm-python/test/lindblad/test_action_generator.py b/ppvm-python/test/lindblad/test_action_generator.py new file mode 100644 index 000000000..7b571c9c9 --- /dev/null +++ b/ppvm-python/test/lindblad/test_action_generator.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Cross-checks for :meth:`Lindbladian.action` / :meth:`Lindbladian.generator` +/ :meth:`Lindbladian.leakage` against the Hermitian-Pauli reference built from +the single-qubit Pauli multiplication table. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from ppvm import Lindbladian + +from ._helpers import ( + _reference_action, + _tuple_to_str, + assert_action_matches, + coo_to_dense, + expm_mv_dense, + random_pauli_str, + tfim_xdeph, + xy_dephasing, +) + + +def test_action_xy_dephasing(): + L = 8 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_op = Lindbladian(L, h_terms, jump_terms) + rng = np.random.default_rng(42) + strings = ["I" * L] + strings += ["I" * i + "Z" + "I" * (L - i - 1) for i in range(L)] + strings += [random_pauli_str(rng, L) for _ in range(20)] + assert_action_matches(L_op, h_terms, jump_terms, strings) + + +def test_action_tfim_xdephasing(): + L = 6 + h_terms, jump_terms = tfim_xdeph(L, J=0.7, h=0.4, gamma=0.2) + L_op = Lindbladian(L, h_terms, jump_terms) + rng = np.random.default_rng(7) + strings = [random_pauli_str(rng, L) for _ in range(30)] + assert_action_matches(L_op, h_terms, jump_terms, strings) + + +def test_generator_leakage_and_expm(): + L = 5 + dt = 0.1 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.4) + L_op = Lindbladian(L, h_terms, jump_terms) + basis = ["I" * i + "Z" + "I" * (L - i - 1) for i in range(L)] + basis += ["YIYII", "IYIYI", "ZZIII"] + coeffs = np.array([0.5, -0.3, 0.2, 0.4, -0.1, 0.6, 0.2, 0.1]) + + M_shim = coo_to_dense(L_op.generator(basis), len(basis)) + M_ref = np.zeros((len(basis), len(basis))) + index = {p: i for i, p in enumerate(basis)} + for col, p in enumerate(basis): + for r_tuple, v in _reference_action(p, h_terms, jump_terms).items(): + r = _tuple_to_str(r_tuple) + if r in index: + M_ref[index[r], col] += v + assert np.max(np.abs(M_shim - M_ref)) < 1e-12 + + shim_leak = L_op.leakage(basis, coeffs) + ref_leak: dict = {} + for p, cf in zip(basis, coeffs): + for r_tuple, v in _reference_action(p, h_terms, jump_terms).items(): + r = _tuple_to_str(r_tuple) + if r not in index: + ref_leak[r] = ref_leak.get(r, 0.0) + v * cf + ref_leak = {kk: v for kk, v in ref_leak.items() if v} + for kk in set(shim_leak) | set(ref_leak): + assert abs(shim_leak.get(kk, 0.0) - ref_leak.get(kk, 0.0)) < 1e-12 + + c_shim = expm_mv_dense(dt * M_shim, coeffs) + c_ref = expm_mv_dense(dt * M_ref, coeffs) + assert np.allclose(c_shim, c_ref, atol=1e-13) + + +def test_generator_rejects_duplicate_basis(): + """Duplicate basis rows would silently overwrite each other in the + row-index map and produce an incorrect sparse generator. The user-facing + entry point must reject them with a clear ValueError instead. + """ + L = 4 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_op = Lindbladian(L, h_terms, jump_terms) + basis = ["ZIII", "IZII", "ZIII"] # duplicate at rows 0 and 2 + with pytest.raises(ValueError, match=r"duplicate Pauli word at row 0 and row 2"): + L_op.generator(basis) + # pc_step also builds the row index and must reject duplicates. + with pytest.raises(ValueError, match=r"duplicate Pauli word"): + L_op.pc_step(basis, np.ones(len(basis)), 0.01, 10_000_000) + + +def test_protected_strings_suppressed(): + L = 4 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.0) + L_op = Lindbladian(L, h_terms, jump_terms) + basis = ["ZIII"] + coeffs = np.array([1.0]) + leak = L_op.leakage(basis, coeffs) + assert leak, "expected some leakage" + protected_key = next(iter(leak)) + leak2 = L_op.leakage(basis, coeffs, protected=[protected_key]) + assert protected_key not in leak2 diff --git a/ppvm-python/test/lindblad/test_adaptive_pc.py b/ppvm-python/test/lindblad/test_adaptive_pc.py new file mode 100644 index 000000000..ab467c90c --- /dev/null +++ b/ppvm-python/test/lindblad/test_adaptive_pc.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""End-to-end convergence of the adaptive predictor-corrector evolution +(numpy eigendecomposition reference) against the closed bilinear NN-XY + +Z-dephasing solution. + +For NN interactions the JW bilinears stay closed under the adjoint +Lindbladian, so the spin correlator obeys a tractable L²×L² ODE +(:func:`bilinear_nn_xy_z_dephasing_obc`). OBC keeps this exact; PBC would +introduce a parity-twist that only matches up to 1/L corrections. +""" + +from __future__ import annotations + +from itertools import pairwise + +import numpy as np + +from ppvm import Lindbladian + +from ._helpers import ( + adaptive_z_correlator, + adaptive_z_correlator_pc, + bilinear_nn_xy_z_dephasing_obc, + nn_xy_z_dephasing_obc, +) + + +def test_adaptive_converges_to_nn_xy_z_dephasing_bilinear(): + """Halving dt drives the adaptive stepper toward the closed bilinear solution. + + Single-hop has local truncation O(dt²) per step → global error O(T·dt). + """ + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + T = 0.05 + tau_add = 1e-12 # tight enough that integrator (T·dt) dominates + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + errors = [] + final_corr = None + for dt in (0.01, 0.005, 0.0025): + n_steps = round(T / dt) + times = np.arange(n_steps + 1) * dt + exact = bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0) + shim = adaptive_z_correlator(L_op, L, site0, dt, n_steps, tau_add) + # Endpoint comparison only — independent of which step counts we use. + errors.append(float(np.max(np.abs(shim[-1] - exact[-1])))) + if dt == 0.0025: + final_corr = (shim[-1], exact[-1]) + + assert final_corr is not None + + # Halving dt should roughly halve the error; allow 2× slack. + assert errors[1] < 0.8 * errors[0], f"dt-halving did not help: {errors}" + assert errors[2] < 0.8 * errors[1], f"dt-halving did not help: {errors}" + + # Integrator floor at dt = 0.0025 is ~T·dt = 1.25e-4; expect <1e-3. + assert errors[-1] < 1e-3, ( + f"shim vs bilinear at smallest dt: max abs error = {errors[-1]:.3g}; " + f"shim={final_corr[0]}, exact={final_corr[1]}" + ) + + +def test_predictor_corrector_lifts_dt_scaling_to_cubic(): + """The predictor-corrector basis expansion lifts the single-hop scheme's + local O(dt²) truncation to O(dt³). PC error is also strictly smaller at + every dt we test. + """ + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + T = 0.05 + tau_add = 1e-12 + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + err_single = [] + err_pc = [] + for dt in (0.01, 0.005, 0.0025): + n_steps = round(T / dt) + times = np.arange(n_steps + 1) * dt + exact = bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0) + single = adaptive_z_correlator(L_op, L, site0, dt, n_steps, tau_add) + pc = adaptive_z_correlator_pc(L_op, L, site0, dt, n_steps, tau_add) + err_single.append(float(np.max(np.abs(single[-1] - exact[-1])))) + err_pc.append(float(np.max(np.abs(pc[-1] - exact[-1])))) + + # PC strictly more accurate than single-hop at every dt (by ~100× in this + # regime). Threshold loose enough to absorb expm_multiply tolerance noise. + for s, p, dt in zip(err_single, err_pc, (0.01, 0.005, 0.0025)): + assert p < s / 50, ( + f"PC ({p:.3e}) not meaningfully better than single-hop ({s:.3e}) at dt={dt}" + ) + + # dt-scaling one order steeper: halving dt should drop the error ~8× + # (dt³ vs single-hop's ~4×). Require >5× per halving with safety margin. + for prev, curr in pairwise(err_pc): + assert curr < prev / 5, f"PC dt-halving ratio < 5: errors {err_pc}" + + # Smallest-dt PC error should sit at FP noise of the bilinear reference. + assert err_pc[-1] < 1e-7, f"PC at smallest dt: error = {err_pc[-1]:.3e}" diff --git a/ppvm-python/test/lindblad/test_kossakowski.py b/ppvm-python/test/lindblad/test_kossakowski.py new file mode 100644 index 000000000..96570f353 --- /dev/null +++ b/ppvm-python/test/lindblad/test_kossakowski.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Kossakowski-form dissipator: exact equivalence with the eigenmode-jump +representation, physics regression against the exact excitation-cascade +reference, and leakage coverage. +""" + +import pathlib + +import numpy as np +import pytest + +from ppvm import Lindbladian +from ppvm.lindblad import _basis_to_codes, _codes_to_basis, sigma_minus + +from ._dissipative_refs import ( + chain_positions, + couplings, + eigenmode_jumps, + exact_rate_sector, + hamiltonian_terms, + rate_observable, +) + +BIG = 10_000_000 # uncapped max_basis + +FIG11_H5 = pathlib.Path( + "/Users/alexschuckert/dev/26_ppvm/CTPP Figures/fig11_superradiant_burst/data.h5" +) + + +def run_steps(lind, obs, n, dt, steps): + """Evolve {string: coeff} a few uncapped pc steps; return final dict.""" + strings = list(obs) + basis = _basis_to_codes(strings, n) + coeff = np.array([obs[s] for s in strings], dtype=np.float64) + for _ in range(steps): + basis, coeff = lind.pc_step_arr(basis, coeff, dt, max_basis=BIG, drop_tol=0.0) + return dict(zip(_codes_to_basis(basis), coeff)) + + +def random_psd(rng, m, complex_k=False): + a = rng.standard_normal((m, m)) + if complex_k: + a = a + 1j * rng.standard_normal((m, m)) + return a @ a.conj().T / m + + +def random_lincomb(rng, n): + """A random 2-term Pauli lincomb with complex coefficients.""" + ops = "IXYZ" + out = [] + for _ in range(2): + s = "".join(rng.choice(list(ops)) for _ in range(n)) + if s == "I" * n: + s = "X" + s[1:] + c = complex(rng.standard_normal(), rng.standard_normal()) + out.append((s, c)) + return out + + +@pytest.mark.parametrize("complex_k", [False, True]) +def test_random_model_equivalence(complex_k): + """Eigenmode jumps built from K and kossakowski=(ops, K) produce the + same evolution to near machine precision (same dt, uncapped basis).""" + rng = np.random.default_rng(7 if complex_k else 3) + n, dt, steps = 4, 0.02, 3 + # ops: all single-site sigma^- plus one random 2-term lincomb + ops = [sigma_minus(j, n) for j in range(n)] + [random_lincomb(rng, n)] + K = random_psd(rng, len(ops), complex_k) + h_terms = [("XX" + "I" * (n - 2), 0.9), ("I" + "ZZ" + "I" * (n - 3), -0.4)] + obs = {"Z" + "I" * (n - 1): 1.0, "IXY" + "I" * (n - 3): 0.3} + + out_k = run_steps(Lindbladian(n, h_terms, kossakowski=(ops, K)), obs, n, dt, steps) + out_j = run_steps(Lindbladian(n, h_terms, eigenmode_jumps(ops, K)), obs, n, dt, steps) + + assert set(out_k) == set(out_j) + max_dev = max(abs(out_k[s] - out_j[s]) for s in out_k) + assert max_dev < 1e-12, f"representations diverged: max |dc| = {max_dev:.2e}" + + +def test_kossakowski_coexists_with_jump_terms(): + """kossakowski= and jump_terms may both contribute.""" + n = 2 + ops = [sigma_minus(j, n) for j in range(n)] + K = [[1.0, 0.5], [0.5, 1.0]] + both = Lindbladian(n, [], [("ZI", 0.3)], kossakowski=(ops, K)) + only_k = Lindbladian(n, [], kossakowski=(ops, K)) + only_j = Lindbladian(n, [], [("ZI", 0.3)]) + a_both = both.action("XI") + a_sum = {} + for d in (only_k.action("XI"), only_j.action("XI")): + for s, c in d.items(): + a_sum[s] = a_sum.get(s, 0.0) + c + for s in set(a_both) | set(a_sum): + assert abs(a_both.get(s, 0.0) - a_sum.get(s, 0.0)) < 1e-13 + + +def superradiance_chain(n, d_over_lam=0.1): + J, Gam = couplings(chain_positions(n, d_over_lam)) + ops = [sigma_minus(j, n) for j in range(n)] + return J, Gam, hamiltonian_terms(n, J), ops, rate_observable(n, Gam) + + +def rate_trace(lind, obs, n, dt, steps): + """R(t) on the fully inverted state = sum of {I,Z}-string coefficients.""" + strings = list(obs) + basis = _basis_to_codes(strings, n) + coeff = np.array([obs[s] for s in strings], dtype=np.float64) + R = np.zeros(steps + 1) + for k in range(steps + 1): + iz = np.all((basis == 0) | (basis == 2), axis=1) # codes: I=0, Z=2 + R[k] = coeff[iz].sum() + if k == steps: + break + basis, coeff = lind.pc_step_arr(basis, coeff, dt, max_basis=BIG, drop_tol=0.0) + return R + + +def test_superradiance_physics_regression(): + """N=6 subwavelength chain, full basis, T=1: the Kossakowski path matches + the eigenmode path to ~1e-12 and the exact cascade reference to < 1e-4.""" + n, dt, T = 6, 0.01, 1.0 + steps = round(T / dt) + J, Gam, h_terms, ops, obs = superradiance_chain(n) + + R_k = rate_trace(Lindbladian(n, h_terms, kossakowski=(ops, Gam)), obs, n, dt, steps) + R_j = rate_trace(Lindbladian(n, h_terms, eigenmode_jumps(ops, Gam)), obs, n, dt, steps) + assert np.abs(R_k - R_j).max() < 1e-11, ( + f"kossakowski vs eigenmode R(t): {np.abs(R_k - R_j).max():.2e}" + ) + + _, R_exact = exact_rate_sector(n, J, Gam, T_run=T, dt_out=dt) + err = np.abs(R_k - R_exact).max() + assert err < 1e-4, f"kossakowski vs exact cascade: max |dR| = {err:.2e}" + + +@pytest.mark.skipif(not FIG11_H5.exists(), reason="fig11 data.h5 not present") +def test_superradiance_vs_fig11_reference(): + """Cross-check R(t) against the stored exact reference of the + superradiant-burst study (same model, N=6, first 1/Gamma_0).""" + h5py = pytest.importorskip("h5py") + n, dt, T = 6, 0.01, 1.0 + steps = round(T / dt) + _, Gam, h_terms, ops, obs = superradiance_chain(n) + R_k = rate_trace(Lindbladian(n, h_terms, kossakowski=(ops, Gam)), obs, n, dt, steps) + with h5py.File(FIG11_H5, "r") as h5: + R_ref = h5["n6/exact"][: steps + 1] + err_ref = np.abs(R_k - R_ref).max() + assert err_ref < 1e-4, f"vs fig11 data.h5 n6/exact: {err_ref:.2e}" + + +def test_leakage_covers_kossakowski_terms(): + """Leakage of a Z string under a pure-Kossakowski dissipator is nonzero + and identical to the eigenmode-jump leakage (admission sees the same + physics).""" + n = 3 + ops = [sigma_minus(j, n) for j in range(n)] + _, Gam = couplings(chain_positions(n, 0.1)) + lk = Lindbladian(n, [], kossakowski=(ops, Gam)) + lj = Lindbladian(n, [], eigenmode_jumps(ops, Gam)) + + basis = ["ZII"] + coeffs = np.array([1.0]) + leak_k = lk.leakage(basis, coeffs) + leak_j = lj.leakage(basis, coeffs) + assert leak_k, "pure-Kossakowski dissipator produced empty leakage" + assert set(leak_k) == set(leak_j) + for s in leak_k: + assert abs(leak_k[s] - leak_j[s]) < 1e-12 diff --git a/ppvm-python/test/lindblad/test_non_hermitian_jumps.py b/ppvm-python/test/lindblad/test_non_hermitian_jumps.py new file mode 100644 index 000000000..e0170a567 --- /dev/null +++ b/ppvm-python/test/lindblad/test_non_hermitian_jumps.py @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Non-Hermitian dissipators: cross-check :meth:`Lindbladian.action` / +:meth:`Lindbladian.generator` / :meth:`Lindbladian.leakage` against the dense +2^L × 2^L Liouvillian reference. Only viable for L ≤ 3. +""" + +from __future__ import annotations + +import numpy as np + +from ppvm import Lindbladian, sigma_minus, sigma_plus + +from ._helpers import ( + SIGMA_MINUS_MAT, + SIGMA_PLUS_MAT, + all_strings, + coo_to_dense, + dense_action, + embed_op, + pauli_mat, + random_pauli_str, +) + + +def test_amplitude_damping_action(): + L = 3 + gamma = 0.5 + h_terms = [("XXI", 1.0), ("IXX", 0.7), ("ZIZ", 0.3)] + jump_terms = [(sigma_minus(i, L), gamma) for i in range(L)] + L_op = Lindbladian(L, h_terms, jump_terms) + + H = sum(c * pauli_mat(s) for s, c in h_terms) + jumps_dense = [(embed_op(SIGMA_MINUS_MAT, i, L), gamma) for i in range(L)] + + rng = np.random.default_rng(11) + strings = ["III", "ZII", "IZI", "IIZ", "XYZ", "YYZ"] + strings += [random_pauli_str(rng, L) for _ in range(10)] + + for p in strings: + got = L_op.action(p) + want = dense_action(H, jumps_dense, p, L) + for k in set(got) | set(want): + diff = abs(got.get(k, 0.0) - want.get(k, 0.0)) + assert diff < 1e-10, ( + f"sigma_minus action mismatch at p={p!r} k={k!r}: " + f"shim={got.get(k, 0.0)} ref={want.get(k, 0.0)} diff={diff}" + ) + + +def test_thermal_excitation_damping_action(): + """σ⁺ + σ⁻ jumps together (thermal bath at finite temperature).""" + L = 2 + h_terms = [("XX", 1.0), ("ZI", 0.2), ("IZ", 0.1)] + jump_terms = [(sigma_minus(i, L), 0.4) for i in range(L)] + [ + (sigma_plus(i, L), 0.1) for i in range(L) + ] + L_op = Lindbladian(L, h_terms, jump_terms) + + H = sum(c * pauli_mat(s) for s, c in h_terms) + jumps_dense = [(embed_op(SIGMA_MINUS_MAT, i, L), 0.4) for i in range(L)] + [ + (embed_op(SIGMA_PLUS_MAT, i, L), 0.1) for i in range(L) + ] + + for p in all_strings(L): + got = L_op.action(p) + want = dense_action(H, jumps_dense, p, L) + for k in set(got) | set(want): + diff = abs(got.get(k, 0.0) - want.get(k, 0.0)) + assert diff < 1e-10, ( + f"sigma_plus/sigma_minus action mismatch at p={p!r} k={k!r}: " + f"shim={got.get(k, 0.0)} ref={want.get(k, 0.0)}" + ) + + +def test_amplitude_damping_generator_and_leakage(): + L = 3 + gamma = 0.3 + h_terms = [("XXI", 0.5), ("IXX", 0.5), ("ZII", 0.2), ("IZI", 0.2), ("IIZ", 0.2)] + jump_terms = [(sigma_minus(0, L), gamma), (sigma_minus(2, L), gamma)] + L_op = Lindbladian(L, h_terms, jump_terms) + + basis = ["III", "ZII", "IZI", "IIZ", "ZZI", "IZZ"] + coeffs = np.array([0.1, 0.5, -0.3, 0.4, 0.2, -0.1]) + + H = sum(c * pauli_mat(s) for s, c in h_terms) + jumps_dense = [(embed_op(SIGMA_MINUS_MAT, i, L), gamma) for i in (0, 2)] + + M_shim = coo_to_dense(L_op.generator(basis), len(basis)) + M_ref = np.zeros((len(basis), len(basis))) + idx = {p: i for i, p in enumerate(basis)} + leak_ref: dict = {} + for col, p in enumerate(basis): + action_p = dense_action(H, jumps_dense, p, L) + for q, v in action_p.items(): + if q in idx: + M_ref[idx[q], col] += v + else: + leak_ref[q] = leak_ref.get(q, 0.0) + v * coeffs[col] + assert np.max(np.abs(M_shim - M_ref)) < 1e-10 + + leak_shim = L_op.leakage(basis, coeffs) + leak_ref = {k: v for k, v in leak_ref.items() if abs(v) > 1e-14} + for k in set(leak_shim) | set(leak_ref): + diff = abs(leak_shim.get(k, 0.0) - leak_ref.get(k, 0.0)) + assert diff < 1e-10, f"leakage mismatch at k={k!r}: diff={diff}" diff --git a/ppvm-python/test/lindblad/test_orbit_dissipative.py b/ppvm-python/test/lindblad/test_orbit_dissipative.py new file mode 100644 index 000000000..d58aa6b2d --- /dev/null +++ b/ppvm-python/test/lindblad/test_orbit_dissipative.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Dissipative generators on the orbit-rep path. + +`pc_step_orbit_rep` evolves canonical translation-orbit representatives +with complex coefficients under the convention set by the momentum +projector `canonicalize_basis_arr_complex`: + + c_rep = coeff of the representative word itself, + +on every orbit, whether or not it has a non-trivial stabilizer. + +Under this convention the phase-aware action is exact for ANY equivariant +generator, including jump and Kossakowski dissipators: transitions between +orbits of different sizes (e.g. the non-unital Z → I flow of σ⁻ decay, +where I is stabilized by the whole group) carry the correct weight. An +orbit contributes ``|orbit| · c_rep`` to a translation-invariant {I,Z} +sum, so the emission rate in rep space is ``R = Σ |orbit(rep)| · c_rep`` +over {I,Z} reps — see `cyclic_orbit_size` below. + +Model: ring of N emitters (positions on a circle → circulant J and Γ, +exact C_N symmetry), collective σ⁻ decay, momentum sector k = 0. +""" + +import numpy as np +import pytest + +from ppvm import Lindbladian +from ppvm._core import TranslationGroup, canonicalize_basis_arr_complex +from ppvm.lindblad import _basis_to_codes, _codes_to_basis, sigma_minus + +from ._dissipative_refs import ( + couplings, + eigenmode_jumps, + exact_rate_sector, + hamiltonian_terms, + rate_observable, + ring_positions, +) + +BIG = 10_000_000 + + +def ring_model(n, d_over_lam=0.1): + J, Gam = couplings(ring_positions(n, d_over_lam)) + assert np.allclose(Gam, np.roll(np.roll(Gam, 1, 0), 1, 1)), "Gamma not circulant" + assert np.linalg.eigvalsh(Gam).min() > -1e-12, "Gamma not PSD" + ops = [sigma_minus(j, n) for j in range(n)] + return J, Gam, hamiltonian_terms(n, J), ops, rate_observable(n, Gam) + + +def to_rep(basis, coeff, group, mom): + b, c = canonicalize_basis_arr_complex(basis, np.asarray(coeff, dtype=np.complex128), group, mom) + return dict(zip(_codes_to_basis(b), c)) + + +@pytest.mark.parametrize("representation", ["kossakowski", "eigenmode"]) +def test_orbit_matches_full_basis(representation): + """N=6 ring, uncapped: orbit-rep evolution equals the full-basis + real-space evolution projected to rep space, coefficient by + coefficient.""" + n, dt, steps = 6, 0.02, 4 + _, Gam, h_terms, ops, obs = ring_model(n) + if representation == "kossakowski": + lind = Lindbladian(n, h_terms, kossakowski=(ops, Gam)) + else: + lind = Lindbladian(n, h_terms, eigenmode_jumps(ops, Gam)) + group = TranslationGroup.chain_1d(n) + mom = np.array([0], dtype=np.int32) + + strings = list(obs) + basis0 = _basis_to_codes(strings, n) + coeff0 = np.array([obs[s] for s in strings]) + + b, c = basis0.copy(), coeff0.copy() + for _ in range(steps): + b, c = lind.pc_step_arr(b, c, dt, max_basis=BIG, drop_tol=0.0) + full = to_rep(b, c, group, mom) + + br, cr = canonicalize_basis_arr_complex(basis0, coeff0.astype(np.complex128), group, mom) + for _ in range(steps): + br, cr = lind.pc_step_orbit_rep( + br, cr, dt, max_basis=BIG, group=group, momentum=mom, drop_tol=0.0 + ) + orbit = dict(zip(_codes_to_basis(br), cr)) + + assert set(full) == set(orbit) + max_dev = max(abs(full[s] - orbit[s]) for s in full) + assert max_dev < 1e-12, f"orbit vs full-basis: max |dc| = {max_dev:.2e}" + + +def cyclic_orbit_size(word: str) -> int: + """Number of distinct cyclic rotations of `word` — its orbit size under + `TranslationGroup.chain_1d`. Words fixed by a non-trivial shift (e.g. + the identity, or `ZIZI`) have an orbit smaller than `|G|`.""" + return len({word[i:] + word[:i] for i in range(len(word))}) + + +def orbit_rate_trace(lind, obs, n, dt, steps, group, mom, max_basis=BIG, admit=None): + """R(t) from the orbit-rep evolution: the {I,Z} sum over all real-space + words, reassembled as `Σ_reps |orbit(rep)| · c_rep`.""" + strings = list(obs) + basis0 = _basis_to_codes(strings, n) + coeff0 = np.array([obs[s] for s in strings]) + br, cr = canonicalize_basis_arr_complex(basis0, coeff0.astype(np.complex128), group, mom) + R = np.zeros(steps + 1) + peak = 0 + for k in range(steps + 1): + iz = np.all((br == 0) | (br == 2), axis=1) + sizes = np.array([cyclic_orbit_size(w) for w in _codes_to_basis(br[iz])]) + R[k] = (sizes * cr[iz]).sum().real + peak = max(peak, len(cr)) + if k == steps: + break + br, cr = lind.pc_step_orbit_rep( + br, + cr, + dt, + max_basis=max_basis, + group=group, + momentum=mom, + drop_tol=0.0, + admit_basis=admit, + ) + return R, peak + + +def test_orbit_rate_vs_exact_cascade(): + """N=6 ring, T=1, full rep basis: R(t) traced down from the orbit-rep + evolution matches the excitation-cascade ED to < 1e-4.""" + n, dt, T = 6, 0.01, 1.0 + steps = round(T / dt) + J, Gam, h_terms, ops, obs = ring_model(n) + lind = Lindbladian(n, h_terms, kossakowski=(ops, Gam)) + group = TranslationGroup.chain_1d(n) + mom = np.array([0], dtype=np.int32) + + R_orbit, _ = orbit_rate_trace(lind, obs, n, dt, steps, group, mom) + _, R_exact = exact_rate_sector(n, J, Gam, T_run=T, dt_out=dt) + err = np.abs(R_orbit - R_exact).max() + assert err < 1e-4, f"orbit-rep R(t) vs exact cascade: max |dR| = {err:.2e}" + + +def test_orbit_truncated_sanity(): + """N=10 ring, genuinely truncated: no NaNs or blowup, and the error + against the (matched-capacity) real-space run improves monotonically + with the rep budget.""" + n, dt, steps = 10, 0.02, 20 + _, Gam, h_terms, ops, obs = ring_model(n) + lind = Lindbladian(n, h_terms, kossakowski=(ops, Gam)) + group = TranslationGroup.chain_1d(n) + mom = np.array([0], dtype=np.int32) + + # Real-space reference at matched effective capacity B_full = n * B_reps. + b = _basis_to_codes(list(obs), n) + c = np.array([obs[s] for s in obs]) + R_full = np.zeros(steps + 1) + for k in range(steps + 1): + iz = np.all((b == 0) | (b == 2), axis=1) + R_full[k] = c[iz].sum() + if k == steps: + break + b, c = lind.pc_step_arr( + b, c, dt, max_basis=n * 2048, drop_tol=0.0, admit_basis=3 * n * 2048 + ) + + errs = {} + for b_reps in (512, 2048): + R, peak = orbit_rate_trace( + lind, + obs, + n, + dt, + steps, + group, + mom, + max_basis=b_reps, + admit=3 * b_reps, + ) + assert np.all(np.isfinite(R)), f"non-finite R(t) at B_reps={b_reps}" + assert np.abs(R).max() < 5 * n, f"R(t) blowup at B_reps={b_reps}" + assert peak <= 3 * b_reps, "admission bound violated" + errs[b_reps] = np.abs(R - R_full).max() + + assert errs[2048] <= errs[512], ( + f"no improvement with rep budget: err(2048)={errs[2048]:.3e} > err(512)={errs[512]:.3e}" + ) + # At matched capacity the two representations should agree closely. + assert errs[2048] < 0.05 * np.abs(R_full).max(), ( + f"orbit-rep tracks real-space poorly: {errs[2048]:.3e}" + ) + + +def test_identity_bookkeeping_closed_form(): + """Uniform single-site decay (K = Γ0·1): from O = Σ_j Z_j the exact + solution is coeff_{Z_j}(t) = e^{-Γ0 t} per site and + coeff_I(t) = -n(1 - e^{-Γ0 t}) (each site pours into the identity). + Rep-space coefficients are member coefficients on every orbit, + stabilized or not, so c_Z = e^{-Γ0 t} and c_I = coeff_I — the identity + orbit has a single member and carries its full weight. This pins the + non-unital bookkeeping across orbits of different sizes.""" + n, dt, steps = 6, 0.01, 40 + ops = [sigma_minus(j, n) for j in range(n)] + lind = Lindbladian(n, [], kossakowski=(ops, np.eye(n))) + group = TranslationGroup.chain_1d(n) + mom = np.array([0], dtype=np.int32) + + # O = Σ_j Z_j → one Z rep with c = 1 (k = 0 eigenstate); + # canonicalize_first rewrites the row to the lex-min representative. + strings = ["Z" + "I" * (n - 1)] + br = _basis_to_codes(strings, n) + cr = np.array([1.0 + 0.0j]) + for _ in range(steps): + br, cr = lind.pc_step_orbit_rep( + br, + cr, + dt, + max_basis=BIG, + group=group, + momentum=mom, + drop_tol=0.0, + canonicalize_first=True, + ) + out = dict(zip(_codes_to_basis(br), cr)) + + t = steps * dt + (z_rep,) = [s for s in out if s.count("Z") == 1 and set(s) <= {"I", "Z"}] + c_z = out[z_rep] + c_i = out["I" * n] + assert abs(c_z - np.exp(-t)) < 1e-9, f"c_Z = {c_z} vs {np.exp(-t)}" + expected_i = -n * (1 - np.exp(-t)) + assert abs(c_i - expected_i) < 1e-9, f"c_I = {c_i} vs coeff_I = {expected_i}" diff --git a/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py new file mode 100644 index 000000000..6bb8a81d2 --- /dev/null +++ b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py @@ -0,0 +1,320 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Orbit-representative predictor-corrector evolution through the Python +binding (:meth:`Lindbladian.pc_step_orbit_rep`). + +The state lives entirely in orbit-rep form: the basis holds only canonical +translation-orbit representatives and the coefficients are complex. For a +translation-invariant Lindbladian, evolving in orbit-rep form and projecting +onto the momentum sector at the *end* of a full-space evolution must agree — +that is the content of the projection theorem, and it is what the first test +checks against a dense numpy matrix exponential, independent of the Rust +Al-Mohy & Higham implementation. +""" + +from __future__ import annotations + +import cmath + +import numpy as np +import pytest + +from ppvm import Lindbladian, TranslationGroup, canonicalize_basis_arr_complex + +from ._helpers import all_strings + +_CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} +_CHAR = {v: k for k, v in _CODE.items()} + + +def basis_arr(strings, n): + arr = np.zeros((len(strings), n), dtype=np.uint8) + for i, s in enumerate(strings): + arr[i] = [_CODE[c] for c in s] + return arr + + +def string(row): + return "".join(_CHAR[int(c)] for c in row) + + +def to_dict(basis, coeffs): + return {string(w): c for w, c in zip(basis, coeffs, strict=True)} + + +def momentum(*modes): + """A plain tuple: both the wrappers and `Lindbladian.pc_step_orbit_rep` + coerce momentum to the int32 the compiled code needs.""" + return modes + + +def xy_chain_pbc(n, gamma): + """Translation-invariant XY chain with PBC plus uniform Z dephasing.""" + h_terms = [] + for j in range(n): + nxt = (j + 1) % n + for op in "XY": + s = ["I"] * n + s[j] = op + s[nxt] = op + h_terms.append(("".join(s), 1.0)) + jumps = [("I" * j + "Z" + "I" * (n - j - 1), gamma) for j in range(n)] + return Lindbladian(n, h_terms, jumps) + + +def z_momentum_seed(n, k): + """``O_k = Σ_a e^{-2πi k a / n} Z_a`` as ``(basis_arr, complex coeffs)``.""" + words = ["I" * a + "Z" + "I" * (n - a - 1) for a in range(n)] + coeffs = np.array([cmath.exp(-2j * cmath.pi * k * a / n) for a in range(n)]) + return basis_arr(words, n), coeffs + + +def _dense_expm(A, terms=40): + """``exp(A)`` for a real matrix, by Taylor series with scaling and squaring. + + The full-space Lindbladian is not diagonalizable in general (numpy's + ``eig`` returns a singular eigenvector matrix here), and the test suite + deliberately has no scipy dependency — so the reference is this direct + series, independent of the Rust Al-Mohy & Higham implementation. + """ + norm = np.abs(A).sum(axis=0).max() + s = max(0, int(np.ceil(np.log2(norm))) + 1) if norm > 0 else 0 + B = A / 2**s + total = np.eye(A.shape[0]) + term = np.eye(A.shape[0]) + for k in range(1, terms + 1): + term = term @ B / k + total = total + term + for _ in range(s): + total = total @ total + return total + + +@pytest.mark.parametrize("k", [0, 1, 2]) +def test_orbit_rep_matches_dense_full_space_then_project(k): + """Orbit-rep evolution == full-space evolution projected at the end. + + Full space is all 4^n Pauli strings (n=3 -> 64), exponentiated densely + with numpy. The orbit-rep side runs with a huge ``max_basis`` so its rank + cap never binds and the only remaining difference would be a bug in the + phase-aware action. + """ + n = 3 + dt = 0.02 + n_steps = 3 + op = xy_chain_pbc(n, gamma=0.3) + group = TranslationGroup.chain_1d(n) + k_arr = momentum(k) + + # --- dense full-space reference --- + full = all_strings(n) + generator = np.zeros((len(full), len(full)), dtype=float) + rows, cols, vals = op.generator(full) + generator[rows, cols] = vals + seed_basis, seed_coeffs = z_momentum_seed(n, k) + index = {s: i for i, s in enumerate(full)} + v = np.zeros(len(full), dtype=complex) + for w, c in zip(seed_basis, seed_coeffs, strict=True): + v[index[string(w)]] = c + # `generator` is real, so exp(dt·G) is too: evolve the real and imaginary + # parts of the coefficient vector separately. + step = _dense_expm(dt * generator) + v_re, v_im = v.real.copy(), v.imag.copy() + for _ in range(n_steps): + v_re = step @ v_re + v_im = step @ v_im + v = v_re + 1j * v_im + expected = to_dict(*canonicalize_basis_arr_complex(basis_arr(full, n), v, group, k_arr)) + + # --- orbit-rep evolution --- + rep_basis, rep_coeffs = canonicalize_basis_arr_complex(seed_basis, seed_coeffs, group, k_arr) + for _ in range(n_steps): + rep_basis, rep_coeffs = op.pc_step_orbit_rep( + rep_basis, rep_coeffs, dt, 10_000_000, group, k_arr, drop_tol=0.0 + ) + got = to_dict(rep_basis, rep_coeffs) + + # Compare on the union; the dense side keeps exact zeros the orbit-rep + # side never admits, so only nonzero entries must match. + for word in set(expected) | set(got): + e = expected.get(word, 0.0) + g = got.get(word, 0.0) + assert abs(e - g) < 1e-9, f"k={k}: rep {word} dense {e} vs orbit-rep {g}" + assert any(abs(c) > 1e-6 for c in got.values()), "orbit-rep state decayed away" + + +def test_orbit_rep_handles_stabilized_orbits(): + """Same dense cross-check, seeded on a **stabilized** orbit. + + ``ZIZI + IZIZ`` has period 2 on a 4-site chain, so its orbit has 2 + distinct members rather than 4. The phase-aware action is naturally the + orbit-rep generator in the *summing* convention; converting it to the + *averaged* convention that ``canonicalize_basis_arr_complex`` returns + costs a per-orbit-pair ``|orbit_in| / |orbit_out|`` factor, which is 1 + only when both orbits are free. Without it every coefficient here comes + out exactly 2x too large. + """ + n = 4 + dt = 0.05 + n_steps = 2 + op = xy_chain_pbc(n, gamma=0.3) + group = TranslationGroup.chain_1d(n) + k_arr = momentum(0) + seed_basis, seed_coeffs = basis_arr(["ZIZI", "IZIZ"], n), np.array([1.0 + 0j, 1.0 + 0j]) + + # --- dense full-space reference --- + full = all_strings(n) + generator = np.zeros((len(full), len(full)), dtype=float) + rows, cols, vals = op.generator(full) + generator[rows, cols] = vals + index = {s: i for i, s in enumerate(full)} + v = np.zeros(len(full), dtype=complex) + for w, c in zip(seed_basis, seed_coeffs, strict=True): + v[index[string(w)]] = c + step = _dense_expm(dt * generator) + v_re, v_im = v.real.copy(), v.imag.copy() + for _ in range(n_steps): + v_re = step @ v_re + v_im = step @ v_im + expected = to_dict( + *canonicalize_basis_arr_complex(basis_arr(full, n), v_re + 1j * v_im, group, k_arr) + ) + + # --- orbit-rep evolution --- + rep_basis, rep_coeffs = canonicalize_basis_arr_complex(seed_basis, seed_coeffs, group, k_arr) + assert len(rep_coeffs) == 1, "the seed is a single orbit" + for _ in range(n_steps): + rep_basis, rep_coeffs = op.pc_step_orbit_rep( + rep_basis, rep_coeffs, dt, 10_000_000, group, k_arr, drop_tol=0.0 + ) + got = to_dict(rep_basis, rep_coeffs) + + for word in set(expected) | set(got): + e = expected.get(word, 0.0) + g = got.get(word, 0.0) + assert abs(e - g) < 1e-9, f"rep {word} dense {e} vs orbit-rep {g}" + assert any(abs(c) > 1e-6 for c in got.values()), "orbit-rep state decayed away" + + +def test_canonicalize_first_accepts_non_canonical_input(): + """The same physical state seeded on a non-canonical orbit member gives + the same evolution once ``canonicalize_first=True`` normalizes it.""" + n = 3 + dt = 0.02 + op = xy_chain_pbc(n, gamma=0.3) + group = TranslationGroup.chain_1d(n) + k_arr = momentum(0) + + seed_basis, seed_coeffs = z_momentum_seed(n, 0) + canonical, coeffs = canonicalize_basis_arr_complex(seed_basis, seed_coeffs, group, k_arr) + + ref_basis, ref_coeffs = op.pc_step_orbit_rep(canonical, coeffs, dt, 10_000_000, group, k_arr) + # Feed a shifted (non-canonical) representative of the same orbit. + shifted = np.array([[_CODE[c] for c in "IZI"]], dtype=np.uint8) + got_basis, got_coeffs = op.pc_step_orbit_rep( + shifted, coeffs, dt, 10_000_000, group, k_arr, canonicalize_first=True + ) + ref = to_dict(ref_basis, ref_coeffs) + got = to_dict(got_basis, got_coeffs) + assert ref.keys() == got.keys() + for w in ref: + assert abs(ref[w] - got[w]) < 1e-12 + + +def test_max_basis_caps_the_live_basis(): + n = 4 + op = xy_chain_pbc(n, gamma=0.1) + group = TranslationGroup.chain_1d(n) + k_arr = momentum(0) + seed_basis, seed_coeffs = z_momentum_seed(n, 0) + basis, coeffs = canonicalize_basis_arr_complex(seed_basis, seed_coeffs, group, k_arr) + for _ in range(4): + basis, coeffs = op.pc_step_orbit_rep(basis, coeffs, 0.05, 6, group, k_arr) + assert basis.shape[0] <= 6 + assert basis.shape == (len(coeffs), n) + + +def test_protected_reps_are_never_dropped(): + n = 4 + op = xy_chain_pbc(n, gamma=0.1) + group = TranslationGroup.chain_1d(n) + k_arr = momentum(0) + seed_basis, seed_coeffs = z_momentum_seed(n, 0) + basis, coeffs = canonicalize_basis_arr_complex(seed_basis, seed_coeffs, group, k_arr) + protected = basis.copy() + keep = {string(w) for w in protected} + for _ in range(3): + # max_basis=1 with a huge drop_tol would wipe everything unprotected. + basis, coeffs = op.pc_step_orbit_rep( + basis, coeffs, 0.05, 1, group, k_arr, drop_tol=1e3, protected_arr=protected + ) + assert keep <= {string(w) for w in basis} + + +@pytest.mark.parametrize("num_threads", [1, 2]) +def test_num_threads_does_not_change_the_result(num_threads): + """``num_threads`` pins the call to a fresh rayon pool — same result, and + (unlike before) it is no longer silently ignored on this path.""" + n = 4 + dt = 0.03 + op = xy_chain_pbc(n, gamma=0.2) + group = TranslationGroup.chain_1d(n) + k_arr = momentum(1) + seed_basis, seed_coeffs = z_momentum_seed(n, 1) + basis, coeffs = canonicalize_basis_arr_complex(seed_basis, seed_coeffs, group, k_arr) + + ref_b, ref_c = op.pc_step_orbit_rep(basis, coeffs, dt, 10_000_000, group, k_arr) + got_b, got_c = op.pc_step_orbit_rep( + basis, coeffs, dt, 10_000_000, group, k_arr, num_threads=num_threads + ) + ref, got = to_dict(ref_b, ref_c), to_dict(got_b, got_c) + assert ref.keys() == got.keys() + for w in ref: + assert abs(ref[w] - got[w]) < 1e-12 + + +def test_pc_step_orbit_rep_validates_inputs(): + n = 3 + op = xy_chain_pbc(n, gamma=0.0) + group = TranslationGroup.chain_1d(n) + basis, coeffs = z_momentum_seed(n, 0) + with pytest.raises(ValueError, match="momentum has 2 entries but group has 1 generators"): + op.pc_step_orbit_rep(basis, coeffs, 0.01, 100, group, momentum(0, 0)) + with pytest.raises(ValueError, match="coeffs has length 2 but basis has 3 rows"): + op.pc_step_orbit_rep(basis, coeffs[:2], 0.01, 100, group, momentum(0)) + with pytest.raises(ValueError, match="spec has 3 qubits but the TranslationGroup acts on 4"): + op.pc_step_orbit_rep(basis, coeffs, 0.01, 100, TranslationGroup.chain_1d(4), momentum(0)) + + +def test_pc_step_orbit_rep_rejects_duplicate_reps(): + """The step indexes the basis by Pauli word, so duplicate rows would + silently collapse. They are rejected — including duplicates created by + ``canonicalize_first``, which does not deduplicate.""" + n = 4 + op = xy_chain_pbc(n, gamma=0.0) + group = TranslationGroup.chain_1d(n) + coeffs = np.array([1.0 + 0j, 1.0 + 0j]) + duplicate = basis_arr(["ZIZI", "ZIZI"], n) + with pytest.raises(ValueError, match="duplicate Pauli word at row 0 and row 1"): + op.pc_step_orbit_rep(duplicate, coeffs, 0.01, 100, group, momentum(0)) + # "ZIZI" and "IZIZ" are distinct words on one orbit: legal as input, + # but canonicalize_first collapses them onto the same rep. + same_orbit = basis_arr(["ZIZI", "IZIZ"], n) + with pytest.raises(ValueError, match="duplicate Pauli word at row 0 and row 1"): + op.pc_step_orbit_rep( + same_orbit, coeffs, 0.01, 100, group, momentum(0), canonicalize_first=True + ) + + +def test_returns_complex_arrays_of_matching_shape(): + n = 3 + op = xy_chain_pbc(n, gamma=0.2) + group = TranslationGroup.chain_1d(n) + k_arr = momentum(1) + seed_basis, seed_coeffs = z_momentum_seed(n, 1) + basis, coeffs = canonicalize_basis_arr_complex(seed_basis, seed_coeffs, group, k_arr) + out_basis, out_coeffs = op.pc_step_orbit_rep(basis, coeffs, 0.01, 500, group, k_arr) + assert out_basis.dtype == np.uint8 + assert out_coeffs.dtype == np.complex128 + assert out_basis.shape == (len(out_coeffs), n) diff --git a/ppvm-python/test/lindblad/test_pc_step_rust.py b/ppvm-python/test/lindblad/test_pc_step_rust.py new file mode 100644 index 000000000..f2269e474 --- /dev/null +++ b/ppvm-python/test/lindblad/test_pc_step_rust.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Pure-Rust :meth:`Lindbladian.pc_step` (Al-Mohy & Higham expm + parallel +SpMV): agrees with the numpy-eigendecomp PC reference at FP precision and +shows the same cubic dt-scaling against the bilinear reference. Plus a +sanity check that a length-1 real lincomb routes to the Hermitian fast path. +""" + +from __future__ import annotations + +from itertools import pairwise + +import numpy as np + +from ppvm import Lindbladian + +from ._helpers import ( + adaptive_z_correlator_pc, + bilinear_nn_xy_z_dephasing_obc, + nn_xy_z_dephasing_obc, + random_pauli_str, + xy_dephasing, +) + + +def _adaptive_z_correlator_pc_rust(L_op, L, site0, dt, n_steps, max_basis): + """Same as :func:`_helpers.adaptive_z_correlator_pc` but the per-step PC + work (leakage expansion, predictor expm, second-hop expansion, corrector + expm) all runs in Rust through :meth:`Lindbladian.pc_step`.""" + z_strings = ["I" * j + "Z" + "I" * (L - j - 1) for j in range(L)] + basis = [z_strings[site0]] + coeffs = np.array([1.0]) + protected = [z_strings[site0]] + + corr = np.zeros((n_steps + 1, L)) + corr[0, site0] = 1.0 + + for step in range(n_steps): + basis, coeffs = L_op.pc_step( + basis, coeffs, dt, max_basis, drop_tol=0.0, protected=protected + ) + index = {s: i for i, s in enumerate(basis)} + for j in range(L): + if z_strings[j] in index: + corr[step + 1, j] = coeffs[index[z_strings[j]]] + return corr + + +def test_pc_step_rust_matches_python_pc(): + """The pure-Rust PC step agrees with the numpy-eigendecomp PC reference + at FP precision. + + Pins the Rust matrix exponential (Al-Mohy & Higham) against an + independent reference (numpy ``eig``) under the exact same + basis-expansion schedule.""" + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + dt = 0.01 + n_steps = 5 + tau_add = 1e-12 + # Large max_basis so the rust rank cap never binds: full enrichment, + # matching the python reference's effectively-all-leakage tau_add. + max_basis = 10_000_000 + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + rust = _adaptive_z_correlator_pc_rust(L_op, L, site0, dt, n_steps, max_basis) + py_ref = adaptive_z_correlator_pc(L_op, L, site0, dt, n_steps, tau_add) + + diff = float(np.max(np.abs(rust - py_ref))) + assert diff < 1e-10, f"Rust PC differs from numpy-eigendecomp PC by {diff:.3e}" + + +def test_pc_step_rust_dt_scaling_is_cubic(): + """End-to-end: the Rust-only PC step matches the bilinear reference with + cubic dt-scaling, confirming the Rust matrix exponential is not the + accuracy bottleneck.""" + L = 4 + J = 1.0 + gamma = 1.0 + site0 = L // 2 + T = 0.05 + max_basis = 10_000_000 # large: rank cap never binds (full enrichment) + + h_terms, jump_terms = nn_xy_z_dephasing_obc(L, J, gamma) + L_op = Lindbladian(L, h_terms, jump_terms) + + err = [] + for dt in (0.01, 0.005, 0.0025): + n_steps = round(T / dt) + times = np.arange(n_steps + 1) * dt + exact = bilinear_nn_xy_z_dephasing_obc(L, J, gamma, times, site0) + rust = _adaptive_z_correlator_pc_rust(L_op, L, site0, dt, n_steps, max_basis) + err.append(float(np.max(np.abs(rust[-1] - exact[-1])))) + + # Halving dt should drop the error by ≥5× (cubic gives 8×). + for prev, curr in pairwise(err): + assert curr < prev / 5, f"Rust PC dt-halving ratio < 5: errors {err}" + assert err[-1] < 1e-7, f"Rust PC tight-dt error too large: {err[-1]:.3e}" + + +def test_lincomb_single_term_matches_hermitian_fast_path(): + """A length-1 real lincomb should route to the Hermitian fast path and + produce numerically identical results to passing the string directly.""" + L = 4 + h_terms, jump_simple = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_simple = Lindbladian(L, h_terms, jump_simple) + # Same operator, expressed as a length-1 complex lincomb. + jump_lincomb = [([(s, 1.0 + 0.0j)], g) for s, g in jump_simple] + L_lincomb = Lindbladian(L, h_terms, jump_lincomb) + + rng = np.random.default_rng(99) + for _ in range(5): + p = random_pauli_str(rng, L) + got_a = L_simple.action(p) + got_b = L_lincomb.action(p) + for k in set(got_a) | set(got_b): + assert got_a.get(k, 0.0) == got_b.get(k, 0.0), ( + f"lincomb fast path mismatch at p={p!r}: {got_a} vs {got_b}" + ) diff --git a/ppvm-python/test/lindblad/test_word_width.py b/ppvm-python/test/lindblad/test_word_width.py new file mode 100644 index 000000000..4520bf4e3 --- /dev/null +++ b/ppvm-python/test/lindblad/test_word_width.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 +"""Registers wider than 128 qubits (variable Pauli-word width).""" + +import numpy as np +import pytest + +from ppvm import Lindbladian +from ppvm._core import TranslationGroup, canonicalize_basis_arr_complex +from ppvm.lindblad import _basis_to_codes + + +def _word(n, ops): + s = ["I"] * n + for q, p in ops: + s[q] = p + return "".join(s) + + +def _ring(n): + terms = [] + for i in range(n): + j = (i + 1) % n + terms += [ + (_word(n, [(i, "X"), (j, "X")]), 1.0), + (_word(n, [(i, "Y"), (j, "Y")]), 1.0), + (_word(n, [(i, "Z"), (j, "Z")]), 0.5), + (_word(n, [(i, "Z")]), 0.3), + ] + return terms + + +def _orbit_run(n, steps=2): + lind = Lindbladian(n, _ring(n), []) + group = TranslationGroup.chain_1d(n) + mom = np.array([0], dtype=np.int32) + seed = _basis_to_codes([_word(n, [(q, "X")]) for q in range(n)], n) + basis, co = canonicalize_basis_arr_complex(seed, np.ones(n, dtype=np.complex128), group, mom) + for _ in range(steps): + basis, co = lind.pc_step_orbit_rep( + basis, co, 0.1, 10**6, group=group, momentum=mom, drop_tol=0.0 + ) + return basis, co + + +@pytest.mark.parametrize("n", [130, 256, 300, 512]) +def test_wide_lindbladian_constructs_and_steps(n): + # 130 qubits used to fail with "LindbladSpec supports n_qubits ≤ 128". + lind = Lindbladian( + n, + [(_word(n, [(0, "Z"), (n - 1, "Z")]), 0.7), (_word(n, [(n - 1, "X")]), 1.3)], + [(_word(n, [(n - 1, "Z")]), 0.05)], + ) + assert lind.n_qubits == n + basis, _ = lind.pc_step([_word(n, [(n - 1, "Z")])], np.array([1.0]), 0.05, 1000) + assert len(basis) > 1 + assert all(len(b) == n for b in basis) + # The top qubit is live: something acts on it. + assert any(b[n - 1] == "Y" for b in basis) + + +def test_more_than_512_qubits_is_rejected(): + with pytest.raises(ValueError, match="512"): + Lindbladian(513, [(_word(513, [(0, "Z")]), 1.0)], []) + + +def test_orbit_rep_step_beyond_128_qubits(): + basis, co = _orbit_run(130) + assert basis.shape[1] == 130 + assert len(basis) > 5 + assert np.all(np.isfinite(co)) + + +def test_orbit_rep_step_matches_across_the_width_boundary(): + # Rings of 120 and 136 sites use 128- and 256-qubit words. Compare the + # k=0 autocorrelation of M_x, which is ring-size independent here. + def autocorr(n): + basis, co = _orbit_run(n, steps=3) + # The single-X rep, whatever position the canonical form puts it at. + m = np.where(((basis == 1).sum(axis=1) == 1) & ((basis != 0).sum(axis=1) == 1))[0] + assert m.size == 1 + return co[m[0]].real + + # With nearest-neighbour H and 3 short steps the operator front is far + # smaller than either ring, so the value is ring-size independent. + assert autocorr(120) == pytest.approx(autocorr(136), rel=1e-12, abs=1e-14) diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py new file mode 100644 index 000000000..5f8c02a70 --- /dev/null +++ b/ppvm-python/test/test_momentum_merge.py @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for momentum-sector (k != 0) symmetry merging of real PauliSum pairs. + +A complex operator O = O_re + i·O_im is carried as a pair of real PauliSums. +``PauliSum.momentum_merge`` folds the pair onto translation-orbit +representatives in momentum sector k, generalizing ``symmetry_merge`` (k=0). + +These checks compare against *exact* references — the projector definition, +idempotency, and exact diagonalization of the dynamics — NOT against any +other propagation scheme. +""" + +import cmath +import math + +import numpy as np +import pytest + +from ppvm import PauliSum, TranslationGroup + +# ── dense Pauli helpers (exact references) ─────────────────────────────────── +_I = np.eye(2, dtype=complex) +_X = np.array([[0, 1], [1, 0]], dtype=complex) +_Y = np.array([[0, -1j], [1j, 0]], dtype=complex) +_Z = np.array([[1, 0], [0, -1]], dtype=complex) +_P = {"I": _I, "X": _X, "Y": _Y, "Z": _Z} + + +def dense(pauli_str): + m = np.array([[1]], dtype=complex) + for ch in pauli_str: + m = np.kron(m, _P[ch]) + return m + + +def zstr(n, q): + return "".join("Z" if i == q else "I" for i in range(n)) + + +def chain_bonds(n): + return [(i, (i + 1) % n, 1.0) for i in range(n)] + + +# ── helpers shared with the k-resolved Trotter driver ──────────────────────── +def _seed_pair(n, k): + a = np.arange(n) + re = np.cos(2 * np.pi * k * a / n) + im = -np.sin(2 * np.pi * k * a / n) # e^{-2πi k a/n} = cos - i sin + Z = [zstr(n, q) for q in range(n)] + PA = PauliSum.new( + n, [(Z[q], float(re[q])) for q in range(n)], min_abs_coeff=0.0, max_pauli_weight=n + ) + PB = PauliSum.new( + n, [(Z[q], float(im[q])) for q in range(n)], min_abs_coeff=0.0, max_pauli_weight=n + ) + return PA, PB + + +def _to_complex_dict(PA, PB): + d = {} + for s, c in PA.terms: + d[s] = d.get(s, 0j) + c + for s, c in PB.terms: + d[s] = d.get(s, 0j) + 1j * c + return {s: v for s, v in d.items() if v != 0j} + + +def _ovl(sA, sB, oA, oB): + re = sA.overlap(oA) + sB.overlap(oB) + im = sA.overlap(oB) - sB.overlap(oA) + return complex(re, im) + + +# ============================================================================= +# 1. The merge is an exact sector projector: idempotent, and it leaves a +# genuine momentum-k eigenoperator unchanged. +# ============================================================================= +@pytest.mark.parametrize("k", [0, 1, 2, 3]) +def test_momentum_merge_idempotent(k): + n = 4 + g = TranslationGroup.chain_1d(n) + PA, PB = _seed_pair(n, k) # S^z_k is exactly in sector k + PA.momentum_merge(PB, g, [k]) + once = _to_complex_dict(PA, PB) + PA.momentum_merge(PB, g, [k]) # merging again must be a no-op + twice = _to_complex_dict(PA, PB) + keys = set(once) | set(twice) + assert max(abs(once.get(x, 0j) - twice.get(x, 0j)) for x in keys) < 1e-12 + + +@pytest.mark.parametrize("word", ["ZZZZ", "ZIZI"]) +def test_momentum_merge_idempotent_on_stabilized_orbit(word): + """Idempotency must hold for every orbit, not just the free ones. + + ``ZZZZ`` is translation-invariant (orbit size 1) and ``ZIZI`` has period + 2, so on a 4-site chain their orbits are smaller than the group. A merge + that rescaled the orbit-*averaged* projection by a global ``|G|`` would + amplify them by ``|G|/|orbit|`` — 4x and 2x — on every merge; the summing + projector leaves them fixed. ``_seed_pair`` only produces free orbits, + where the two conventions coincide, so this case needs its own test. + """ + n = 4 + g = TranslationGroup.chain_1d(n) + PA = PauliSum.new(n, [(word, 1.0)], min_abs_coeff=0.0, max_pauli_weight=n) + PB = PauliSum.new(n, [(word, 0.0)], min_abs_coeff=0.0, max_pauli_weight=n) + PA.momentum_merge(PB, g, [0]) + once = _to_complex_dict(PA, PB) + # The coefficient is conserved outright, not just stable under re-merging. + assert sorted(abs(v) for v in once.values() if abs(v) > 1e-12) == pytest.approx([1.0]) + PA.momentum_merge(PB, g, [0]) + twice = _to_complex_dict(PA, PB) + keys = set(once) | set(twice) + assert max(abs(once.get(x, 0j) - twice.get(x, 0j)) for x in keys) < 1e-12 + + +def test_momentum_merge_rejects_the_same_object_twice(): + """``self`` and ``other`` hold the real and imaginary parts, so they must + be distinct; passing one object twice gets a message saying so rather + than a raw borrow error.""" + n = 4 + g = TranslationGroup.chain_1d(n) + PA, _ = _seed_pair(n, 1) + with pytest.raises(ValueError, match="must be distinct PauliSum objects"): + PA.momentum_merge(PA, g, [1]) + + +def test_momentum_merge_projects_out_other_sectors(): + """Merging a pure sector-k operator in sector k' != k gives ~zero.""" + n = 4 + g = TranslationGroup.chain_1d(n) + PA, PB = _seed_pair(n, 1) # operator lives in k=1 + PA.momentum_merge(PB, g, [2]) # project onto k=2 + d = _to_complex_dict(PA, PB) + assert all(abs(v) < 1e-12 for v in d.values()), d + + +# ============================================================================= +# 2. End-to-end: k-resolved, symmetry-compressed Trotter reproduces the +# EXACT (dense-diagonalization) operator autocorrelator as dt -> 0. +# ============================================================================= +def _ed_autocorr(n, bonds, k, ts): + """C_k(t) = Tr[O0^dagger O(t)] / Tr[O0^dagger O0], O0 = S^z_k, exact.""" + H = np.zeros((2**n, 2**n), dtype=complex) + for i, j, J in bonds: + for q in "XY": + s = ["I"] * n + s[i] = q + s[j] = q + H += J * dense("".join(s)) + O0 = np.zeros((2**n, 2**n), dtype=complex) + for a in range(n): + O0 += cmath.exp(-2j * math.pi * k * a / n) * dense(zstr(n, a)) + E, V = np.linalg.eigh(H) + out = [] + with np.errstate(all="ignore"): # silence spurious macOS-Accelerate matmul warnings + norm = np.trace(O0.conj().T @ O0).real + for t in ts: + U = (V * np.exp(-1j * E * t)) @ V.conj().T + Ot = U.conj().T @ O0 @ U + out.append(np.trace(O0.conj().T @ Ot) / norm) + return np.array(out) + + +def _ctrotter_autocorr(n, bonds, k, dt, steps): + g = TranslationGroup.chain_1d(n) + PA, PB = _seed_pair(n, k) + PA.momentum_merge(PB, g, [k]) + refA, refB = PA.copy(), PB.copy() + C0 = _ovl(refA, refB, PA, PB) + out = [1.0 + 0j] + for _ in range(steps): + for i, j, J in bonds: # Strang: forward then reversed + PA.rxx(i, j, J * dt, truncate=False) + PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False) + PB.ryy(i, j, J * dt, truncate=False) + for i, j, J in reversed(bonds): + PA.rxx(i, j, J * dt, truncate=False) + PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False) + PB.ryy(i, j, J * dt, truncate=False) + PA.momentum_merge(PB, g, [k]) + out.append(_ovl(refA, refB, PA, PB) / C0) + return np.array(out) + + +@pytest.mark.parametrize("k", [0, 1, 2, 3]) +def test_k_resolved_trotter_converges_to_exact(k): + n, T = 4, 0.3 + bonds = chain_bonds(n) + # exact reference at the matching times for two step sizes + err = {} + for dt in (0.04, 0.02): + steps = round(T / dt) + ts = np.arange(steps + 1) * dt + c = _ctrotter_autocorr(n, bonds, k, dt, steps) + ed = _ed_autocorr(n, bonds, k, ts) + err[dt] = np.max(np.abs(c - ed)) + + assert abs(_ctrotter_autocorr(n, bonds, k, 0.02, 1)[0] - 1.0) < 1e-12 # C_k(0)=1 + if k == 0: + # total Z is conserved -> exact in every sector-0 step + assert err[0.02] < 1e-10 + else: + assert err[0.02] < 5e-3 # close to exact at dt=0.02 + assert err[0.02] < err[0.04] # converges toward exact as dt->0 + + +def test_compressed_matches_uncompressed_evolution(): + """Merging must not change observables beyond the O(dt^2) equivariance + error: compressed (merge each step) vs the same gates with no merge.""" + n, k, dt, steps = 4, 2, 0.02, 10 + bonds = chain_bonds(n) + g = TranslationGroup.chain_1d(n) + + # uncompressed: evolve the full real pair, project only at readout + PA, PB = _seed_pair(n, k) + rA, rB = _seed_pair(n, k) + rA.momentum_merge(rB, g, [k]) + C0 = _ovl(rA, rB, *_merged_copy(PA, PB, g, k)) + comp = _ctrotter_autocorr(n, bonds, k, dt, steps) + unc = [] + for _ in range(steps): + for i, j, J in bonds: + PA.rxx(i, j, J * dt, truncate=False) + PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False) + PB.ryy(i, j, J * dt, truncate=False) + for i, j, J in reversed(bonds): + PA.rxx(i, j, J * dt, truncate=False) + PA.ryy(i, j, J * dt, truncate=False) + PB.rxx(i, j, J * dt, truncate=False) + PB.ryy(i, j, J * dt, truncate=False) + mA, mB = _merged_copy(PA, PB, g, k) + unc.append(_ovl(rA, rB, mA, mB) / C0) + unc = np.array([1.0 + 0j, *unc]) + assert np.max(np.abs(comp - unc)) < 5e-3 # only O(dt^2) equivariance + + +def _merged_copy(PA, PB, g, k): + a, b = PA.copy(), PB.copy() + a.momentum_merge(b, g, [k]) + return a, b diff --git a/ppvm-python/test/test_symmetry_arrays.py b/ppvm-python/test/test_symmetry_arrays.py new file mode 100644 index 000000000..7630e6cc0 --- /dev/null +++ b/ppvm-python/test/test_symmetry_arrays.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the array-form symmetry primitives on the ``(basis_arr, coeffs)`` +representation used by ``Lindbladian.pc_step_arr``, as exported from the +``ppvm`` package (thin dtype-coercing wrappers over ``ppvm._core``): + +- ``canonicalize_basis_arr`` — plain real merge (sums colliding coefficients) +- ``canonicalize_basis_arr_complex`` — momentum-sector projection (averages + over the distinct orbit members with the character weight) +- ``check_momentum_sector_arr`` — validation that an input really lies in the + sector it is about to be projected onto + +References are computed here in numpy from the group action, independent of +the Rust merge routines. +""" + +import cmath + +import numpy as np +import pytest + +from ppvm import ( + TranslationGroup, + canonicalize_basis_arr, + canonicalize_basis_arr_complex, + check_momentum_sector_arr, +) + +_CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} +_CHAR = {v: k for k, v in _CODE.items()} + + +def basis_arr(strings): + return np.array([[_CODE[c] for c in s] for s in strings], dtype=np.uint8) + + +def string(row): + return "".join(_CHAR[int(c)] for c in row) + + +def to_dict(pair): + words, coeffs = pair + return {string(w): c for w, c in zip(words, coeffs, strict=True)} + + +def rep_of(group, s): + return string(group.canonicalize(np.array([_CODE[c] for c in s], dtype=np.uint8))) + + +def momentum(*modes): + """The wrappers coerce momentum for us; most tests pass a plain tuple. + + See `test_wrappers_coerce_argument_dtypes` for the coercion itself. + """ + return modes + + +def z_strings(n): + return ["I" * j + "Z" + "I" * (n - j - 1) for j in range(n)] + + +# ── argument coercion (the reason the wrappers exist) ──────────────────────── +def test_wrappers_coerce_argument_dtypes(): + """The compiled entry points demand exact dtypes — uint8 basis, float64 / + complex128 coefficients, int32 momentum. numpy's default integer dtype is + int64, so an unwrapped ``np.array([0])`` momentum is rejected; the wrappers + accept plain Python sequences and default-dtype arrays. + """ + n = 4 + g = TranslationGroup.chain_1d(n) + words = z_strings(n) + py_basis = [[_CODE[c] for c in s] for s in words] # list[list[int]] + + real = to_dict(canonicalize_basis_arr(py_basis, [1.0] * n, g)) + assert real == pytest.approx({rep_of(g, words[0]): float(n)}) + + # int64 momentum (numpy default) and a plain list of complex. + cx = to_dict(canonicalize_basis_arr_complex(py_basis, [1 + 0j] * n, g, np.array([0]))) + assert len(cx) == 1 + assert check_momentum_sector_arr(py_basis, [1 + 0j] * n, g, [0]) is None + + +# ── canonicalize_basis_arr (real, k=0) ─────────────────────────────────────── +def test_canonicalize_basis_arr_sums_collisions(): + n = 4 + g = TranslationGroup.chain_1d(n) + words = z_strings(n) + coeffs = np.array([1.0, 2.0, 3.0, 4.0]) + merged = to_dict(canonicalize_basis_arr(basis_arr(words), coeffs, g)) + assert merged == pytest.approx({rep_of(g, words[0]): 10.0}) + + +def test_canonicalize_basis_arr_matches_manual_grouping(): + """Reference: group rows by their rep in numpy and sum.""" + n = 4 + g = TranslationGroup.chain_1d(n) + rng = np.random.default_rng(3) + words = [*z_strings(n), "XXII", "IXXI", "IIXX", "XIIX", "ZZZZ"] + coeffs = rng.normal(size=len(words)) + + expected: dict[str, float] = {} + for w, c in zip(words, coeffs, strict=True): + expected[rep_of(g, w)] = expected.get(rep_of(g, w), 0.0) + c + + merged = to_dict(canonicalize_basis_arr(basis_arr(words), coeffs, g)) + assert merged.keys() == expected.keys() + for w in expected: + assert merged[w] == pytest.approx(expected[w]) + + +def test_canonicalize_basis_arr_validates_shapes(): + g = TranslationGroup.chain_1d(4) + with pytest.raises(ValueError, match="3 qubits per row but group acts on 4"): + canonicalize_basis_arr(basis_arr(["ZII"]), np.array([1.0]), g) + with pytest.raises(ValueError, match="coeffs has length 2 but basis has 1 rows"): + canonicalize_basis_arr(basis_arr(["ZIII"]), np.array([1.0, 2.0]), g) + + +# ── canonicalize_basis_arr_complex (momentum sectors) ──────────────────────── +def _momentum_seed(n, k): + """``O_k = Σ_a e^{-2πi k a / n} Z_a`` as ``(basis_arr, coeffs)``.""" + words = z_strings(n) + coeffs = np.array([cmath.exp(-2j * cmath.pi * k * a / n) for a in range(n)]) + return basis_arr(words), coeffs + + +@pytest.mark.parametrize("k", [0, 1, 2, 3]) +def test_complex_merge_of_momentum_eigenstate_has_unit_rep_coefficient(k): + """The projection *averages* over the orbit, so a normalized momentum + eigenstate folds to a rep coefficient of modulus 1.""" + n = 4 + g = TranslationGroup.chain_1d(n) + words, coeffs = _momentum_seed(n, k) + merged = to_dict(canonicalize_basis_arr_complex(words, coeffs, g, momentum(k))) + assert len(merged) == 1 + assert abs(next(iter(merged.values()))) == pytest.approx(1.0) + + +def test_complex_merge_projects_out_other_sectors(): + """A pure k=1 state has zero component in every other sector.""" + n = 4 + g = TranslationGroup.chain_1d(n) + words, coeffs = _momentum_seed(n, 1) + for k_other in [0, 2, 3]: + merged = to_dict(canonicalize_basis_arr_complex(words, coeffs, g, momentum(k_other))) + for c in merged.values(): + assert abs(c) < 1e-12, f"k=1 state leaked into sector {k_other}: {c}" + + +def test_complex_merge_matches_character_average(): + """Reference: (1/|orbit|) Σ_{p in orbit} χ_k(g_p) · c_p, computed here + by walking the cyclic shifts explicitly.""" + n = 4 + k = 1 + g = TranslationGroup.chain_1d(n) + rng = np.random.default_rng(11) + words = z_strings(n) + coeffs = rng.normal(size=n) + 1j * rng.normal(size=n) + + # Z_a is the shift of Z_0 by `a`, so the character weight is e^{2πika/n}. + by_word = dict(zip(words, coeffs, strict=True)) + rep = rep_of(g, words[0]) + shift_of_rep = words.index(rep) + expected = ( + sum( + cmath.exp(2j * cmath.pi * k * ((a - shift_of_rep) % n) / n) * by_word[words[a]] + for a in range(n) + ) + / n + ) + + merged = to_dict(canonicalize_basis_arr_complex(basis_arr(words), coeffs, g, momentum(k))) + assert merged[rep] == pytest.approx(expected) + + +def test_complex_merge_validates_shapes(): + g = TranslationGroup.chain_1d(4) + words, coeffs = _momentum_seed(4, 1) + with pytest.raises(ValueError, match="momentum has 2 entries but group has 1 generators"): + canonicalize_basis_arr_complex(words, coeffs, g, momentum(0, 0)) + with pytest.raises(ValueError, match="coeffs has length 2 but basis has 4 rows"): + canonicalize_basis_arr_complex(words, coeffs[:2], g, momentum(1)) + with pytest.raises(ValueError, match="3 qubits per row but group acts on 4"): + canonicalize_basis_arr_complex(basis_arr(["ZII"]), np.array([1 + 0j]), g, momentum(0)) + + +# ── check_momentum_sector_arr ──────────────────────────────────────────────── +@pytest.mark.parametrize("k", [0, 1, 2, 3]) +def test_check_momentum_sector_accepts_eigenstate(k): + n = 4 + g = TranslationGroup.chain_1d(n) + words, coeffs = _momentum_seed(n, k) + assert check_momentum_sector_arr(words, coeffs, g, momentum(k)) is None + + +def test_check_momentum_sector_rejects_wrong_sector(): + n = 4 + g = TranslationGroup.chain_1d(n) + words, coeffs = _momentum_seed(n, 1) + with pytest.raises(ValueError, match="not in target momentum sector"): + check_momentum_sector_arr(words, coeffs, g, momentum(0)) + + +def test_check_momentum_sector_rejects_incomplete_orbit(): + """Orbit members missing from the basis count as zero, so a lone Z_0 is + not a momentum eigenstate.""" + g = TranslationGroup.chain_1d(4) + with pytest.raises(ValueError, match="not in target momentum sector"): + check_momentum_sector_arr(basis_arr(["ZIII"]), np.array([1 + 0j]), g, momentum(0)) + + +def test_check_momentum_sector_flags_incompatible_stabilizer(): + """``ZIZI`` has a period-2 stabilizer, which cannot carry k=1.""" + g = TranslationGroup.chain_1d(4) + with pytest.raises(ValueError, match="stabilizer incompatible with momentum sector"): + check_momentum_sector_arr( + basis_arr(["ZIZI", "IZIZ"]), + np.array([1 + 0j, -1 + 0j]), + g, + momentum(1), + ) + + +def test_check_momentum_sector_tolerance_is_configurable(): + n = 4 + g = TranslationGroup.chain_1d(n) + words, coeffs = _momentum_seed(n, 1) + perturbed = coeffs.copy() + perturbed[0] += 1e-6 + with pytest.raises(ValueError, match="not in target momentum sector"): + check_momentum_sector_arr(words, perturbed, g, momentum(1), 1e-9) + # Same input passes once the tolerance exceeds the perturbation. + assert check_momentum_sector_arr(words, perturbed, g, momentum(1), 1e-4) is None + + +def test_check_momentum_sector_rejects_invalid_tolerance(): + n = 4 + g = TranslationGroup.chain_1d(n) + words, coeffs = _momentum_seed(n, 0) + with pytest.raises(ValueError, match="invalid tolerance"): + check_momentum_sector_arr(words, coeffs, g, momentum(0), -1.0) diff --git a/ppvm-python/test/test_symmetry_merge.py b/ppvm-python/test/test_symmetry_merge.py new file mode 100644 index 000000000..f91536534 --- /dev/null +++ b/ppvm-python/test/test_symmetry_merge.py @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the ``TranslationGroup`` binding and ``PauliSum.symmetry_merge``. + +``symmetry_merge`` is the plain real-coefficient (``k=0``) merge: every Pauli +word is replaced by its canonical translation-orbit representative and +coefficients of colliding words are summed. See ``test_momentum_merge.py`` +for the phase-aware (``k != 0``) counterpart. +""" + +import numpy as np +import pytest + +from ppvm import LossyPauliSum, PauliSum, TranslationGroup + +_CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} +_CHAR = {v: k for k, v in _CODE.items()} + + +def codes(s): + return np.array([_CODE[c] for c in s], dtype=np.uint8) + + +def string(arr): + return "".join(_CHAR[int(c)] for c in arr) + + +def psum(n, terms): + return PauliSum.new(n, terms, min_abs_coeff=0.0, max_pauli_weight=n) + + +# ── TranslationGroup constructors and properties ───────────────────────────── +@pytest.mark.parametrize( + "group, n_qubits, n_generators, order", + [ + (TranslationGroup.chain_1d(6), 6, 1, 6), + (TranslationGroup.torus_2d(3, 2), 6, 2, 6), + (TranslationGroup.torus_3d(2, 2, 2), 8, 3, 8), + (TranslationGroup.ladder(3, 2), 6, 1, 3), + ], +) +def test_group_shapes(group, n_qubits, n_generators, order): + assert group.n_qubits == n_qubits + assert group.n_generators == n_generators + assert group.order == order + + +def test_from_generators_matches_chain_1d(): + n = 4 + shift = [(i + 1) % n for i in range(n)] + g = TranslationGroup.from_generators(n, [shift], [n]) + ref = TranslationGroup.chain_1d(n) + assert (g.n_qubits, g.n_generators, g.order) == (ref.n_qubits, ref.n_generators, ref.order) + for s in ["ZIII", "IZII", "XYII", "IXYI"]: + assert string(g.canonicalize(codes(s))) == string(ref.canonicalize(codes(s))) + + +@pytest.mark.parametrize( + "perms, orders, message", + [ + ([[1, 0, 2, 3]], [4, 4], "same length"), + ([[1, 0, 2]], [2], "permutation length"), + ([[1, 0, 2, 9]], [2], "out of range"), + ([[1, 1, 2, 3]], [2], "duplicate target"), + # The declared order must be the permutation's *exact* cyclic + # order, not a multiple of it and not zero. + ([[1, 2, 3, 0]], [2], "declared order 2 != exact permutation order 4"), + ([[1, 2, 3, 0]], [8], "declared order 8 != exact permutation order 4"), + ([[1, 2, 3, 0]], [0], "order must be nonzero"), + # Generators must commute: (0 1) and (1 2) do not. + ([[1, 0, 2, 3], [0, 2, 1, 3]], [2, 2], "generators 0 and 1 do not commute"), + ], +) +def test_from_generators_validates(perms, orders, message): + """Every precondition is reported as ``ValueError``, never as a panic.""" + with pytest.raises(ValueError, match=message): + TranslationGroup.from_generators(4, perms, orders) + + +@pytest.mark.parametrize( + "ctor, args, message", + [ + (TranslationGroup.chain_1d, (0,), "n must be positive"), + (TranslationGroup.torus_2d, (0, 2), "lx must be positive"), + (TranslationGroup.torus_2d, (2, 0), "ly must be positive"), + (TranslationGroup.torus_3d, (2, 0, 2), "ly must be positive"), + (TranslationGroup.torus_3d, (2, 2, 0), "lz must be positive"), + (TranslationGroup.ladder, (0, 2), "l must be positive"), + (TranslationGroup.ladder, (2, 0), "n_legs must be positive"), + ], +) +def test_lattice_constructors_reject_empty_extents(ctor, args, message): + """Degenerate lattice extents raise ``ValueError`` rather than tripping + the core's ``assert!`` (which would surface as a ``PanicException``).""" + with pytest.raises(ValueError, match=message): + ctor(*args) + + +def test_canonicalize_is_orbit_invariant(): + g = TranslationGroup.chain_1d(4) + shifts = ["IIXY", "IXYI", "XYII", "YIIX"] + reps = {string(g.canonicalize(codes(s))) for s in shifts} + assert len(reps) == 1, "all cyclic shifts must share one representative" + # The rep is itself a member of the orbit (lex-min is over the internal + # (xbits, zbits) ordering, which isn't observable from Python). + assert reps.pop() in shifts + + +def test_canonicalize_rejects_wrong_length(): + g = TranslationGroup.chain_1d(4) + with pytest.raises(ValueError, match="length 3 but group expects 4"): + g.canonicalize(codes("IXY")) + + +# ── PauliSum.symmetry_merge ────────────────────────────────────────────────── +def test_symmetry_merge_sums_one_orbit(): + """Σ_j Z_j on a 4-chain is a single free orbit: 4 entries -> 1 with c=4.""" + n = 4 + g = TranslationGroup.chain_1d(n) + p = psum(n, [("I" * j + "Z" + "I" * (n - j - 1), 1.0) for j in range(n)]) + assert len(p.terms) == n + p.symmetry_merge(g) + assert len(p.terms) == 1 + (word, coeff) = p.terms[0] + assert coeff == pytest.approx(4.0) + assert string(g.canonicalize(codes(word))) == word + + +def test_symmetry_merge_keeps_distinct_orbits_and_weights(): + n = 4 + g = TranslationGroup.chain_1d(n) + terms = [("I" * j + "Z" + "I" * (n - j - 1), 1.0) for j in range(n)] + terms += [("I" * j + "X" + "I" * (n - j - 1), 0.25) for j in range(n)] + p = psum(n, terms) + p.symmetry_merge(g) + coeffs = sorted(c for _, c in p.terms) + assert coeffs == pytest.approx([1.0, 4.0]) + + +def test_symmetry_merge_is_idempotent(): + """A merged sum is already in orbit-rep form, so re-merging is a no-op.""" + n = 4 + g = TranslationGroup.chain_1d(n) + p = psum(n, [("I" * j + "Z" + "I" * (n - j - 1), 1.0) for j in range(n)]) + p.symmetry_merge(g) + once = sorted(p.terms) + p.symmetry_merge(g) + assert sorted(p.terms) == once + + +def test_symmetry_merge_preserves_translation_invariant_trace(): + """Merging conserves Σ_p c_p, hence any orbit-summed observable.""" + n = 4 + g = TranslationGroup.chain_1d(n) + rng = np.random.default_rng(7) + words = ["ZIII", "IZII", "IIZI", "IIIZ", "XXII", "IXXI", "IIXX", "XIIX"] + coeffs = rng.normal(size=len(words)) + p = psum(n, list(zip(words, coeffs, strict=True))) + total = sum(c for _, c in p.terms) + p.symmetry_merge(g) + assert sum(c for _, c in p.terms) == pytest.approx(total) + + +def test_symmetry_merge_rejects_qubit_count_mismatch(): + p = psum(4, [("ZIII", 1.0)]) + with pytest.raises(ValueError, match="4 qubits but the TranslationGroup acts on 3"): + p.symmetry_merge(TranslationGroup.chain_1d(3)) + + +def test_lossy_pauli_sum_rejects_symmetry_merging(): + """`LossyPauliSum` inherits the merge wrappers but the Rust core expands + them only for non-loss variants, so both must fail with a clear + `NotImplementedError` rather than an `AttributeError` from inside the + wrapper.""" + lossy = LossyPauliSum(["ZIZI"], 4, [1.0]) + group = TranslationGroup.chain_1d(4) + with pytest.raises(NotImplementedError, match="not implemented for LossyPauliSum"): + lossy.symmetry_merge(group) + with pytest.raises(NotImplementedError, match="not implemented for LossyPauliSum"): + lossy.momentum_merge(LossyPauliSum(["ZIZI"], 4, [1.0]), group, [0]) diff --git a/ppvm-python/uv.lock b/ppvm-python/uv.lock index 88e4fa704..f8fae91cc 100644 --- a/ppvm-python/uv.lock +++ b/ppvm-python/uv.lock @@ -906,9 +906,14 @@ source = { editable = "." } dependencies = [ { name = "bloqade-circuit" }, { name = "kirin-toolchain" }, + { name = "numpy" }, ] [package.dev-dependencies] +demo = [ + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] dev = [ { name = "numpy" }, { name = "pytest" }, @@ -919,9 +924,11 @@ dev = [ requires-dist = [ { name = "bloqade-circuit", specifier = ">=0.14.1" }, { name = "kirin-toolchain", specifier = "~=0.22.2" }, + { name = "numpy", specifier = ">=1.26" }, ] [package.metadata.requires-dev] +demo = [{ name = "scipy", specifier = ">=1.13" }] dev = [ { name = "numpy", specifier = ">=2.2.6" }, { name = "pytest", specifier = ">=9.0.2" }, From 1cf9db228f77bacb7c6f3e3a1c095abb737072d8 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Tue, 29 Sep 2026 09:43:31 +0200 Subject: [PATCH 5/6] docs: add ppvm-lindblad and translation symmetry to API reference and developer guide Extract rustdoc for ppvm-lindblad so it appears on /api/, and list the crate plus the ppvm-pauli-sum symmetry module in the developer guide's workspace tree, dependency graph, and "where to look for X" table. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scripts/extract-rust.mjs | 3 ++- docs/src/pages/develop.astro | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/scripts/extract-rust.mjs b/docs/scripts/extract-rust.mjs index b940d7e66..5a556e46e 100644 --- a/docs/scripts/extract-rust.mjs +++ b/docs/scripts/extract-rust.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(__dirname, "..", ".."); -const CRATES = ["ppvm-traits", "ppvm-pauli-word", "ppvm-pauli-sum", "ppvm-tableau", "ppvm-sym"]; +const CRATES = ["ppvm-traits", "ppvm-pauli-word", "ppvm-pauli-sum", "ppvm-tableau", "ppvm-sym", "ppvm-lindblad"]; const CRATE_BLURB = { "ppvm-traits": "Trait system, the `Config` bundle, the `Pauli` alphabet, and map impls.", @@ -17,6 +17,7 @@ const CRATE_BLURB = { "ppvm-pauli-sum": "The `PauliSum` engine, truncation strategies, and concrete config bundles.", "ppvm-tableau": "Generalized stabilizer tableau simulator (Clifford + non-Clifford).", "ppvm-sym": "Symbolic, parametric Pauli propagation.", + "ppvm-lindblad": "Adaptive Heisenberg-picture Lindbladian evolution on a truncated Pauli-string basis.", }; const KIND_ORDER = ["module", "struct", "enum", "trait", "type_alias", "function", "macro"]; diff --git a/docs/src/pages/develop.astro b/docs/src/pages/develop.astro index 6beaa457b..a114fb323 100644 --- a/docs/src/pages/develop.astro +++ b/docs/src/pages/develop.astro @@ -59,6 +59,9 @@ const xrefs: Record = { Tableau: "ppvm-tableau:Tableau", GeneralizedTableau: "ppvm-tableau:GeneralizedTableau", SparseVector: "ppvm-tableau:SparseVector", + TranslationGroup: "ppvm-pauli-sum:TranslationGroup", + LindbladSpec: "ppvm-lindblad:LindbladSpec", + PcStepConfig: "ppvm-lindblad:PcStepConfig", }; // Pre-resolve so the template stays declarative. @@ -119,9 +122,10 @@ const toc = [ ├── crates/ │ ├── ppvm-traits # Trait system, Config bundle, Pauli alphabet, map impls │ ├── ppvm-pauli-word # Packed Pauli strings: PauliWord, phased, lossy, pattern -│ ├── ppvm-pauli-sum # PauliSum engine, truncation strategy, concrete configs +│ ├── ppvm-pauli-sum # PauliSum engine, truncation strategy, configs, translation symmetry │ ├── ppvm-tableau # Stabilizer + generalized-tableau simulator │ ├── ppvm-sym # Symbolic (parametric) Pauli propagation +│ ├── ppvm-lindblad # Adaptive Heisenberg-picture Lindbladian evolution │ ├── ppvm-stim # Stim program execution against the tableau │ ├── stim-parser # Standalone parser for the Stim circuit format │ └── ppvm-python-native # PyO3 bindings, compiled into `ppvm` as `ppvm._core` @@ -138,8 +142,9 @@ const toc = [ ppvm-sym, and ppvm-stim depend on the Pauli crates. ppvm-stim additionally depends on ppvm-tableau and stim-parser. - ppvm-python-native depends on ppvm-pauli-sum - and ppvm-tableau. + ppvm-lindblad builds on the three Pauli crates. + ppvm-python-native depends on ppvm-pauli-sum, + ppvm-tableau, and ppvm-lindblad.

§ 2Build & test

@@ -732,6 +737,12 @@ chore: restore lockfile consistency
Config trait & implementations
trait in crates/ppvm-traits/src/config.rs; concrete bundles in crates/ppvm-pauli-sum/src/config/
+
Translation symmetry (TranslationGroup, orbit & momentum-sector merging)
+
crates/ppvm-pauli-sum/src/symmetry/; Python bindings in crates/ppvm-python-native/src/symmetry.rs and ppvm-python/src/ppvm/symmetry.py
+ +
Lindbladian evolution (LindbladSpec, PcStepConfig)
+
crates/ppvm-lindblad/src/; Python bindings in crates/ppvm-python-native/src/lindblad.rs and ppvm-python/src/ppvm/lindblad.py; tests in ppvm-python/test/lindblad/
+
Stabilizer tableau core (Tableau, GeneralizedTableau)
crates/ppvm-tableau/src/data.rs, tableau_like.rs
From dba30fdd38ce185ed824bf6fd42375262a8182a7 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Tue, 29 Sep 2026 10:50:01 +0200 Subject: [PATCH 6/6] fix: address Copilot review on symmetry and Lindblad entry points - symmetry: try_from_generators reports order overflow as GroupError (PermutationOrderOverflow, GroupOrderOverflow) instead of panicking - symmetry: CharacterTable records its group's generator orders, so a table from a different group of equal order is rejected - lindblad: cap_basis and cap_map_to_room keep exactly the requested count under magnitude ties (also fixes a room+1 off-by-one) - python: LindbladSpec.action validates the input width - python: array entry points reject non-integer or out-of-range Pauli codes instead of letting the uint8 cast wrap them - python: add matplotlib to the demo dependency group Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ppvm-lindblad/src/truncate.rs | 95 +++++++++++++++++-- crates/ppvm-pauli-sum/src/symmetry/group.rs | 50 +++++----- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 12 ++- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 31 +++++- crates/ppvm-python-native/src/lindblad.rs | 7 ++ ppvm-python/pyproject.toml | 6 +- ppvm-python/src/ppvm/_codes.py | 29 ++++++ ppvm-python/src/ppvm/lindblad.py | 17 ++-- ppvm-python/src/ppvm/symmetry.py | 3 +- .../test/lindblad/test_action_generator.py | 20 ++++ ppvm-python/test/test_symmetry_arrays.py | 16 ++++ ppvm-python/test/test_symmetry_merge.py | 6 ++ ppvm-python/uv.lock | 6 +- 13 files changed, 250 insertions(+), 48 deletions(-) create mode 100644 ppvm-python/src/ppvm/_codes.py diff --git a/crates/ppvm-lindblad/src/truncate.rs b/crates/ppvm-lindblad/src/truncate.rs index 1fe7a9fb3..308e5e0a5 100644 --- a/crates/ppvm-lindblad/src/truncate.rs +++ b/crates/ppvm-lindblad/src/truncate.rs @@ -36,9 +36,8 @@ pub(crate) fn cap_map_to_room( return; } let mut mags: Vec = merged.values().map(|v| v.mag()).collect(); - let k = room.min(mags.len() - 1); - let cutoff = nth_largest(&mut mags, k); - merged.retain(|_, v| v.mag() >= cutoff); + let mut keep = top_k_keeper(&mut mags, room); + merged.retain(|_, v| keep(v.mag())); } /// Compact `basis` / `coeffs` in place: drop entries whose coefficient @@ -82,15 +81,12 @@ pub(crate) fn cap_basis( .filter(|(w, _)| !protected_set.contains(w)) .map(|(_, c)| c.mag()) .collect(); - let cutoff = if slots == 0 { - f64::INFINITY - } else if slots >= mags.len() { + if slots >= mags.len() { return; - } else { - nth_largest(&mut mags, slots - 1) - }; + } + let mut keep = top_k_keeper(&mut mags, slots); retain_in_place(basis, coeffs, |w, c| { - protected_set.contains(w) || c.mag() >= cutoff + protected_set.contains(w) || keep(c.mag()) }); } @@ -139,6 +135,27 @@ fn retain_in_place( coeffs.truncate(write); } +/// Predicate that, fed every magnitude of `mags` once, accepts exactly the +/// `k` largest (`k < mags.len()`): all above the cutoff, then ties in visit +/// order. Reorders `mags`. +fn top_k_keeper(mags: &mut [f64], k: usize) -> impl FnMut(f64) -> bool { + let (cutoff, mut ties_left) = if k == 0 { + (f64::INFINITY, 0) + } else { + let cutoff = nth_largest(mags, k - 1); + let n_above = mags.iter().filter(|&&m| m > cutoff).count(); + (cutoff, k.saturating_sub(n_above)) + }; + move |m| { + if m > cutoff { + return true; + } + let tie = m == cutoff && ties_left > 0; + ties_left -= tie as usize; + tie + } +} + /// The `k`-th largest element of `mags` (0-indexed), via a partial sort. /// Reorders `mags`. Panics if `k >= mags.len()`. fn nth_largest(mags: &mut [f64], k: usize) -> f64 { @@ -154,3 +171,61 @@ fn desc_by_mag(a: T, b: T) -> std::cmp::Ordering { .partial_cmp(&a.mag()) .unwrap_or(std::cmp::Ordering::Equal) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::word::word_from_codes; + + /// `n` distinct 4-qubit words. + fn words(n: usize) -> Vec { + (0..n) + .map(|i| { + let codes: Vec = (0..4).map(|q| ((i >> (2 * q)) & 3) as u8).collect(); + word_from_codes(&codes).unwrap() + }) + .collect() + } + + #[test] + fn cap_basis_is_a_hard_cap_under_ties() { + let mut basis = words(10); + // One large entry, nine tied zeros (fresh leakage admissions). + let mut coeffs = vec![0.0; 10]; + coeffs[3] = 1.0; + cap_basis(&mut basis, &mut coeffs, 4, &[]); + assert_eq!(basis.len(), 4); + assert_eq!(coeffs, vec![0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn cap_basis_keeps_protected_beyond_slots() { + let all = words(6); + let mut basis = all.clone(); + let mut coeffs = vec![0.5; 6]; + let protected = [all[5]]; + cap_basis(&mut basis, &mut coeffs, 3, &protected); + assert_eq!(basis, vec![all[0], all[1], all[5]]); + } + + #[test] + fn cap_map_to_room_keeps_exactly_room_under_ties() { + let mut merged: FxHashMap = words(10).into_iter().map(|w| (w, 0.25)).collect(); + cap_map_to_room(&mut merged, 3); + assert_eq!(merged.len(), 3); + } + + #[test] + fn cap_map_to_room_keeps_the_largest() { + let ws = words(5); + let mut merged: FxHashMap = ws + .iter() + .zip([1.0, -5.0, 3.0, 0.5, -4.0]) + .map(|(w, c)| (*w, c)) + .collect(); + cap_map_to_room(&mut merged, 2); + let mut kept: Vec = merged.values().copied().collect(); + kept.sort_by(f64::total_cmp); + assert_eq!(kept, vec![-5.0, -4.0]); + } +} diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index 5175941b5..646cadad5 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -12,13 +12,12 @@ fn gcd(mut a: usize, mut b: usize) -> usize { 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 checked_lcm(a: usize, b: usize) -> Option { + a.checked_div(gcd(a, b)).and_then(|q| q.checked_mul(b)) } -fn permutation_order(perm: &[u32], generator: usize) -> u32 { +/// Exact cyclic order of `perm`, or `None` if it does not fit in `u32`. +fn permutation_order(perm: &[u32], generator: usize) -> Option { let mut seen = vec![false; perm.len()]; let mut order = 1usize; for start in 0..perm.len() { @@ -36,22 +35,19 @@ fn permutation_order(perm: &[u32], generator: usize) -> u32 { break; } } - order = checked_lcm(order, length, "permutation order"); + order = checked_lcm(order, length)?; } - u32::try_from(order).unwrap_or_else(|_| { - panic!("generator {generator} exact permutation order does not fit in u32") - }) + u32::try_from(order).ok() } 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 checked_group_order(orders: &[u32]) -> Option { + orders + .iter() + .try_fold(1usize, |acc, &value| acc.checked_mul(value as usize)) } pub(super) fn validate_site_count(n: usize, context: &str) { @@ -66,9 +62,7 @@ pub(super) fn validate_site_count(n: usize, context: &str) { /// /// Every variant is caller-supplied-input error, and its [`Display`] /// text is exactly what [`TranslationGroup::from_generators`] panics -/// with. Arithmetic overflow in the group order or character phase -/// modulus is NOT covered — that needs generator orders in the billions -/// and still panics. +/// with. /// /// [`Display`]: std::fmt::Display #[derive(Debug, Clone, PartialEq, Eq)] @@ -99,6 +93,10 @@ pub enum GroupError { }, /// Two generators do not commute, so they generate no abelian group. NonCommuting { left: usize, right: usize }, + /// A generator's exact cyclic order does not fit in `u32`. + PermutationOrderOverflow { generator: usize }, + /// The group order `Π orders[g]` does not fit in `usize`. + GroupOrderOverflow, } impl std::fmt::Display for GroupError { @@ -142,6 +140,11 @@ impl std::fmt::Display for GroupError { Self::NonCommuting { left, right } => { write!(f, "generators {left} and {right} do not commute") } + Self::PermutationOrderOverflow { generator } => write!( + f, + "generator {generator} exact permutation order does not fit in u32" + ), + Self::GroupOrderOverflow => write!(f, "group order overflows usize"), } } } @@ -438,7 +441,8 @@ impl TranslationGroup { if declared == 0 { return Err(GroupError::ZeroOrder { generator }); } - let exact = permutation_order(&perms[generator], generator); + let exact = permutation_order(&perms[generator], generator) + .ok_or(GroupError::PermutationOrderOverflow { generator })?; if declared != exact { return Err(GroupError::OrderMismatch { generator, @@ -454,10 +458,12 @@ impl TranslationGroup { } } } - let order = checked_group_order(&orders); - let phase_modulus = orders.iter().fold(1usize, |acc, &value| { - checked_lcm(acc, value as usize, "character phase modulus") - }); + let order = checked_group_order(&orders).ok_or(GroupError::GroupOrderOverflow)?; + // lcm divides the product, so this cannot overflow once `order` fits. + let phase_modulus = orders + .iter() + .try_fold(1usize, |acc, &value| checked_lcm(acc, value as usize)) + .ok_or(GroupError::GroupOrderOverflow)?; let block_cyclic = detect_block_cyclic(n_qubits, &perms, &orders); let rotations = perms .iter() diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index d294ca1ec..a45b26886 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -88,7 +88,11 @@ impl TranslationGroup { *c = 0; } } - CharacterTable { numerators, values } + CharacterTable { + orders: self.orders.clone(), + numerators, + values, + } } /// Everything the phase-aware routines need about `w`'s orbit in @@ -148,8 +152,7 @@ impl TranslationGroup { S: BuildHasher + Clone + Default + HashFinalize, { assert_eq!( - table.len(), - self.order(), + table.orders, self.orders, "character table does not belong to this group" ); let (rep, idx, stabilizer) = @@ -163,6 +166,9 @@ impl TranslationGroup { /// [`TranslationGroup::character_table`]. #[derive(Debug, Clone)] pub struct CharacterTable { + /// Generator orders of the originating group; element indices (and so + /// the table) are only meaningful for a group with the same orders. + orders: Vec, /// Exact phase numerators (see `character_numerator`); `0` ⇔ `χ = 1`. numerators: Vec, values: Vec>, diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 5e09d5c47..4a76db03b 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -824,7 +824,27 @@ fn rejects_group_order_overflow() { } else { vec![u32::MAX, u32::MAX] }; - assert!(std::panic::catch_unwind(|| { super::group::checked_group_order(&orders) }).is_err()); + assert_eq!(super::group::checked_group_order(&orders), None); +} + +#[test] +fn try_from_generators_reports_overflow() { + use super::GroupError; + // 64 copies of one swap: a valid direct product of order 2^64. + let err = TranslationGroup::try_from_generators(2, vec![vec![1, 0]; 64], vec![2; 64]) + .expect_err("group order must overflow"); + assert_eq!(err, GroupError::GroupOrderOverflow); + + // Disjoint prime cycles 2..29 on 129 qubits: order 6469693230 > u32::MAX. + let mut perm = Vec::new(); + for len in [2u32, 3, 5, 7, 11, 13, 17, 19, 23, 29] { + let start = perm.len() as u32; + perm.extend((0..len).map(|j| start + (j + 1) % len)); + } + let n = perm.len(); + let err = TranslationGroup::try_from_generators(n, vec![perm], vec![1]) + .expect_err("permutation order must overflow u32"); + assert_eq!(err, GroupError::PermutationOrderOverflow { generator: 0 }); } // --------------------------------------------------------------------------- @@ -1100,3 +1120,12 @@ fn orbit_yields_hashed_words() { assert_eq!(fxhash::hash64(&member), fxhash::hash64(&fresh)); } } + +#[test] +#[should_panic(expected = "character table does not belong to this group")] +fn character_table_rejects_a_group_of_equal_order() { + // Both groups have order 4, but their element indices mean different things. + let table = TranslationGroup::chain_1d(4).character_table(&[1]); + let torus = TranslationGroup::torus_2d(2, 2); + torus.canonicalize_in_sector_indexed(&word("XIII"), &table); +} diff --git a/crates/ppvm-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index 796fcb995..dc209cd37 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -228,6 +228,13 @@ impl LindbladSpec { ) -> PyResult> { with_spec!(&self.inner, inner, C => { let p_slice = p.as_slice()?; + if p_slice.len() != inner.n_qubits() { + return Err(PyValueError::new_err(format!( + "p has {} entries but spec.n_qubits = {}", + p_slice.len(), + inner.n_qubits() + ))); + } let p_word = word_from_codes::(p_slice).map_err(map_err)?; let pairs = inner.action(&p_word); pack_pauli_map(py, pairs, inner.n_qubits()) diff --git a/ppvm-python/pyproject.toml b/ppvm-python/pyproject.toml index 5c8d11dfd..be07ef7ba 100644 --- a/ppvm-python/pyproject.toml +++ b/ppvm-python/pyproject.toml @@ -58,8 +58,10 @@ dev = [ "pytest>=9.0.2", "pytest-benchmark>=5.2.3", ] -# Optional: only the `demo/` scripts use it (`expm_multiply` for the reference -# matrix exponential). Runtime ppvm and the test suite have no scipy dep. +# Optional: only the `demo/` scripts use these (`expm_multiply` for the +# reference matrix exponential, plus plotting). Runtime ppvm and the test +# suite depend on neither. demo = [ + "matplotlib>=3.8", "scipy>=1.13", ] diff --git a/ppvm-python/src/ppvm/_codes.py b/ppvm-python/src/ppvm/_codes.py new file mode 100644 index 000000000..208036195 --- /dev/null +++ b/ppvm-python/src/ppvm/_codes.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2026 The PPVM Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Coercion of array-like Pauli codes to the uint8 layout the core expects.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + + +def as_pauli_codes(codes: npt.ArrayLike) -> np.ndarray: + """Return `codes` as a C-contiguous uint8 array of Pauli codes + (``0=I, 1=X, 2=Z, 3=Y``). + + uint8 input passes through unchanged (the core range-checks it). Any + other input must be integer-typed with every value in ``0..=3``, so an + out-of-range code raises ``ValueError`` instead of wrapping on the cast + (e.g. ``256`` silently becoming ``0 = I``). + """ + arr = np.asarray(codes) + if arr.dtype != np.uint8 and arr.size: + if not np.issubdtype(arr.dtype, np.integer): + raise ValueError(f"Pauli codes must be integers, got dtype {arr.dtype}") + lo, hi = arr.min(), arr.max() + if lo < 0 or hi > 3: + bad = lo if lo < 0 else hi + raise ValueError(f"Pauli code must be 0 (I), 1 (X), 2 (Z), or 3 (Y); got {bad}") + return np.ascontiguousarray(arr, dtype=np.uint8) diff --git a/ppvm-python/src/ppvm/lindblad.py b/ppvm-python/src/ppvm/lindblad.py index 501eb7d12..d5e3f731a 100644 --- a/ppvm-python/src/ppvm/lindblad.py +++ b/ppvm-python/src/ppvm/lindblad.py @@ -45,6 +45,7 @@ import numpy.typing as npt from . import _core +from ._codes import as_pauli_codes from ._core import LindbladSpec as _LindbladSpec _PAULI_CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} @@ -267,7 +268,7 @@ def action_arr(self, p: np.ndarray) -> tuple[np.ndarray, np.ndarray]: Returns ``(out_basis, out_coeffs)``: a ``(M, n_qubits)`` uint8 array and a length-``M`` float64 array. """ - return self._spec.action(np.ascontiguousarray(p, dtype=np.uint8)) + return self._spec.action(as_pauli_codes(p)) def leakage_arr( self, @@ -288,9 +289,9 @@ def leakage_arr( if protected_arr is None: protected_arr = np.zeros((0, n), dtype=np.uint8) return self._spec.leakage( - np.ascontiguousarray(basis_arr, dtype=np.uint8), + as_pauli_codes(basis_arr), np.ascontiguousarray(coeffs, dtype=np.float64), - np.ascontiguousarray(protected_arr, dtype=np.uint8), + as_pauli_codes(protected_arr), ) def pc_step_arr( @@ -345,12 +346,12 @@ def pc_step_arr( if protected_arr is None: protected_arr = np.zeros((0, n), dtype=np.uint8) return self._spec.pc_step( - np.ascontiguousarray(basis_arr, dtype=np.uint8), + as_pauli_codes(basis_arr), np.ascontiguousarray(coeffs, dtype=np.float64), float(dt), int(max_basis), float(drop_tol), - np.ascontiguousarray(protected_arr, dtype=np.uint8), + as_pauli_codes(protected_arr), None if num_threads is None else int(num_threads), None if admit_basis is None else int(admit_basis), None if tau_add is None else float(tau_add), @@ -412,14 +413,14 @@ def pc_step_orbit_rep( if protected_arr is None: protected_arr = np.zeros((0, n), dtype=np.uint8) return self._spec.pc_step_orbit_rep( - np.ascontiguousarray(basis_arr, dtype=np.uint8), + as_pauli_codes(basis_arr), np.ascontiguousarray(coeffs, dtype=np.complex128), float(dt), int(max_basis), group, np.ascontiguousarray(momentum, dtype=np.int32), float(drop_tol), - np.ascontiguousarray(protected_arr, dtype=np.uint8), + as_pauli_codes(protected_arr), bool(canonicalize_first), None if admit_basis is None else int(admit_basis), None if tau_add is None else float(tau_add), @@ -468,7 +469,7 @@ def generator_arr(self, basis_arr: np.ndarray) -> tuple[np.ndarray, np.ndarray, ... (vals, (rows, cols)), shape=(len(basis_arr), len(basis_arr)) ... ).tocsc() """ - return self._spec.generator(np.ascontiguousarray(basis_arr, dtype=np.uint8)) + return self._spec.generator(as_pauli_codes(basis_arr)) # ── String-keyed convenience API (slower; for tests / display) ── diff --git a/ppvm-python/src/ppvm/symmetry.py b/ppvm-python/src/ppvm/symmetry.py index d7a7ee9b4..9938c4571 100644 --- a/ppvm-python/src/ppvm/symmetry.py +++ b/ppvm-python/src/ppvm/symmetry.py @@ -30,6 +30,7 @@ import numpy.typing as npt from . import _core +from ._codes import as_pauli_codes from ._core import TranslationGroup as TranslationGroup __all__ = [ @@ -45,7 +46,7 @@ def _momentum(momentum: npt.ArrayLike) -> np.ndarray: def _basis(basis_arr: npt.ArrayLike) -> np.ndarray: - return np.ascontiguousarray(basis_arr, dtype=np.uint8) + return as_pauli_codes(basis_arr) def canonicalize_basis_arr( diff --git a/ppvm-python/test/lindblad/test_action_generator.py b/ppvm-python/test/lindblad/test_action_generator.py index 7b571c9c9..bca08585e 100644 --- a/ppvm-python/test/lindblad/test_action_generator.py +++ b/ppvm-python/test/lindblad/test_action_generator.py @@ -106,3 +106,23 @@ def test_protected_strings_suppressed(): protected_key = next(iter(leak)) leak2 = L_op.leakage(basis, coeffs, protected=[protected_key]) assert protected_key not in leak2 + + +@pytest.mark.parametrize("width", [3, 5]) +def test_action_arr_rejects_wrong_width(width): + L = 4 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_op = Lindbladian(L, h_terms, jump_terms) + with pytest.raises(ValueError, match=f"p has {width} entries but spec.n_qubits = 4"): + L_op.action_arr(np.zeros(width, dtype=np.uint8)) + + +def test_arr_entry_points_reject_out_of_range_codes(): + L = 4 + h_terms, jump_terms = xy_dephasing(L, alpha=1.0, gamma=0.3) + L_op = Lindbladian(L, h_terms, jump_terms) + # A bare uint8 cast would turn 258 into 2 (= Z) and accept it. + with pytest.raises(ValueError, match="got 258"): + L_op.action_arr(np.array([258, 0, 0, 0])) + with pytest.raises(ValueError, match="got 258"): + L_op.leakage_arr(np.array([[258, 0, 0, 0]]), np.ones(1)) diff --git a/ppvm-python/test/test_symmetry_arrays.py b/ppvm-python/test/test_symmetry_arrays.py index 7630e6cc0..64751a74f 100644 --- a/ppvm-python/test/test_symmetry_arrays.py +++ b/ppvm-python/test/test_symmetry_arrays.py @@ -81,6 +81,22 @@ def test_wrappers_coerce_argument_dtypes(): assert check_momentum_sector_arr(py_basis, [1 + 0j] * n, g, [0]) is None +@pytest.mark.parametrize( + "bad, message", + [ + # 256 would wrap to 0 (= I) and 259 to 3 (= Y) on a bare uint8 cast. + (np.array([[256, 0, 0, 0]]), "got 256"), + (np.array([[259, 0, 0, 0]]), "got 259"), + (np.array([[-1, 0, 0, 0]]), "got -1"), + (np.array([[1.5, 0, 0, 0]]), "must be integers"), + ], +) +def test_wrappers_reject_codes_the_uint8_cast_would_alter(bad, message): + g = TranslationGroup.chain_1d(4) + with pytest.raises(ValueError, match=message): + canonicalize_basis_arr(bad, [1.0], g) + + # ── canonicalize_basis_arr (real, k=0) ─────────────────────────────────────── def test_canonicalize_basis_arr_sums_collisions(): n = 4 diff --git a/ppvm-python/test/test_symmetry_merge.py b/ppvm-python/test/test_symmetry_merge.py index f91536534..06184936e 100644 --- a/ppvm-python/test/test_symmetry_merge.py +++ b/ppvm-python/test/test_symmetry_merge.py @@ -78,6 +78,12 @@ def test_from_generators_validates(perms, orders, message): TranslationGroup.from_generators(4, perms, orders) +def test_from_generators_overflow_is_value_error(): + """64 commuting copies of one swap: group order 2^64 overflows.""" + with pytest.raises(ValueError, match="group order overflows"): + TranslationGroup.from_generators(2, [[1, 0]] * 64, [2] * 64) + + @pytest.mark.parametrize( "ctor, args, message", [ diff --git a/ppvm-python/uv.lock b/ppvm-python/uv.lock index 6d8fb7195..0547e3d35 100644 --- a/ppvm-python/uv.lock +++ b/ppvm-python/uv.lock @@ -911,6 +911,7 @@ dependencies = [ [package.dev-dependencies] demo = [ + { name = "matplotlib" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] @@ -927,7 +928,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -demo = [{ name = "scipy", specifier = ">=1.13" }] +demo = [ + { name = "matplotlib", specifier = ">=3.8" }, + { name = "scipy", specifier = ">=1.13" }, +] dev = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-benchmark", specifier = ">=5.2.3" },