From e82570fef10b15487bb36a72e2b81a6198998505 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Thu, 16 Jul 2026 13:18:25 +0100 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20symmetry-merged=20evolution=20?= =?UTF-8?q?=E2=80=94=20Trotter=20merging=20+=20momentum-sector=20CTPP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consumers of the translation-symmetry primitive: Trotter path: PauliSum.symmetry_merge (k=0) and PauliSum.momentum_merge (k≠0 carried as a real pair, character-weighted fold, |G|-rescaled to the summing projector so merging after every step is idempotent), with TranslationGroup and canonicalize_basis_arr{,_complex} / check_momentum_sector_arr exposed to Python. Inputs validated at the boundary (ValueError, not panics). CTPP path: pc_step_orbit_rep — per-step evolution entirely in orbit-representative form (complex coefficients, phase-aware action, cached-CSC expm; basis ~|G|× smaller than full-basis evolution, persisting through every step), with the same PcStepConfig truncation policy incl. displacement admission. Split 3/4 of the CTPP work; full history on branch continuous-time-pauli-propagation. Co-Authored-By: Claude Fable 5 --- crates/ppvm-lindblad/Cargo.toml | 4 +- crates/ppvm-lindblad/src/lib.rs | 5 + crates/ppvm-lindblad/src/orbit_rep.rs | 445 +++++++++++++++++++++ crates/ppvm-lindblad/src/tests.rs | 102 +++++ crates/ppvm-python-native/src/interface.rs | 139 +++++++ crates/ppvm-python-native/src/lib.rs | 11 + crates/ppvm-python-native/src/lindblad.rs | 118 +++++- crates/ppvm-python-native/src/symmetry.rs | 328 +++++++++++++++ ppvm-python/src/ppvm/lindblad.py | 60 ++- ppvm-python/src/ppvm/paulisum.py | 44 ++ ppvm-python/test/test_momentum_merge.py | 199 +++++++++ 11 files changed, 1450 insertions(+), 5 deletions(-) create mode 100644 crates/ppvm-lindblad/src/orbit_rep.rs create mode 100644 crates/ppvm-python-native/src/symmetry.rs create mode 100644 ppvm-python/test/test_momentum_merge.py diff --git a/crates/ppvm-lindblad/Cargo.toml b/crates/ppvm-lindblad/Cargo.toml index 69f425341..900388d36 100644 --- a/crates/ppvm-lindblad/Cargo.toml +++ b/crates/ppvm-lindblad/Cargo.toml @@ -10,6 +10,7 @@ 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. @@ -18,6 +19,3 @@ quspin-expm = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2 # 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] -ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } diff --git a/crates/ppvm-lindblad/src/lib.rs b/crates/ppvm-lindblad/src/lib.rs index da5414e66..e99bbb5d0 100644 --- a/crates/ppvm-lindblad/src/lib.rs +++ b/crates/ppvm-lindblad/src/lib.rs @@ -45,6 +45,10 @@ mod word; /// Matrix-free / quspin-expm-backed `exp(dt·L*)·b` engine. See module docs. pub(crate) mod mf_expm; +/// Per-step orbit-rep evolution under translation symmetry, with a +/// phase-aware complex action. See module docs. +pub mod orbit_rep; + pub use basis::build_basis_index; pub use config::PcStepConfig; pub use error::Error; @@ -54,3 +58,4 @@ pub use word::{MAX_QUBITS, Word, codes_from_word, parse_pauli_string, word_from_ #[cfg(test)] mod tests; + diff --git a/crates/ppvm-lindblad/src/orbit_rep.rs b/crates/ppvm-lindblad/src/orbit_rep.rs new file mode 100644 index 000000000..7bf7a27c6 --- /dev/null +++ b/crates/ppvm-lindblad/src/orbit_rep.rs @@ -0,0 +1,445 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Per-step orbit-representative evolution under translation symmetry. +//! +//! The state lives entirely in **orbit-rep form** throughout: `basis` +//! contains only canonical translation-orbit representatives, and +//! `coeffs` 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` (where `v` is the matrix +//! element of `L*` between input rep `r` and output `q`). +//! +//! The orbit-rep basis is ~|G|× smaller than the full-basis +//! representation, throughout the entire evolution. +//! +//! The phase-aware action is genuinely **complex** (because of the +//! `χ_k(g)` phase factors). Rather than materialise a sparse matrix, the +//! per-column action — for each input rep, the list of `(row, χ_k·v)` +//! pairs for the in-basis outputs — is computed **once per expm call** +//! (via [`build_orbit_rep_cols`]) and then reused, CSC-style, across +//! every Krylov–Taylor matvec driving the external `quspin-expm` engine. +//! +//! ## Limitations +//! +//! - Caller is 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. +//! - The momentum sector `k_modes` is fixed for the duration of one +//! pc_step call. To compute a full site-resolved profile, call +//! `pc_step_orbit_rep` once per momentum mode and inverse-Fourier +//! the results. + +use crate::mf_expm::CscOp; +use crate::{Error, LindbladSpec}; +use fxhash::{FxBuildHasher, FxHashMap}; +use num::Complex; +use ppvm_pauli_sum::symmetry::TranslationGroup; +use quspin_expm::ExpmOp; +use rayon::prelude::*; + +// Word type re-exported from lib.rs. +use crate::Word; + +/// 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 +/// [`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); + } +} + +/// Build the per-column phase-aware action of the in-basis-restricted +/// orbit-rep generator `M` at momentum sector `k_modes`. +/// +/// Returns, for each input rep `basis[c]` (column `c`), the list of +/// `(row, χ_k(g_{cnt_q}) · v_q)` pairs for every action output Pauli `q` +/// of `L*(basis[c])` whose orbit rep `r_q` is in `basis` at index `row`. +/// Outputs not in `basis` are dropped. This is the expensive part of the +/// orbit-rep dynamics (`compute_action_terms`, `canonicalize_with_shift`, +/// `character`); it is computed once and reused by the CSC-style matvec +/// in [`CscOp`]. +pub(crate) fn build_orbit_rep_cols( + spec: &LindbladSpec, + basis: &[Word], + index: &FxHashMap, + group: &TranslationGroup, + k_modes: &[i32], +) -> Vec)>> { + basis + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(spec.n_qubits()), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), r| { + let terms = spec.compute_action_terms(r, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (q, v) in terms.iter() { + let (r_q, cnt_q) = group.canonicalize_with_shift(q); + if let Some(&row) = index.get(&r_q) { + let phase = group.character(k_modes, &cnt_q); + out.push((row, phase * *v)); + } + } + out + }, + ) + .collect() +} + +/// Compute `exp(dt · M) · coeffs` for the in-basis-restricted orbit-rep +/// generator `M` at momentum sector `k_modes`, 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 (see [`CscOp`]). One pass over the cached columns +/// extracts the diagonal shift `μ = tr(M)/n` and a valid upper bound on +/// the column 1-norm of `M − μ·I`; from `‖dt·(M−μI)‖₁` we pick the Taylor +/// partition `(m*, s)` and hand everything to +/// [`quspin_expm::ExpmOp::from_parts`] (mirroring +/// [`crate::mf_expm::expm_apply_mf`]). +pub(crate) fn expm_apply_orbit_rep_cached( + spec: &LindbladSpec, + basis: &[Word], + group: &TranslationGroup, + k_modes: &[i32], + dt: f64, + coeffs: &[Complex], +) -> Vec> { + let n = basis.len(); + if n == 0 { + return Vec::new(); + } + + let index = crate::build_basis_index(basis); + let cols = build_orbit_rep_cols(spec, basis, &index, group, k_modes); + + // One pass for the per-column `(raw, diag)` used by the `μ`/1-norm + // selection: `raw = Σ|val|` (upper bound on the absolute column sum), + // `diag = M[c,c]`. From these: `trace = Σ diag`, `μ = trace/n`, and an + // upper bound on the column 1-norm of `M − μ·I`: `raw − |diag| + |diag − μ|`. + let per_col: Vec<(f64, Complex)> = cols + .par_iter() + .enumerate() + .map(|(c, col)| { + let mut raw = 0.0_f64; + let mut diag = Complex::new(0.0, 0.0); + for &(row, val) in col.iter() { + raw += val.norm(); + if row as usize == c { + diag += val; + } + } + (raw, diag) + }) + .collect(); + + let trace: Complex = per_col.iter().map(|(_, d)| *d).sum(); + let mu = trace / n as f64; + let onenorm = per_col + .iter() + .map(|(raw, diag)| raw - diag.norm() + (diag - mu).norm()) + .fold(0.0_f64, f64::max); + + let (m_star, s) = crate::expm::select_ms(dt.abs() * onenorm); + + let mut v = coeffs.to_vec(); + let op = CscOp { cols: &cols, dim: n }; + let e = ExpmOp::from_parts( + op, + Complex::new(dt, 0.0), + mu, + s as usize, + m_star as usize, + 1e-12_f64, + ); + e.apply(ndarray::ArrayViewMut1::from(v.as_mut_slice())) + .expect("expm apply (orbit-rep cached)"); + v +} + +/// 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 `k_modes`). +/// +/// 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, cnt_q)`. +/// 2. If `r_q` NOT in `basis` and NOT in `protected`: +/// `merged[r_q] += χ_k(g_{cnt_q}) · v_q · c_r`. +/// +/// Returns `(r_q, sum)` pairs for all candidates with nonzero sum. +/// +/// The live candidate map is capped to the *available room* +/// `room = max_basis − basis.len()` (the reps we could actually add), +/// applied during accumulation: input reps are processed in descending +/// `|c|` order and after each chunk only the `room` largest-`|sum|` +/// candidates are kept. A large `max_basis` (room ≥ all candidates) +/// disables the cap — the near-exact case. +#[allow(clippy::too_many_arguments)] +pub fn leakage_orbit_rep( + spec: &LindbladSpec, + basis: &[Word], + coeffs: &[Complex], + protected: &[Word], + group: &TranslationGroup, + k_modes: &[i32], + max_basis: usize, +) -> Result)>, Error> { + if basis.len() != coeffs.len() { + return Err(Error::LengthMismatch { + what: "basis and coeffs", + a: basis.len(), + b: coeffs.len(), + }); + } + let in_basis: FxHashMap<&Word, ()> = basis.iter().map(|w| (w, ())).collect(); + let protected_set: FxHashMap<&Word, ()> = protected.iter().map(|w| (w, ())).collect(); + + // Descending sort by |c|: process largest-magnitude contributors first + // so the running room-cap keeps the right entries. + let mut order: Vec = (0..basis.len()).collect(); + order.sort_by(|&a, &b| { + coeffs[b] + .norm() + .partial_cmp(&coeffs[a].norm()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + const CHUNK_SIZE: usize = 4096; + let room = max_basis.saturating_sub(basis.len()); + let mut merged: FxHashMap> = FxHashMap::default(); + for chunk_indices in order.chunks(CHUNK_SIZE) { + let local: Vec)>> = chunk_indices + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(spec.n_qubits()), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), &i| { + let r = &basis[i]; + let c_r = coeffs[i]; + let terms = spec.compute_action_terms(r, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + for (q, v) in terms.iter() { + let (r_q, cnt_q) = group.canonicalize_with_shift(q); + if !in_basis.contains_key(&r_q) && !protected_set.contains_key(&r_q) { + let phase = group.character(k_modes, &cnt_q); + out.push((r_q, phase * *v * c_r)); + } + } + out + }, + ) + .collect(); + for v in local { + for (k, val) in v { + *merged.entry(k).or_insert(Complex::new(0.0, 0.0)) += val; + } + } + + // Room-cap: keep only the `room` largest-magnitude entries. + if merged.len() > room { + if room == 0 { + merged.clear(); + } else { + let mut mags: Vec = merged.values().map(|v| v.norm()).collect(); + let k = room.min(mags.len() - 1); + mags.select_nth_unstable_by(k, |a, b| { + b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) + }); + let cutoff = mags[k]; + merged.retain(|_, &mut v| v.norm() >= cutoff); + } + } + } + Ok(merged.into_iter().filter(|(_, c)| c.norm() > 0.0).collect()) +} + +/// Per-step orbit-rep predictor-corrector evolution. +/// +/// All state lives in orbit-rep form throughout. Each pc step does: +/// 1. Phase-aware leakage from `(basis, coeffs)`; append the largest +/// leakage reps, up to the admission room. +/// 2. Predictor: cached-action expm ([`expm_apply_orbit_rep_cached`]). +/// 3. Phase-aware leakage from the predicted state; append further reps. +/// 4. Corrector: cached-action expm from the pre-step coefficients on +/// the doubly-enlarged basis. +/// 5. Prune `|c| < drop_tol`, then trim to the top-`max_basis` reps by +/// `|c|`; protected reps never dropped. +/// +/// `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, the +/// leakage map is capped to the same room, and the post-step basis is +/// trimmed to the top-`max_basis` by `|c|`. Pass a large value (e.g. +/// `usize::MAX`) for the near-exact, uncapped case. `drop_tol` additionally +/// prunes by magnitude. +/// +/// `basis` is assumed to contain only canonical orbit representatives. +/// If not, [`canonicalize_basis_to_rep`] should be called first. +#[allow(clippy::too_many_arguments)] +pub fn pc_step_orbit_rep( + spec: &LindbladSpec, + basis: &mut Vec, + coeffs: &mut Vec>, + dt: f64, + protected: &[Word], + group: &TranslationGroup, + k_modes: &[i32], + cfg: &crate::PcStepConfig, +) -> Result<(), Error> { + let crate::PcStepConfig { max_basis, admit_basis, drop_tol, tau_add, .. } = *cfg; + // Admission bound, mirroring the real-space `pc_step`: enrichment may + // grow the live basis to `admit` >= `max_basis`; the final + // `cap_basis_complex` 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 = leakage_orbit_rep(spec, basis, coeffs, protected, group, k_modes, admit)?; + if tau_add > 0.0 { + leak.retain(|(_, c)| c.norm() > tau_add); + } + add_leakage_capped_complex(basis, coeffs, leak, admit); + // 2. Predictor: cached-action expm (the phase-aware action is built + // once via `build_orbit_rep_cols`). + let coeffs_predict = expm_apply_orbit_rep_cached(spec, basis, group, k_modes, dt, coeffs); + // 3. Second-hop leakage from predicted state. + let mut leak2 = + leakage_orbit_rep(spec, basis, &coeffs_predict, protected, group, k_modes, admit)?; + drop(coeffs_predict); + if tau_add > 0.0 { + leak2.retain(|(_, c)| c.norm() > tau_add); + } + add_leakage_capped_complex(basis, coeffs, leak2, admit); + // 4. Corrector: cache-the-action expm from pre-step state (basis grew). + *coeffs = expm_apply_orbit_rep_cached(spec, basis, group, k_modes, dt, coeffs); + // 5. Prune by magnitude, then rank-cap to max_basis. + if drop_tol > 0.0 { + prune_basis_complex_local(basis, coeffs, drop_tol, protected); + } + cap_basis_complex(basis, coeffs, max_basis, protected); + Ok(()) +} + +/// Complex analogue of `crate::add_leakage_capped`: add the largest leakage +/// reps to the basis, up to the available room `room = max_basis − +/// basis.len()`, so the in-step orbit-rep basis never exceeds `max_basis`. +/// New reps get coefficient 0; the surrounding expm fills them. No +/// magnitude filter — the top-`room` by `|leakage|` are added. +fn add_leakage_capped_complex( + basis: &mut Vec, + coeffs: &mut Vec>, + mut leak: Vec<(Word, Complex)>, + 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| { + b.1.norm() + .partial_cmp(&a.1.norm()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + leak.truncate(room); + } + for (w, _) in leak { + basis.push(w); + coeffs.push(Complex::new(0.0, 0.0)); + } +} + +/// Complex analogue of `crate::cap_basis`: keep only the `max_basis` +/// largest-`|c|` reps (protected reps always kept), dropping the rest. +/// A `max_basis` large enough to cover the whole basis is a no-op. +fn cap_basis_complex( + basis: &mut Vec, + coeffs: &mut Vec>, + max_basis: usize, + protected: &[Word], +) { + if basis.len() <= max_basis { + return; + } + let protected_set: fxhash::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.norm()) + .collect(); + let cutoff = if slots == 0 { + f64::INFINITY + } else if slots >= mags.len() { + return; + } else { + let k = slots - 1; + mags.select_nth_unstable_by(k, |a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + mags[k] + }; + let mut write = 0; + for read in 0..basis.len() { + if protected_set.contains(&basis[read]) || coeffs[read].norm() >= cutoff { + if write != read { + basis.swap(write, read); + coeffs.swap(write, read); + } + write += 1; + } + } + basis.truncate(write); + coeffs.truncate(write); +} + +/// Complex analogue of `crate::prune_basis`: drop reps with `|c| < +/// drop_tol`, never dropping `protected` reps. No-op when `drop_tol <= 0`. +fn prune_basis_complex_local( + basis: &mut Vec, + coeffs: &mut Vec>, + drop_tol: f64, + protected: &[Word], +) { + if drop_tol <= 0.0 { + return; + } + let protected_set: fxhash::FxHashSet<&Word> = protected.iter().collect(); + let mut write = 0; + for read in 0..basis.len() { + if coeffs[read].norm() >= drop_tol || protected_set.contains(&basis[read]) { + if write != read { + basis.swap(write, read); + coeffs.swap(write, read); + } + write += 1; + } + } + basis.truncate(write); + coeffs.truncate(write); +} diff --git a/crates/ppvm-lindblad/src/tests.rs b/crates/ppvm-lindblad/src/tests.rs index 4217b3e13..3ad4899d5 100644 --- a/crates/ppvm-lindblad/src/tests.rs +++ b/crates/ppvm-lindblad/src/tests.rs @@ -94,6 +94,108 @@ fn word_codec_roundtrip() { assert_eq!(out.as_slice(), &codes); } +/// Per-step orbit-rep evolution gives the SAME final orbit-rep +/// state as full-basis complex evolution followed by a single +/// projection at the end. Validates that the phase-aware complex +/// action machinery is consistent with the full-basis reference. +#[test] +fn pc_step_orbit_rep_matches_full_basis_projection() { + use std::f64::consts::PI; + + use ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex; + let n = 4usize; + let dt = 0.01f64; + let n_steps = 3usize; + 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 = LindbladSpec::new(n, &h_terms, &[]).unwrap(); + let group = ppvm_pauli_sum::symmetry::TranslationGroup::chain_1d(n); + let k_mode: i32 = 1; + let k = vec![k_mode]; + + // Build the k=1 eigenstate in FULL basis form. + let basis_full: Vec = (0..n) + .map(|j| { + let mut s = vec!['I'; n]; + s[j] = 'Z'; + let (w, _) = parse_pauli_string(&s.into_iter().collect::(), n).unwrap(); + w + }) + .collect(); + let coeffs_full: Vec> = (0..n as i32) + .map(|a| Complex::from_polar(1.0, -2.0 * PI * (k_mode as f64) * (a as f64) / (n as f64))) + .collect(); + + // ----- Full-basis path ----- + let mut bf = basis_full.clone(); + let mut cf = coeffs_full.clone(); + let protected: Vec = Vec::new(); + for _ in 0..n_steps { + // Full enrichment (tau_add = 0.0 adds every leakage string): + // for a momentum eigenstate the leakage is pure-sector, so the + // full-basis and orbit-rep paths build corresponding bases and + // the projection theorem gives an exact match. The orbit-rep + // side uses a large max_basis so its rank cap never binds. + pc_step_complex_full(&spec, &mut bf, &mut cf, dt); + } + // Project at the end. + canonicalize_pauli_sum_complex(&mut bf, &mut cf, &group, &k); + + // ----- Orbit-rep path ----- + // Initial orbit-rep form: project the full-basis input. + let mut br = basis_full.clone(); + let mut cr = coeffs_full.clone(); + canonicalize_pauli_sum_complex(&mut br, &mut cr, &group, &k); + // Evolve in orbit-rep form (max_basis large ⇒ full enrichment). + for _ in 0..n_steps { + orbit_rep::pc_step_orbit_rep( + &spec, + &mut br, + &mut cr, + dt, + &protected, + &group, + &k, + &PcStepConfig { + max_basis: 10_000_000, + ..Default::default() + }, + ) + .unwrap(); + } + + // Compare. + 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 {:?} in orbit-rep but not in full-basis", w)); + 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}" + ); +} + /// The full-space complex step at momentum k=0 must reproduce the real /// pc_step on the same trajectory exactly. #[test] diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 44a47b83d..ec690ae80 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -48,6 +48,139 @@ 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._core.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, reusing the tested + /// `canonicalize_pauli_sum_complex`. + /// + /// `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, + mut other: pyo3::PyRefMut<'_, Self>, + group: &crate::symmetry::TranslationGroup, + momentum: Vec, + ) -> pyo3::PyResult<()> { + let n_g = group.core().n_qubits(); + for (label, n) in [ + ("self", self.inner.n_qubits()), + ("other", other.inner.n_qubits()), + ] { + if n != n_g { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "{label} PauliSum has {n} qubits but the \ + TranslationGroup acts on {n_g}", + ))); + } + } + 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(), + ))); + } + // Gather both real components into word -> (re + i·im). + let mut combined: std::collections::HashMap< + <$type as Config>::PauliWordType, + num::Complex, + > = std::collections::HashMap::new(); + for (w, v) in self.inner.data().iter() { + combined + .entry(w.clone()) + .or_insert(num::Complex::new(0.0, 0.0)) + .re += *v; + } + for (w, v) in other.inner.data().iter() { + combined + .entry(w.clone()) + .or_insert(num::Complex::new(0.0, 0.0)) + .im += *v; + } + let mut basis = Vec::with_capacity(combined.len()); + let mut coeffs = Vec::with_capacity(combined.len()); + for (w, c) in combined { + basis.push(w); + coeffs.push(c); + } + // Character-weighted fold onto orbit reps. + // `canonicalize_pauli_sum_complex` carries a 1/|G| prefactor; + // we rescale by |G| so the merge is the *summing* projector + // (like `symmetry_merge`): idempotent on already-merged input, + // hence stable under merging after every Trotter step. + ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex( + &mut basis, + &mut coeffs, + group.core(), + &momentum, + ); + let scale = group.core().order() as f64; + // Write the real/imag parts back into the two sums. + self.inner.data_mut().clear(); + other.inner.data_mut().clear(); + for (w, c) in basis.into_iter().zip(coeffs.into_iter()) { + let re = c.re * scale; + let im = c.im * scale; + if re != 0.0 { + self.inner += (w.clone(), re); + } + if im != 0.0 { + other.inner += (w, im); + } + } + Ok(()) + } + } + }; +} + macro_rules! create_strategy { (false, $min_abs_coeff:ident, $max_pauli_weight:ident, $_max_loss_weight:ident) => { CombinedStrategy( @@ -403,6 +536,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 13f4c22ec..efb190104 100644 --- a/crates/ppvm-python-native/src/lib.rs +++ b/crates/ppvm-python-native/src/lib.rs @@ -16,6 +16,7 @@ pub mod interface_tableau; pub mod interface_tableau_sum; pub mod lindblad; pub mod stim_program; +pub mod symmetry; pub(crate) fn flat_pairs(targets: &[usize]) -> PyResult> { if !targets.len().is_multiple_of(2) { @@ -310,4 +311,14 @@ pub mod _core { // 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 index 25d4e0b0d..07d0e739f 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -13,7 +13,9 @@ use std::collections::HashMap; use num::Complex; -use numpy::{IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2}; +use numpy::{ + Complex64, IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, +}; use ppvm_lindblad::{JumpInput, LindbladSpec as CoreSpec, Word, codes_from_word, word_from_codes}; use pyo3::{exceptions::PyValueError, prelude::*}; @@ -341,6 +343,120 @@ impl LindbladSpec { 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, + ))] + #[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, + ) -> PyResult<(Bound<'py, PyArray2>, Bound<'py, PyArray1>)> { + use num::Complex; + use ppvm_lindblad::orbit_rep; + + let n_q = self.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()?; + if coeffs_slice.len() != basis_words.len() { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + 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()?; + if k_slice.len() != group.core().n_generators() { + return Err(PyValueError::new_err(format!( + "momentum has {} entries but group has {} generators", + k_slice.len(), + group.core().n_generators() + ))); + } + if canonicalize_first { + orbit_rep::canonicalize_basis_to_rep(&mut basis_words, group.core()); + } + orbit_rep::pc_step_orbit_rep( + &self.inner, + &mut basis_words, + &mut coeffs_vec, + dt, + &protected_words, + group.core(), + k_slice, + &ppvm_lindblad::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + num_threads: None, + }, + ) + .map_err(map_err)?; + + let m = basis_words.len(); + let mut out_basis = vec![0u8; m * n_q]; + for (i, w) in basis_words.iter().enumerate() { + codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); + } + let out_coeffs: Vec = coeffs_vec + .iter() + .map(|c| Complex64::new(c.re, c.im)) + .collect(); + let basis_arr = out_basis + .into_pyarray(py) + .reshape([m, n_q]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + Ok((basis_arr, out_coeffs.into_pyarray(py))) + } + /// Sparse generator matrix in COO form: `(rows, cols, vals)`. fn generator<'py>( &self, diff --git a/crates/ppvm-python-native/src/symmetry.rs b/crates/ppvm-python-native/src/symmetry.rs new file mode 100644 index 000000000..d3c69b6e9 --- /dev/null +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -0,0 +1,328 @@ +// 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, PyArrayMethods, PyReadonlyArray1, + PyReadonlyArray2, +}; +use ppvm_lindblad::{codes_from_word, word_from_codes}; +use ppvm_pauli_sum::symmetry as core_sym; +use pyo3::{exceptions::PyValueError, prelude::*}; + +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 + } +} + +#[pymethods] +impl TranslationGroup { + #[staticmethod] + pub fn chain_1d(n: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::chain_1d(n), + } + } + + #[staticmethod] + pub fn torus_2d(lx: usize, ly: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::torus_2d(lx, ly), + } + } + + #[staticmethod] + pub fn torus_3d(lx: usize, ly: usize, lz: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::torus_3d(lx, ly, lz), + } + } + + #[staticmethod] + pub fn ladder(l: usize, n_legs: usize) -> Self { + Self { + inner: core_sym::TranslationGroup::ladder(l, n_legs), + } + } + + #[staticmethod] + pub fn from_generators( + n_qubits: usize, + perms: Vec>, + orders: Vec, + ) -> PyResult { + if perms.len() != orders.len() { + return Err(PyValueError::new_err(format!( + "perms ({} generators) and orders ({}) must have the same length", + perms.len(), + orders.len() + ))); + } + for (g, perm) in perms.iter().enumerate() { + if perm.len() != n_qubits { + return Err(PyValueError::new_err(format!( + "generator {g}: permutation length {} != n_qubits {n_qubits}", + perm.len() + ))); + } + let mut seen = vec![false; n_qubits]; + for &p in perm { + let p = p as usize; + if p >= n_qubits { + return Err(PyValueError::new_err(format!( + "generator {g}: target {p} out of range [0, {n_qubits})" + ))); + } + if seen[p] { + return Err(PyValueError::new_err(format!( + "generator {g}: not a permutation (duplicate target {p})" + ))); + } + seen[p] = true; + } + } + Ok(Self { + inner: core_sym::TranslationGroup::from_generators(n_qubits, perms, orders), + }) + } + + /// 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 w = word_from_codes(codes).map_err(|e| PyValueError::new_err(e.to_string()))?; + let canon = self.inner.canonicalize(&w); + let mut out = vec![0u8; codes.len()]; + codes_from_word(&canon, &mut out); + 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/|G| normalization the complex merge applies. +/// +/// 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(); + if basis_view.shape().get(1).copied() != Some(n_q) { + return Err(PyValueError::new_err(format!( + "basis has {} qubits per row but group acts on {n_q}", + basis_view.shape().get(1).copied().unwrap_or(0) + ))); + } + let n = basis_view.shape()[0]; + let coeffs_slice = coeffs.as_slice()?; + if coeffs_slice.len() != n { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_slice.len(), + n + ))); + } + let k_slice = momentum.as_slice()?; + if k_slice.len() != group.inner.n_generators() { + return Err(PyValueError::new_err(format!( + "momentum has {} entries but group has {} generators", + k_slice.len(), + group.inner.n_generators() + ))); + } + let mut basis_words = crate::lindblad::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 m = basis_words.len(); + let mut out_basis = vec![0u8; m * n_q]; + for (i, w) in basis_words.iter().enumerate() { + codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); + } + let out_coeffs: Vec = + coeffs_vec.iter().map(|c| Complex64::new(c.re, c.im)).collect(); + let basis_arr = out_basis + .into_pyarray(py) + .reshape([m, n_q]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + 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(); + if basis_view.shape().get(1).copied() != Some(n_q) { + return Err(PyValueError::new_err(format!( + "basis has {} qubits per row but group acts on {n_q}", + basis_view.shape().get(1).copied().unwrap_or(0) + ))); + } + let coeffs_slice = coeffs.as_slice()?; + let k_slice = momentum.as_slice()?; + let basis_words = crate::lindblad::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(); + if basis_view.shape().get(1).copied() != Some(n_q) { + return Err(PyValueError::new_err(format!( + "basis has {} qubits per row but group acts on {n_q}", + basis_view.shape().get(1).copied().unwrap_or(0) + ))); + } + let n = basis_view.shape()[0]; + let coeffs_slice = coeffs.as_slice()?; + if coeffs_slice.len() != n { + return Err(PyValueError::new_err(format!( + "coeffs has length {} but basis has {} rows", + coeffs_slice.len(), + n + ))); + } + + let mut basis_words = crate::lindblad::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); + + // Re-encode. + let m = basis_words.len(); + let mut out_basis = vec![0u8; m * n_q]; + for (i, w) in basis_words.iter().enumerate() { + codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); + } + let basis_arr = out_basis + .into_pyarray(py) + .reshape([m, n_q]) + .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + Ok((basis_arr, coeffs_vec.into_pyarray(py))) +} diff --git a/ppvm-python/src/ppvm/lindblad.py b/ppvm-python/src/ppvm/lindblad.py index a31ed4e5f..82dfcd769 100644 --- a/ppvm-python/src/ppvm/lindblad.py +++ b/ppvm-python/src/ppvm/lindblad.py @@ -9,7 +9,8 @@ adaptive Heisenberg-picture evolution: - ``pc_step(...)`` / ``pc_step_arr(...)``: one adaptive predictor-corrector - step ``O ← exp(dt·L*) O`` + 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 @@ -290,6 +291,63 @@ def pc_step_arr( 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, + momentum: np.ndarray, + 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, + ) -> 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. + + 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). + """ + 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), + ) + def pc_step( self, basis: Sequence[str], diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index dca573d8d..b367a7244 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -386,6 +386,50 @@ def trace(self, pattern: str) -> float: """ return self._interface.trace(pattern) + def symmetry_merge(self, group) -> 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._core.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, momentum) -> 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. + ``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._core.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. diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py new file mode 100644 index 000000000..de3302ffa --- /dev/null +++ b/ppvm-python/test/test_momentum_merge.py @@ -0,0 +1,199 @@ +# 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 +from ppvm._core import 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 + + +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 From ece442e98e7aad9deb2a9d002e0bfd94d104217f Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Thu, 16 Jul 2026 13:35:12 +0100 Subject: [PATCH 02/15] style: cargo fmt + ruff format Co-Authored-By: Claude Fable 5 --- crates/ppvm-lindblad/src/orbit_rep.rs | 28 ++++++++-- crates/ppvm-python-native/src/interface.rs | 5 +- crates/ppvm-python-native/src/symmetry.rs | 9 ++-- ppvm-python/test/test_momentum_merge.py | 63 +++++++++++++--------- 4 files changed, 66 insertions(+), 39 deletions(-) diff --git a/crates/ppvm-lindblad/src/orbit_rep.rs b/crates/ppvm-lindblad/src/orbit_rep.rs index 7bf7a27c6..c72e3da48 100644 --- a/crates/ppvm-lindblad/src/orbit_rep.rs +++ b/crates/ppvm-lindblad/src/orbit_rep.rs @@ -159,7 +159,10 @@ pub(crate) fn expm_apply_orbit_rep_cached( let (m_star, s) = crate::expm::select_ms(dt.abs() * onenorm); let mut v = coeffs.to_vec(); - let op = CscOp { cols: &cols, dim: n }; + let op = CscOp { + cols: &cols, + dim: n, + }; let e = ExpmOp::from_parts( op, Complex::new(dt, 0.0), @@ -310,7 +313,13 @@ pub fn pc_step_orbit_rep( k_modes: &[i32], cfg: &crate::PcStepConfig, ) -> Result<(), Error> { - let crate::PcStepConfig { max_basis, admit_basis, drop_tol, tau_add, .. } = *cfg; + let crate::PcStepConfig { + max_basis, + admit_basis, + drop_tol, + tau_add, + .. + } = *cfg; // Admission bound, mirroring the real-space `pc_step`: enrichment may // grow the live basis to `admit` >= `max_basis`; the final // `cap_basis_complex` keeps the top-`max_basis` reps by evolved |coeff| @@ -329,8 +338,15 @@ pub fn pc_step_orbit_rep( // once via `build_orbit_rep_cols`). let coeffs_predict = expm_apply_orbit_rep_cached(spec, basis, group, k_modes, dt, coeffs); // 3. Second-hop leakage from predicted state. - let mut leak2 = - leakage_orbit_rep(spec, basis, &coeffs_predict, protected, group, k_modes, admit)?; + let mut leak2 = leakage_orbit_rep( + spec, + basis, + &coeffs_predict, + protected, + group, + k_modes, + admit, + )?; drop(coeffs_predict); if tau_add > 0.0 { leak2.retain(|(_, c)| c.norm() > tau_add); @@ -401,7 +417,9 @@ fn cap_basis_complex( return; } else { let k = slots - 1; - mags.select_nth_unstable_by(k, |a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + mags.select_nth_unstable_by(k, |a, b| { + b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) + }); mags[k] }; let mut write = 0; diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index ec690ae80..54813f130 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -78,10 +78,7 @@ macro_rules! create_interface_symmetry_methods { group.core().n_qubits(), ))); } - ppvm_pauli_sum::symmetry::symmetry_merge_pauli_sum( - &mut self.inner, - group.core(), - ); + ppvm_pauli_sum::symmetry::symmetry_merge_pauli_sum(&mut self.inner, group.core()); Ok(()) } diff --git a/crates/ppvm-python-native/src/symmetry.rs b/crates/ppvm-python-native/src/symmetry.rs index d3c69b6e9..880851468 100644 --- a/crates/ppvm-python-native/src/symmetry.rs +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -12,8 +12,7 @@ use num::Complex; use numpy::{ - Complex64, IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, - PyReadonlyArray2, + Complex64, IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, }; use ppvm_lindblad::{codes_from_word, word_from_codes}; use ppvm_pauli_sum::symmetry as core_sym; @@ -229,8 +228,10 @@ pub fn canonicalize_basis_arr_complex<'py>( for (i, w) in basis_words.iter().enumerate() { codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); } - let out_coeffs: Vec = - coeffs_vec.iter().map(|c| Complex64::new(c.re, c.im)).collect(); + let out_coeffs: Vec = coeffs_vec + .iter() + .map(|c| Complex64::new(c.re, c.im)) + .collect(); let basis_arr = out_basis .into_pyarray(py) .reshape([m, n_q]) diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py index de3302ffa..8566fe2f1 100644 --- a/ppvm-python/test/test_momentum_merge.py +++ b/ppvm-python/test/test_momentum_merge.py @@ -11,6 +11,7 @@ idempotency, and exact diagonalization of the dynamics — NOT against any other propagation scheme. """ + import cmath import math @@ -49,10 +50,12 @@ def _seed_pair(n, k): 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) + 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 @@ -79,10 +82,10 @@ def _ovl(sA, sB, oA, oB): 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, 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 + 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 @@ -92,8 +95,8 @@ 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 + 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 @@ -104,14 +107,14 @@ def test_momentum_merge_projects_out_other_sectors(): # ============================================================================= 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: + 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) + 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) @@ -133,12 +136,16 @@ def _ctrotter_autocorr(n, bonds, k, dt, steps): 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) + 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) @@ -162,8 +169,8 @@ def test_k_resolved_trotter_converges_to_exact(k): # 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 + 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(): @@ -181,16 +188,20 @@ def test_compressed_matches_uncompressed_evolution(): 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) + 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 + assert np.max(np.abs(comp - unc)) < 5e-3 # only O(dt^2) equivariance def _merged_copy(PA, PB, g, k): From d769a94dd77869ddacc5ff158538d9e426a790ac Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Thu, 16 Jul 2026 13:52:36 +0100 Subject: [PATCH 03/15] style: ruff RUF005 (unpack instead of concatenation) Co-Authored-By: Claude Fable 5 --- ppvm-python/test/test_momentum_merge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py index 8566fe2f1..0546deb11 100644 --- a/ppvm-python/test/test_momentum_merge.py +++ b/ppvm-python/test/test_momentum_merge.py @@ -200,7 +200,7 @@ def test_compressed_matches_uncompressed_evolution(): 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) + unc = np.array([1.0 + 0j, *unc]) assert np.max(np.abs(comp - unc)) < 5e-3 # only O(dt^2) equivariance From 4a58ab3ce5006191670ac171ffec5e74f731c71d Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Thu, 16 Jul 2026 13:57:00 +0100 Subject: [PATCH 04/15] types: TranslationGroup / merge / orbit-step stubs in _core.pyi Co-Authored-By: Claude Fable 5 --- ppvm-python/src/ppvm/_core.pyi | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index 4b2cfac7f..b0350fc55 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -62,6 +62,14 @@ class _PauliSumBase: def terms(self) -> list[tuple[str, float]]: ... def weights(self) -> list[tuple[str, int]]: ... def current_max_weight(self) -> int: ... + # Only on non-loss variants (see create_interface_symmetry_methods). + def symmetry_merge(self, group: TranslationGroup) -> None: ... + def momentum_merge( + self, + other: _PauliSumBase, + group: TranslationGroup, + momentum: list[int], + ) -> None: ... class _PauliSumLossBase(_PauliSumBase): def loss_channel(self, addr0: int, p: float, truncate: bool = True) -> None: ... @@ -392,4 +400,56 @@ class LindbladSpec: 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, + ) -> 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: ... From 070bc753b0df30f6290b2d62415013d7cbc13190 Mon Sep 17 00:00:00 2001 From: alexschuckert Date: Thu, 16 Jul 2026 16:13:01 +0100 Subject: [PATCH 05/15] style: type alias for the orbit-step return (clippy type_complexity) Co-Authored-By: Claude Fable 5 --- crates/ppvm-python-native/src/lindblad.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ppvm-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index 07d0e739f..7c7206201 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -20,6 +20,7 @@ use ppvm_lindblad::{JumpInput, LindbladSpec as CoreSpec, Word, codes_from_word, 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>, @@ -388,7 +389,7 @@ impl LindbladSpec { canonicalize_first: bool, admit_basis: Option, tau_add: Option, - ) -> PyResult<(Bound<'py, PyArray2>, Bound<'py, PyArray1>)> { + ) -> PyResult> { use num::Complex; use ppvm_lindblad::orbit_rep; From b0d1752e95dad3449f8962d83baec9d1e5a9b92a Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 09:40:40 +0200 Subject: [PATCH 06/15] Fix pre-commit failure --- crates/ppvm-lindblad/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ppvm-lindblad/src/lib.rs b/crates/ppvm-lindblad/src/lib.rs index e99bbb5d0..3af2397dd 100644 --- a/crates/ppvm-lindblad/src/lib.rs +++ b/crates/ppvm-lindblad/src/lib.rs @@ -58,4 +58,3 @@ pub use word::{MAX_QUBITS, Word, codes_from_word, parse_pauli_string, word_from_ #[cfg(test)] mod tests; - From 8d533275ec4eb275dfdb22746f955c6d5c31294e Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 11:34:12 +0200 Subject: [PATCH 07/15] refactor(ppvm-lindblad): dissolve orbit_rep.rs into the spec/basis/step layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `orbit_rep.rs` (463 lines) re-implemented one function from each of the crate's existing modules, so every function in it had a near-twin — three of the truncation helpers were verbatim copies with `.abs()` -> `.norm()`. Each function now sits beside its twin, on shared generic code: - new `sector.rs`: `Sector { group, k_modes }` bundles the two values that every phase-aware routine needs together, with one `canonicalize_phase` method replacing the canonicalize/character/multiply idiom at its three call sites. Carries the orbit-rep narrative docs. - new `truncate.rs`: one `prune_basis` / `cap_basis` / `add_leakage_capped` (plus `cap_map_to_room`, `order_by_desc_mag`) generic over the new `scalar::Coeff`, replacing five real/complex duplicate pairs. - `mf_expm.rs`: `build_orbit_rep_cols` + `expm_apply_orbit_rep` beside the real pair, both on a shared `expm_apply_cached` tail (mu, 1-norm, `from_parts`, `apply`). The paths now differ only in the `(m*, s, tol)` selection closure. - `basis.rs`: `leakage_orbit_rep` beside `leakage_complex`. - `step.rs`: `pc_step_orbit_rep` beside `pc_step`. The `Sector` bundle drops `leakage_orbit_rep` 7->6 args and `pc_step_orbit_rep` 8->7, so both `#[allow(clippy::too_many_arguments)]` suppressions are gone; the crate now has zero clippy allows. No behavior change: 348 insertions, 659 deletions. `cargo clippy --workspace --all-targets` is clean, `cargo test --workspace` and the 239 Python tests pass, including the orbit-rep vs full-basis exactness test. Co-Authored-By: Claude Opus 5 (1M context) --- crates/ppvm-lindblad/src/basis.rs | 117 ++++-- crates/ppvm-lindblad/src/lib.rs | 8 +- crates/ppvm-lindblad/src/mf_expm.rs | 206 +++++++--- crates/ppvm-lindblad/src/orbit_rep.rs | 463 ---------------------- crates/ppvm-lindblad/src/scalar.rs | 43 ++ crates/ppvm-lindblad/src/sector.rs | 75 ++++ crates/ppvm-lindblad/src/step.rs | 169 ++++---- crates/ppvm-lindblad/src/tests.rs | 7 +- crates/ppvm-lindblad/src/truncate.rs | 153 +++++++ crates/ppvm-python-native/src/lindblad.rs | 37 +- 10 files changed, 619 insertions(+), 659 deletions(-) delete mode 100644 crates/ppvm-lindblad/src/orbit_rep.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/truncate.rs diff --git a/crates/ppvm-lindblad/src/basis.rs b/crates/ppvm-lindblad/src/basis.rs index 9f352d972..941c67b6c 100644 --- a/crates/ppvm-lindblad/src/basis.rs +++ b/crates/ppvm-lindblad/src/basis.rs @@ -4,12 +4,18 @@ //! 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}; +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 { @@ -71,17 +77,7 @@ impl LindbladSpec { let protected_set: FxHashMap = protected.iter().map(|w| (word_hash(w), ())).collect(); - // Descending sort by |c|: process largest-magnitude contributors - // first so the running room-cap keeps the right entries. - let mut order: Vec = (0..basis.len()).collect(); - order.sort_by(|&a, &b| { - coeffs[b] - .abs() - .partial_cmp(&coeffs[a].abs()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - const CHUNK_SIZE: usize = 4096; + 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 = FxHashMap::default(); @@ -119,21 +115,7 @@ impl LindbladSpec { *merged.entry(k).or_insert(0.0) += val; } } - - // Room-cap: keep only the `room` largest-magnitude entries. - if merged.len() > room { - if room == 0 { - merged.clear(); - } else { - let mut mags: Vec = merged.values().map(|v| v.abs()).collect(); - let k = room.min(mags.len() - 1); - mags.select_nth_unstable_by(k, |a, b| { - b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) - }); - let cutoff = mags[k]; - merged.retain(|_, &mut v| v.abs() >= cutoff); - } - } + 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 @@ -211,7 +193,6 @@ impl LindbladSpec { let protected_set: FxHashMap = protected.iter().map(|w| (word_hash(w), ())).collect(); - const CHUNK_SIZE: usize = 4096; let n_qubits = self.n_qubits(); let mut merged: FxHashMap> = FxHashMap::default(); for chunk_start in (0..basis.len()).step_by(CHUNK_SIZE) { @@ -253,4 +234,84 @@ impl LindbladSpec { } 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)` via [`Sector::canonicalize_phase`]. + /// 2. If `r_q` NOT in `basis` and NOT in `protected`: + /// `merged[r_q] += χ_k · v_q · c_r`. + /// + /// 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)>, 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> = FxHashMap::default(); + for chunk_indices in order.chunks(CHUNK_SIZE) { + let local: Vec)>> = chunk_indices + .par_iter() + .map_init( + || { + ( + Vec::::with_capacity(n_qubits), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), &i| { + let r = &basis[i]; + let c_r = coeffs[i]; + 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 (r_q, phase) = sector.canonicalize_phase(q); + if !in_basis.contains(&r_q) && !protected_set.contains(&r_q) { + out.push((r_q, phase * *v * c_r)); + } + } + 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/lib.rs b/crates/ppvm-lindblad/src/lib.rs index 3af2397dd..57dff8aa2 100644 --- a/crates/ppvm-lindblad/src/lib.rs +++ b/crates/ppvm-lindblad/src/lib.rs @@ -38,20 +38,20 @@ mod basis; pub mod config; pub mod error; pub(crate) mod expm; +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; -/// Per-step orbit-rep evolution under translation symmetry, with a -/// phase-aware complex action. See module docs. -pub mod orbit_rep; - 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::{MAX_QUBITS, Word, codes_from_word, parse_pauli_string, word_from_codes}; diff --git a/crates/ppvm-lindblad/src/mf_expm.rs b/crates/ppvm-lindblad/src/mf_expm.rs index f2e5b73f3..491f686d5 100644 --- a/crates/ppvm-lindblad/src/mf_expm.rs +++ b/crates/ppvm-lindblad/src/mf_expm.rs @@ -1,12 +1,14 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 -//! Matrix-free `exp(dt · L*) · b` for the real (`f64`) path, driven by the -//! external `quspin-expm` crate. +//! 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`]) and reused, CSC-style, across every Krylov/Taylor matvec +//! [`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. @@ -16,17 +18,28 @@ //! `dot_transpose` are never invoked on the single-vector `apply` path; only //! [`LinearOperator::dot`] runs. //! -//! `μ`, the trace, and the exact column 1-norm of `A − μ·I` are computed in -//! the same single action pass as the cache. The `(m, s)` Taylor partition is +//! `μ`, 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}; + +/// CSC columns of a cached in-basis action: `cols[c]` = `(row, coeff)`. +type Cols = Vec>; +/// 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)>; /// 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. @@ -37,16 +50,11 @@ use rayon::prelude::*; /// 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. -/// CSC columns of the cached in-basis action: `cols[c]` = `(row, coeff)`. -type MfCols = Vec>; -/// Per-column `(raw, diag)` for the `μ`/1-norm selection. -type MfPerCol = Vec<(f64, f64)>; - fn build_mf_cols( spec: &LindbladSpec, basis: &[Word], index: &FxHashMap, -) -> (MfCols, MfPerCol) { +) -> (Cols, PerCol) { basis .par_iter() .map_init( @@ -80,6 +88,63 @@ fn build_mf_cols( .unzip() } +/// 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)` 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`]). +/// +/// 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, + sector: Sector<'_>, +) -> (Cols>, PerCol>) { + basis + .par_iter() + .enumerate() + .map_init( + || { + ( + Vec::::with_capacity(spec.n_qubits()), + Vec::::with_capacity(128), + FxHashMap::>::with_capacity_and_hasher( + 128, + FxBuildHasher::default(), + ), + ) + }, + |(s1, s2, lm), (c, r)| { + let terms = spec.compute_action_terms(r, s1, s2, lm); + let mut out = Vec::with_capacity(terms.len()); + let mut raw = 0.0; + let mut diag = Complex::new(0.0, 0.0); + for (q, v) in terms.iter() { + let (r_q, phase) = sector.canonicalize_phase(q); + if let Some(&row) = index.get(&r_q) { + let val = phase * *v; + raw += val.norm(); + if row as usize == c { + diag += val; + } + out.push((row, val)); + } + } + (out, (raw, diag)) + }, + ) + .unzip() +} + /// 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 @@ -214,14 +279,58 @@ where } } +/// 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: &Cols, + 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.len(); + 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, dim: n }; + 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 matrix-free pass extracts the diagonal shift `μ = tr(M)/n` and the -/// exact column 1-norm of `M − μ·I`; from `‖dt·(M−μI)‖₁` we pick the Taylor -/// partition `(m*, s)` and hand everything to -/// [`quspin_expm::ExpmOp::from_parts`]. +/// 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], @@ -229,26 +338,12 @@ pub(crate) fn expm_apply_mf( coeffs: &[f64], drop_tol: f64, ) -> Vec { - let n = basis.len(); - if n == 0 { + if basis.is_empty() { return Vec::new(); } - - // ONE action pass: build the CSC cache `cols` (reused across every matvec) - // and, in the same pass, `per_col = (raw, diag)` for the `μ`/1-norm - // selection. `raw = Σ|coeff|` (all outputs), `diag` = coeff of the - // output == input term. From these: `trace = Σ diag`, `μ = trace/n`, and - // the column 1-norm of `M − μ·I` is `raw − |diag| + |diag − μ|`. let index = build_basis_index(basis); let (cols, per_col) = build_mf_cols(spec, basis, &index); - let trace: f64 = per_col.iter().map(|(_, d)| *d).sum(); - let mu = trace / n as f64; - let onenorm = per_col - .iter() - .map(|(raw, diag)| raw - diag.abs() + (diag - mu).abs()) - .fold(0.0_f64, f64::max); - // 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 @@ -256,24 +351,41 @@ pub(crate) fn expm_apply_mf( // 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. - let t_norm = dt.abs() * onenorm; - let (m_star, s, expm_tol) = if drop_tol >= 1e-4 { - let (m, s) = expm::select_ms_loose(t_norm); - (m, s, 1e-6_f64) - } else { - let (m, s) = expm::select_ms(t_norm); - (m, s, 1e-12_f64) - }; + 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) + } + }) +} - let mut v = coeffs.to_vec(); - let op = CscOp { - cols: &cols, - dim: n, - }; - let expm = quspin_expm::ExpmOp::from_parts(op, 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 **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 diff --git a/crates/ppvm-lindblad/src/orbit_rep.rs b/crates/ppvm-lindblad/src/orbit_rep.rs deleted file mode 100644 index c72e3da48..000000000 --- a/crates/ppvm-lindblad/src/orbit_rep.rs +++ /dev/null @@ -1,463 +0,0 @@ -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Per-step orbit-representative evolution under translation symmetry. -//! -//! The state lives entirely in **orbit-rep form** throughout: `basis` -//! contains only canonical translation-orbit representatives, and -//! `coeffs` 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` (where `v` is the matrix -//! element of `L*` between input rep `r` and output `q`). -//! -//! The orbit-rep basis is ~|G|× smaller than the full-basis -//! representation, throughout the entire evolution. -//! -//! The phase-aware action is genuinely **complex** (because of the -//! `χ_k(g)` phase factors). Rather than materialise a sparse matrix, the -//! per-column action — for each input rep, the list of `(row, χ_k·v)` -//! pairs for the in-basis outputs — is computed **once per expm call** -//! (via [`build_orbit_rep_cols`]) and then reused, CSC-style, across -//! every Krylov–Taylor matvec driving the external `quspin-expm` engine. -//! -//! ## Limitations -//! -//! - Caller is 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. -//! - The momentum sector `k_modes` is fixed for the duration of one -//! pc_step call. To compute a full site-resolved profile, call -//! `pc_step_orbit_rep` once per momentum mode and inverse-Fourier -//! the results. - -use crate::mf_expm::CscOp; -use crate::{Error, LindbladSpec}; -use fxhash::{FxBuildHasher, FxHashMap}; -use num::Complex; -use ppvm_pauli_sum::symmetry::TranslationGroup; -use quspin_expm::ExpmOp; -use rayon::prelude::*; - -// Word type re-exported from lib.rs. -use crate::Word; - -/// 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 -/// [`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); - } -} - -/// Build the per-column phase-aware action of the in-basis-restricted -/// orbit-rep generator `M` at momentum sector `k_modes`. -/// -/// Returns, for each input rep `basis[c]` (column `c`), the list of -/// `(row, χ_k(g_{cnt_q}) · v_q)` pairs for every action output Pauli `q` -/// of `L*(basis[c])` whose orbit rep `r_q` is in `basis` at index `row`. -/// Outputs not in `basis` are dropped. This is the expensive part of the -/// orbit-rep dynamics (`compute_action_terms`, `canonicalize_with_shift`, -/// `character`); it is computed once and reused by the CSC-style matvec -/// in [`CscOp`]. -pub(crate) fn build_orbit_rep_cols( - spec: &LindbladSpec, - basis: &[Word], - index: &FxHashMap, - group: &TranslationGroup, - k_modes: &[i32], -) -> Vec)>> { - basis - .par_iter() - .map_init( - || { - ( - Vec::::with_capacity(spec.n_qubits()), - Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( - 128, - FxBuildHasher::default(), - ), - ) - }, - |(s1, s2, lm), r| { - let terms = spec.compute_action_terms(r, s1, s2, lm); - let mut out = Vec::with_capacity(terms.len()); - for (q, v) in terms.iter() { - let (r_q, cnt_q) = group.canonicalize_with_shift(q); - if let Some(&row) = index.get(&r_q) { - let phase = group.character(k_modes, &cnt_q); - out.push((row, phase * *v)); - } - } - out - }, - ) - .collect() -} - -/// Compute `exp(dt · M) · coeffs` for the in-basis-restricted orbit-rep -/// generator `M` at momentum sector `k_modes`, 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 (see [`CscOp`]). One pass over the cached columns -/// extracts the diagonal shift `μ = tr(M)/n` and a valid upper bound on -/// the column 1-norm of `M − μ·I`; from `‖dt·(M−μI)‖₁` we pick the Taylor -/// partition `(m*, s)` and hand everything to -/// [`quspin_expm::ExpmOp::from_parts`] (mirroring -/// [`crate::mf_expm::expm_apply_mf`]). -pub(crate) fn expm_apply_orbit_rep_cached( - spec: &LindbladSpec, - basis: &[Word], - group: &TranslationGroup, - k_modes: &[i32], - dt: f64, - coeffs: &[Complex], -) -> Vec> { - let n = basis.len(); - if n == 0 { - return Vec::new(); - } - - let index = crate::build_basis_index(basis); - let cols = build_orbit_rep_cols(spec, basis, &index, group, k_modes); - - // One pass for the per-column `(raw, diag)` used by the `μ`/1-norm - // selection: `raw = Σ|val|` (upper bound on the absolute column sum), - // `diag = M[c,c]`. From these: `trace = Σ diag`, `μ = trace/n`, and an - // upper bound on the column 1-norm of `M − μ·I`: `raw − |diag| + |diag − μ|`. - let per_col: Vec<(f64, Complex)> = cols - .par_iter() - .enumerate() - .map(|(c, col)| { - let mut raw = 0.0_f64; - let mut diag = Complex::new(0.0, 0.0); - for &(row, val) in col.iter() { - raw += val.norm(); - if row as usize == c { - diag += val; - } - } - (raw, diag) - }) - .collect(); - - let trace: Complex = per_col.iter().map(|(_, d)| *d).sum(); - let mu = trace / n as f64; - let onenorm = per_col - .iter() - .map(|(raw, diag)| raw - diag.norm() + (diag - mu).norm()) - .fold(0.0_f64, f64::max); - - let (m_star, s) = crate::expm::select_ms(dt.abs() * onenorm); - - let mut v = coeffs.to_vec(); - let op = CscOp { - cols: &cols, - dim: n, - }; - let e = ExpmOp::from_parts( - op, - Complex::new(dt, 0.0), - mu, - s as usize, - m_star as usize, - 1e-12_f64, - ); - e.apply(ndarray::ArrayViewMut1::from(v.as_mut_slice())) - .expect("expm apply (orbit-rep cached)"); - v -} - -/// 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 `k_modes`). -/// -/// 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, cnt_q)`. -/// 2. If `r_q` NOT in `basis` and NOT in `protected`: -/// `merged[r_q] += χ_k(g_{cnt_q}) · v_q · c_r`. -/// -/// Returns `(r_q, sum)` pairs for all candidates with nonzero sum. -/// -/// The live candidate map is capped to the *available room* -/// `room = max_basis − basis.len()` (the reps we could actually add), -/// applied during accumulation: input reps are processed in descending -/// `|c|` order and after each chunk only the `room` largest-`|sum|` -/// candidates are kept. A large `max_basis` (room ≥ all candidates) -/// disables the cap — the near-exact case. -#[allow(clippy::too_many_arguments)] -pub fn leakage_orbit_rep( - spec: &LindbladSpec, - basis: &[Word], - coeffs: &[Complex], - protected: &[Word], - group: &TranslationGroup, - k_modes: &[i32], - max_basis: usize, -) -> Result)>, Error> { - if basis.len() != coeffs.len() { - return Err(Error::LengthMismatch { - what: "basis and coeffs", - a: basis.len(), - b: coeffs.len(), - }); - } - let in_basis: FxHashMap<&Word, ()> = basis.iter().map(|w| (w, ())).collect(); - let protected_set: FxHashMap<&Word, ()> = protected.iter().map(|w| (w, ())).collect(); - - // Descending sort by |c|: process largest-magnitude contributors first - // so the running room-cap keeps the right entries. - let mut order: Vec = (0..basis.len()).collect(); - order.sort_by(|&a, &b| { - coeffs[b] - .norm() - .partial_cmp(&coeffs[a].norm()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - const CHUNK_SIZE: usize = 4096; - let room = max_basis.saturating_sub(basis.len()); - let mut merged: FxHashMap> = FxHashMap::default(); - for chunk_indices in order.chunks(CHUNK_SIZE) { - let local: Vec)>> = chunk_indices - .par_iter() - .map_init( - || { - ( - Vec::::with_capacity(spec.n_qubits()), - Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( - 128, - FxBuildHasher::default(), - ), - ) - }, - |(s1, s2, lm), &i| { - let r = &basis[i]; - let c_r = coeffs[i]; - let terms = spec.compute_action_terms(r, s1, s2, lm); - let mut out = Vec::with_capacity(terms.len()); - for (q, v) in terms.iter() { - let (r_q, cnt_q) = group.canonicalize_with_shift(q); - if !in_basis.contains_key(&r_q) && !protected_set.contains_key(&r_q) { - let phase = group.character(k_modes, &cnt_q); - out.push((r_q, phase * *v * c_r)); - } - } - out - }, - ) - .collect(); - for v in local { - for (k, val) in v { - *merged.entry(k).or_insert(Complex::new(0.0, 0.0)) += val; - } - } - - // Room-cap: keep only the `room` largest-magnitude entries. - if merged.len() > room { - if room == 0 { - merged.clear(); - } else { - let mut mags: Vec = merged.values().map(|v| v.norm()).collect(); - let k = room.min(mags.len() - 1); - mags.select_nth_unstable_by(k, |a, b| { - b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) - }); - let cutoff = mags[k]; - merged.retain(|_, &mut v| v.norm() >= cutoff); - } - } - } - Ok(merged.into_iter().filter(|(_, c)| c.norm() > 0.0).collect()) -} - -/// Per-step orbit-rep predictor-corrector evolution. -/// -/// All state lives in orbit-rep form throughout. Each pc step does: -/// 1. Phase-aware leakage from `(basis, coeffs)`; append the largest -/// leakage reps, up to the admission room. -/// 2. Predictor: cached-action expm ([`expm_apply_orbit_rep_cached`]). -/// 3. Phase-aware leakage from the predicted state; append further reps. -/// 4. Corrector: cached-action expm from the pre-step coefficients on -/// the doubly-enlarged basis. -/// 5. Prune `|c| < drop_tol`, then trim to the top-`max_basis` reps by -/// `|c|`; protected reps never dropped. -/// -/// `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, the -/// leakage map is capped to the same room, and the post-step basis is -/// trimmed to the top-`max_basis` by `|c|`. Pass a large value (e.g. -/// `usize::MAX`) for the near-exact, uncapped case. `drop_tol` additionally -/// prunes by magnitude. -/// -/// `basis` is assumed to contain only canonical orbit representatives. -/// If not, [`canonicalize_basis_to_rep`] should be called first. -#[allow(clippy::too_many_arguments)] -pub fn pc_step_orbit_rep( - spec: &LindbladSpec, - basis: &mut Vec, - coeffs: &mut Vec>, - dt: f64, - protected: &[Word], - group: &TranslationGroup, - k_modes: &[i32], - cfg: &crate::PcStepConfig, -) -> Result<(), Error> { - let crate::PcStepConfig { - max_basis, - admit_basis, - drop_tol, - tau_add, - .. - } = *cfg; - // Admission bound, mirroring the real-space `pc_step`: enrichment may - // grow the live basis to `admit` >= `max_basis`; the final - // `cap_basis_complex` 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 = leakage_orbit_rep(spec, basis, coeffs, protected, group, k_modes, admit)?; - if tau_add > 0.0 { - leak.retain(|(_, c)| c.norm() > tau_add); - } - add_leakage_capped_complex(basis, coeffs, leak, admit); - // 2. Predictor: cached-action expm (the phase-aware action is built - // once via `build_orbit_rep_cols`). - let coeffs_predict = expm_apply_orbit_rep_cached(spec, basis, group, k_modes, dt, coeffs); - // 3. Second-hop leakage from predicted state. - let mut leak2 = leakage_orbit_rep( - spec, - basis, - &coeffs_predict, - protected, - group, - k_modes, - admit, - )?; - drop(coeffs_predict); - if tau_add > 0.0 { - leak2.retain(|(_, c)| c.norm() > tau_add); - } - add_leakage_capped_complex(basis, coeffs, leak2, admit); - // 4. Corrector: cache-the-action expm from pre-step state (basis grew). - *coeffs = expm_apply_orbit_rep_cached(spec, basis, group, k_modes, dt, coeffs); - // 5. Prune by magnitude, then rank-cap to max_basis. - if drop_tol > 0.0 { - prune_basis_complex_local(basis, coeffs, drop_tol, protected); - } - cap_basis_complex(basis, coeffs, max_basis, protected); - Ok(()) -} - -/// Complex analogue of `crate::add_leakage_capped`: add the largest leakage -/// reps to the basis, up to the available room `room = max_basis − -/// basis.len()`, so the in-step orbit-rep basis never exceeds `max_basis`. -/// New reps get coefficient 0; the surrounding expm fills them. No -/// magnitude filter — the top-`room` by `|leakage|` are added. -fn add_leakage_capped_complex( - basis: &mut Vec, - coeffs: &mut Vec>, - mut leak: Vec<(Word, Complex)>, - 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| { - b.1.norm() - .partial_cmp(&a.1.norm()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - } - leak.truncate(room); - } - for (w, _) in leak { - basis.push(w); - coeffs.push(Complex::new(0.0, 0.0)); - } -} - -/// Complex analogue of `crate::cap_basis`: keep only the `max_basis` -/// largest-`|c|` reps (protected reps always kept), dropping the rest. -/// A `max_basis` large enough to cover the whole basis is a no-op. -fn cap_basis_complex( - basis: &mut Vec, - coeffs: &mut Vec>, - max_basis: usize, - protected: &[Word], -) { - if basis.len() <= max_basis { - return; - } - let protected_set: fxhash::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.norm()) - .collect(); - let cutoff = if slots == 0 { - f64::INFINITY - } else if slots >= mags.len() { - return; - } else { - let k = slots - 1; - mags.select_nth_unstable_by(k, |a, b| { - b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) - }); - mags[k] - }; - let mut write = 0; - for read in 0..basis.len() { - if protected_set.contains(&basis[read]) || coeffs[read].norm() >= cutoff { - if write != read { - basis.swap(write, read); - coeffs.swap(write, read); - } - write += 1; - } - } - basis.truncate(write); - coeffs.truncate(write); -} - -/// Complex analogue of `crate::prune_basis`: drop reps with `|c| < -/// drop_tol`, never dropping `protected` reps. No-op when `drop_tol <= 0`. -fn prune_basis_complex_local( - basis: &mut Vec, - coeffs: &mut Vec>, - drop_tol: f64, - protected: &[Word], -) { - if drop_tol <= 0.0 { - return; - } - let protected_set: fxhash::FxHashSet<&Word> = protected.iter().collect(); - let mut write = 0; - for read in 0..basis.len() { - if coeffs[read].norm() >= drop_tol || protected_set.contains(&basis[read]) { - if write != read { - basis.swap(write, read); - coeffs.swap(write, read); - } - write += 1; - } - } - basis.truncate(write); - coeffs.truncate(write); -} 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..ab588f7aa --- /dev/null +++ b/crates/ppvm-lindblad/src/sector.rs @@ -0,0 +1,75 @@ +// 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` +//! (where `v` is the matrix element of `L*` between input rep `r` and +//! output `q`). [`Sector::canonicalize_phase`] is that step. +//! +//! 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::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. +#[derive(Clone, Copy)] +pub struct Sector<'a> { + group: &'a TranslationGroup, + k_modes: &'a [i32], +} + +impl<'a> Sector<'a> { + pub fn new(group: &'a TranslationGroup, k_modes: &'a [i32]) -> Self { + Self { group, 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`. The phase weights the matrix + /// element of `L*` when it is accumulated onto `r_q`. + #[inline] + pub fn canonicalize_phase(&self, q: &Word) -> (Word, Complex) { + let (rep, counter) = self.group.canonicalize_with_shift(q); + let phase = self.group.character(self.k_modes, &counter); + (rep, phase) + } +} + +/// 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/step.rs b/crates/ppvm-lindblad/src/step.rs index fe77ce83f..d0e380a0e 100644 --- a/crates/ppvm-lindblad/src/step.rs +++ b/crates/ppvm-lindblad/src/step.rs @@ -3,10 +3,12 @@ //! 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 fxhash::FxHashSet; +use num::Complex; use std::time::Instant; /// Per-phase timing breakdown (microseconds) returned by @@ -48,99 +50,6 @@ impl Phase { } } -/// Compact `basis` / `coeffs` in place: drop entries whose absolute -/// coefficient is below `drop_tol` unless the word appears in `protected`. -/// No-op when `drop_tol ≤ 0`. -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(); - let mut write = 0; - for read in 0..basis.len() { - if coeffs[read].abs() >= drop_tol || protected_set.contains(&basis[read]) { - if write != read { - basis.swap(write, read); - coeffs.swap(write, read); - } - write += 1; - } - } - basis.truncate(write); - coeffs.truncate(write); -} - -/// Global max-basis cap (PauliStrings.jl-style top-M trim): keep only the -/// `max_basis` largest-|coeff| 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. -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.abs()) - .collect(); - let cutoff = if slots == 0 { - f64::INFINITY - } else if slots >= mags.len() { - return; - } else { - let k = slots - 1; - mags.select_nth_unstable_by(k, |a, b| { - b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) - }); - mags[k] - }; - let mut write = 0; - for read in 0..basis.len() { - if protected_set.contains(&basis[read]) || coeffs[read].abs() >= cutoff { - if write != read { - basis.swap(write, read); - coeffs.swap(write, read); - } - write += 1; - } - } - basis.truncate(write); - coeffs.truncate(write); -} - -/// 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). -fn add_leakage_capped( - basis: &mut Vec, - coeffs: &mut Vec, - mut leak: Vec<(Word, f64)>, - 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| { - b.1.abs() - .partial_cmp(&a.1.abs()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - } - leak.truncate(room); - } - for (w, _) in leak { - basis.push(w); - coeffs.push(0.0); - } -} - impl LindbladSpec { /// One predictor-corrector step `O ← exp(dt·L*) O` in the adaptive /// real-coefficient Pauli basis: first-hop leakage admission, predictor @@ -269,4 +178,76 @@ impl LindbladSpec { 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. + pub fn pc_step_orbit_rep( + &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. + let mut leak2 = self.leakage_orbit_rep(basis, &coeffs_predict, protected, sector, admit)?; + drop(coeffs_predict); + 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 (the basis grew). + *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 index 3ad4899d5..5687c0b31 100644 --- a/crates/ppvm-lindblad/src/tests.rs +++ b/crates/ppvm-lindblad/src/tests.rs @@ -155,15 +155,14 @@ fn pc_step_orbit_rep_matches_full_basis_projection() { let mut cr = coeffs_full.clone(); canonicalize_pauli_sum_complex(&mut br, &mut cr, &group, &k); // Evolve in orbit-rep form (max_basis large ⇒ full enrichment). + let sector = Sector::new(&group, &k); for _ in 0..n_steps { - orbit_rep::pc_step_orbit_rep( - &spec, + spec.pc_step_orbit_rep( &mut br, &mut cr, dt, &protected, - &group, - &k, + sector, &PcStepConfig { max_basis: 10_000_000, ..Default::default() diff --git a/crates/ppvm-lindblad/src/truncate.rs b/crates/ppvm-lindblad/src/truncate.rs new file mode 100644 index 000000000..c96a927fb --- /dev/null +++ b/crates/ppvm-lindblad/src/truncate.rs @@ -0,0 +1,153 @@ +// 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, 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-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index 7c7206201..88799a321 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -391,7 +391,7 @@ impl LindbladSpec { tau_add: Option, ) -> PyResult> { use num::Complex; - use ppvm_lindblad::orbit_rep; + use ppvm_lindblad::{Sector, canonicalize_basis_to_rep}; let n_q = self.inner.n_qubits(); let basis_view = basis.as_array(); @@ -422,25 +422,24 @@ impl LindbladSpec { ))); } if canonicalize_first { - orbit_rep::canonicalize_basis_to_rep(&mut basis_words, group.core()); + canonicalize_basis_to_rep(&mut basis_words, group.core()); } - orbit_rep::pc_step_orbit_rep( - &self.inner, - &mut basis_words, - &mut coeffs_vec, - dt, - &protected_words, - group.core(), - k_slice, - &ppvm_lindblad::PcStepConfig { - max_basis, - admit_basis, - drop_tol, - tau_add, - num_threads: None, - }, - ) - .map_err(map_err)?; + self.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: None, + }, + ) + .map_err(map_err)?; let m = basis_words.len(); let mut out_basis = vec![0u8; m * n_q]; From 4fa2dbd09afaff1861504af5614019ca29ab26da Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 11:52:38 +0200 Subject: [PATCH 08/15] refactor(pauli-sum): lift momentum_merge out of the PyO3 macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PauliSum.momentum_merge` carried ~90 lines of numerics inside a `macro_rules!` body: gather two real sums into a `HashMap`, fold onto orbit reps, rescale, split back into real/imaginary parts. That made it untestable from Rust (only reachable through Python) and expanded it once per `Config` variant. The algorithm now lives in `ppvm_pauli_sum::symmetry` as `momentum_merge_pauli_sum_pair`, beside `canonicalize_pauli_sum_complex` which it reuses, and parallel to `symmetry_merge_pauli_sum`. The PyO3 method keeps only its qubit-count / momentum-length validation (so the Python error messages are unchanged) and one call. New Rust tests pin the two things the wrapper used to hide: - `momentum_merge_pair_matches_symmetry_merge_at_k0`: on free orbits the k=0 pair merge agrees with the independent real-coefficient `symmetry_merge_pauli_sum` path — this is what pins the `|G|` rescale. - `momentum_merge_pair_matches_summing_projector`: for every k on a 4-chain, the rep coefficient equals `Σ_orbit χ_k(g) · c`, computed from the group API. - two `should_panic` tests for the qubit-count and momentum-length asserts. No behavior change. `cargo clippy --workspace --all-targets` is clean and the Python suite still passes. Co-Authored-By: Claude Opus 5 (1M context) --- crates/ppvm-pauli-sum/src/symmetry/mod.rs | 5 +- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 89 ++++++++++- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 147 ++++++++++++++++++ crates/ppvm-python-native/src/interface.rs | 54 +------ 4 files changed, 245 insertions(+), 50 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/mod.rs b/crates/ppvm-pauli-sum/src/symmetry/mod.rs index 30d6d4c01..99ada0665 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/mod.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/mod.rs @@ -68,7 +68,10 @@ 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}; +pub use momentum::{ + 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..952d459a2 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; @@ -143,6 +144,92 @@ pub fn canonicalize_pauli_sum_complex( } } +/// 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`]. +/// +/// [`canonicalize_pauli_sum_complex`] carries a `1/|orbit|` prefactor +/// (it *averages* over orbit members); we rescale by `group.order()` so +/// that the result is the *summing* projector, matching +/// [`super::symmetry_merge_pauli_sum`]. +/// +/// Entries whose rescaled 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 mut basis = Vec::with_capacity(combined.len()); + let mut coeffs = Vec::with_capacity(combined.len()); + for (word, coeff) in combined { + basis.push(word); + coeffs.push(coeff); + } + + canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, group, k_modes); + + let scale = group.order() as f64; + re.data_mut().clear(); + im.data_mut().clear(); + for (word, coeff) in basis.into_iter().zip(coeffs) { + if coeff.re != 0.0 { + *re += (word, coeff.re * scale); + } + if coeff.im != 0.0 { + *im += (word, coeff.im * scale); + } + } +} + /// Verify that a `(basis, complex_coeffs)` Pauli sum lies entirely in /// the momentum sector `k_modes` under `group`. /// diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 64c1deec0..ee9981e60 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::sum::PauliSum; use fxhash::FxHashMap; use num::Complex; use ppvm_pauli_word::word::PauliWord; @@ -452,6 +453,152 @@ 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` on free orbits, the phase-aware pair merge must agree +/// with the independent real-coefficient `symmetry_merge_pauli_sum` +/// code path — this is what pins the `|G|` rescale (the momentum +/// projector averages; `symmetry_merge` sums). +#[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); + + // Σ_j Z_j and Σ_j X_j X_{j+1}: both free orbits (|orbit| = |G|). + 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(); + 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); + } + } + // `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() { diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 54813f130..e84c29fe5 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -105,15 +105,15 @@ macro_rules! create_interface_symmetry_methods { group: &crate::symmetry::TranslationGroup, momentum: Vec, ) -> pyo3::PyResult<()> { - let n_g = group.core().n_qubits(); + let n_q = group.core().n_qubits(); for (label, n) in [ ("self", self.inner.n_qubits()), ("other", other.inner.n_qubits()), ] { - if n != n_g { + if n != n_q { return Err(pyo3::exceptions::PyValueError::new_err(format!( "{label} PauliSum has {n} qubits but the \ - TranslationGroup acts on {n_g}", + TranslationGroup acts on {n_q}", ))); } } @@ -124,54 +124,12 @@ macro_rules! create_interface_symmetry_methods { group.core().n_generators(), ))); } - // Gather both real components into word -> (re + i·im). - let mut combined: std::collections::HashMap< - <$type as Config>::PauliWordType, - num::Complex, - > = std::collections::HashMap::new(); - for (w, v) in self.inner.data().iter() { - combined - .entry(w.clone()) - .or_insert(num::Complex::new(0.0, 0.0)) - .re += *v; - } - for (w, v) in other.inner.data().iter() { - combined - .entry(w.clone()) - .or_insert(num::Complex::new(0.0, 0.0)) - .im += *v; - } - let mut basis = Vec::with_capacity(combined.len()); - let mut coeffs = Vec::with_capacity(combined.len()); - for (w, c) in combined { - basis.push(w); - coeffs.push(c); - } - // Character-weighted fold onto orbit reps. - // `canonicalize_pauli_sum_complex` carries a 1/|G| prefactor; - // we rescale by |G| so the merge is the *summing* projector - // (like `symmetry_merge`): idempotent on already-merged input, - // hence stable under merging after every Trotter step. - ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex( - &mut basis, - &mut coeffs, + ppvm_pauli_sum::symmetry::momentum_merge_pauli_sum_pair( + &mut self.inner, + &mut other.inner, group.core(), &momentum, ); - let scale = group.core().order() as f64; - // Write the real/imag parts back into the two sums. - self.inner.data_mut().clear(); - other.inner.data_mut().clear(); - for (w, c) in basis.into_iter().zip(coeffs.into_iter()) { - let re = c.re * scale; - let im = c.im * scale; - if re != 0.0 { - self.inner += (w.clone(), re); - } - if im != 0.0 { - other.inner += (w, im); - } - } Ok(()) } } From 4841ffc9ac5d1943e53e0ca1305fe0148234225d Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 11:53:48 +0200 Subject: [PATCH 09/15] test: cover the untested Python symmetry surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five public Python entry points shipped with no Python test at all; `momentum_merge` was the only one covered. Adds: - `test_symmetry_merge.py` — `TranslationGroup` constructors, properties, `from_generators` validation, `canonicalize`, and `PauliSum.symmetry_merge` (orbit summing, distinct orbits, idempotency, coefficient-sum conservation, qubit-count mismatch). - `test_symmetry_arrays.py` — the three `_core` array functions (`canonicalize_basis_arr`, `canonicalize_basis_arr_complex`, `check_momentum_sector_arr`) against numpy references derived from the group action, plus every validation path. `check_momentum_sector_arr` was previously exported and referenced from docstrings but never exercised. - `test/lindblad/test_pc_step_orbit_rep.py` — the `pc_step_orbit_rep` binding: orbit-rep evolution vs a dense full-space numpy matrix exponential projected at the end (k = 0, 1, 2), `canonicalize_first`, the `max_basis` rank cap, `protected` reps, input validation, and returned dtypes/shapes. Also adds an xfail (strict) documenting a real bug found while writing these: `momentum_merge` rescales by `|G|`, but the projector averages over the `|orbit|` DISTINCT members, so an orbit with a non-trivial stabilizer is amplified by `|G|/|orbit|` on every merge — `ZZZZ` on a 4-chain grows 4x per merge, `ZIZI` 2x. The existing idempotency test only seeds free orbits, where the two factors coincide, which is why it passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/lindblad/test_pc_step_orbit_rep.py | 222 ++++++++++++++++++ ppvm-python/test/test_momentum_merge.py | 26 ++ ppvm-python/test/test_symmetry_arrays.py | 218 +++++++++++++++++ ppvm-python/test/test_symmetry_merge.py | 142 +++++++++++ 4 files changed, 608 insertions(+) create mode 100644 ppvm-python/test/lindblad/test_pc_step_orbit_rep.py create mode 100644 ppvm-python/test/test_symmetry_arrays.py create mode 100644 ppvm-python/test/test_symmetry_merge.py 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..fdb234cdb --- /dev/null +++ b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py @@ -0,0 +1,222 @@ +# 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 +from ppvm._core import 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): + return np.array(modes, dtype=np.int32) + + +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_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} + + +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)) + + +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/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py index 0546deb11..6dccfc84d 100644 --- a/ppvm-python/test/test_momentum_merge.py +++ b/ppvm-python/test/test_momentum_merge.py @@ -91,6 +91,32 @@ def test_momentum_merge_idempotent(k): assert max(abs(once.get(x, 0j) - twice.get(x, 0j)) for x in keys) < 1e-12 +@pytest.mark.xfail( + reason="momentum_merge rescales by |G| but the projector averages over the " + "|orbit| DISTINCT members, so orbits with a non-trivial stabilizer are " + "amplified by |G|/|orbit| on every merge. `_seed_pair` only produces free " + "orbits, which is why test_momentum_merge_idempotent passes.", + strict=True, +) +@pytest.mark.parametrize("word, orbit_size", [("ZZZZ", 1), ("ZIZI", 2)]) +def test_momentum_merge_idempotent_on_stabilized_orbit(word, orbit_size): + """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 they pick up factors of 4 and 2 per merge. + """ + 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) + 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_projects_out_other_sectors(): """Merging a pure sector-k operator in sector k' != k gives ~zero.""" n = 4 diff --git a/ppvm-python/test/test_symmetry_arrays.py b/ppvm-python/test/test_symmetry_arrays.py new file mode 100644 index 000000000..11ec2aa59 --- /dev/null +++ b/ppvm-python/test/test_symmetry_arrays.py @@ -0,0 +1,218 @@ +# 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``: + +- ``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._core 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 ``_core`` free functions take momentum as an int32 array; numpy's + default integer dtype is int64, which they reject.""" + return np.array(modes, dtype=np.int32) + + +def z_strings(n): + return ["I" * j + "Z" + "I" * (n - j - 1) for j in range(n)] + + +# ── 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..d151cc168 --- /dev/null +++ b/ppvm-python/test/test_symmetry_merge.py @@ -0,0 +1,142 @@ +# 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 PauliSum +from ppvm._core import 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"), + ], +) +def test_from_generators_validates(perms, orders, message): + with pytest.raises(ValueError, match=message): + TranslationGroup.from_generators(4, perms, orders) + + +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)) From 54f79beb7fb870467d0369e4fb37e2988600cf91 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 13:52:13 +0200 Subject: [PATCH 10/15] fix(pauli-sum): make momentum_merge the summing projector on every orbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `momentum_merge` rescaled the orbit-*averaged* projection by a global `group.order()`, but `canonicalize_pauli_sum_complex` divides by the number of DISTINCT orbit members. The two agree only for free orbits, so any orbit with a non-trivial stabilizer was amplified by `|G|/|orbit|` on every merge. Since the documented workflow is "merge after every Trotter step", the error compounded geometrically — on a 4-site chain `ZZZZ` grew 4x per merge and `ZIZI` 2x, so `4^steps` for a translation-invariant word: ZIII (|orbit|=4): 1.0 -> 1.0 -> 1.0 (was already correct) ZIZI (|orbit|=2): 1.0 -> 2.0 -> 4.0 now 1.0 -> 1.0 -> 1.0 ZZZZ (|orbit|=1): 1.0 -> 4.0 -> 16.0 now 1.0 -> 1.0 -> 1.0 The character-weighted fold moves into a shared `project_onto_reps`, which returns the un-normalized sum together with `|orbit|`. The two conventions are now explicit at the two call sites: `canonicalize_pauli_sum_complex` divides to average (unchanged behavior and signature), `momentum_merge_pauli_sum_pair` takes the sum as-is. Summing is what makes the merge idempotent for every orbit and makes it reduce exactly to `symmetry_merge_pauli_sum` at k=0 — which the strengthened `momentum_merge_pair_matches_symmetry_merge_at_k0` now asserts over stabilized orbits too, not just free ones. The strict xfail added in 4841ffc9 becomes a plain passing test, and every pre-existing test still passes untouched — including the exact-diagonalization Trotter checks in test_momentum_merge.py, which only ever exercised free orbits. Also corrects the docs that described the old `1/|G|` relationship. Co-Authored-By: Claude Opus 5 (1M context) --- crates/ppvm-pauli-sum/src/symmetry/mod.rs | 20 +++-- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 90 ++++++++++++------- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 17 ++-- crates/ppvm-python-native/src/interface.rs | 5 +- crates/ppvm-python-native/src/symmetry.rs | 5 +- ppvm-python/src/ppvm/paulisum.py | 7 +- ppvm-python/test/test_momentum_merge.py | 21 +++-- 7 files changed, 104 insertions(+), 61 deletions(-) diff --git a/crates/ppvm-pauli-sum/src/symmetry/mod.rs b/crates/ppvm-pauli-sum/src/symmetry/mod.rs index 99ada0665..22fdbb291 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 //! diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index 952d459a2..19563f27e 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -100,6 +100,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(); @@ -123,25 +160,18 @@ 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); - } - basis.clear(); - coeffs.clear(); - basis.reserve(projected.len()); - coeffs.reserve(projected.len()); - for (w, c) in projected { - basis.push(w); - coeffs.push(c); + projected.insert(rep, (sum, orbit_size)); } + projected } /// Momentum-sector merge of a complex operator carried as a **real @@ -156,13 +186,16 @@ pub fn canonicalize_pauli_sum_complex( /// arithmetic is the internal character-weighted fold, which reuses /// [`canonicalize_pauli_sum_complex`]. /// -/// [`canonicalize_pauli_sum_complex`] carries a `1/|orbit|` prefactor -/// (it *averages* over orbit members); we rescale by `group.order()` so -/// that the result is the *summing* projector, matching -/// [`super::symmetry_merge_pauli_sum`]. +/// 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 rescaled component is exactly zero are dropped, so a -/// purely real operator leaves `im` empty. +/// Entries whose component is exactly zero are dropped, so a purely real +/// operator leaves `im` empty. /// /// # Panics /// @@ -208,24 +241,15 @@ pub fn momentum_merge_pauli_sum_pair( for (word, coeff) in im.data().iter() { combined.entry(*word).or_insert(Complex::new(0.0, 0.0)).im += *coeff; } - let mut basis = Vec::with_capacity(combined.len()); - let mut coeffs = Vec::with_capacity(combined.len()); - for (word, coeff) in combined { - basis.push(word); - coeffs.push(coeff); - } - - canonicalize_pauli_sum_complex(&mut basis, &mut coeffs, group, k_modes); - - let scale = group.order() as f64; + let projected = project_onto_reps(&combined, group, k_modes); re.data_mut().clear(); im.data_mut().clear(); - for (word, coeff) in basis.into_iter().zip(coeffs) { - if coeff.re != 0.0 { - *re += (word, coeff.re * scale); + for (word, (sum, _orbit_size)) in projected { + if sum.re != 0.0 { + *re += (word, sum.re); } - if coeff.im != 0.0 { - *im += (word, coeff.im * scale); + 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 ee9981e60..8af9a01bf 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -473,10 +473,11 @@ where (re, im) } -/// At `k = 0` on free orbits, the phase-aware pair merge must agree -/// with the independent real-coefficient `symmetry_merge_pauli_sum` -/// code path — this is what pins the `|G|` rescale (the momentum -/// projector averages; `symmetry_merge` sums). +/// 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; @@ -486,10 +487,10 @@ fn momentum_merge_pair_matches_symmetry_merge_at_k0() { let n = 4usize; let group = TranslationGroup::chain_1d(n); - // Σ_j Z_j and Σ_j X_j X_{j+1}: both free orbits (|orbit| = |G|). 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'; @@ -502,6 +503,12 @@ fn momentum_merge_pair_matches_symmetry_merge_at_k0() { 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); diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index e84c29fe5..7949b9b24 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -91,8 +91,9 @@ macro_rules! create_interface_symmetry_methods { /// 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, reusing the tested - /// `canonicalize_pauli_sum_complex`. + /// 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 diff --git a/crates/ppvm-python-native/src/symmetry.rs b/crates/ppvm-python-native/src/symmetry.rs index 880851468..bd03a1b72 100644 --- a/crates/ppvm-python-native/src/symmetry.rs +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -171,8 +171,9 @@ impl TranslationGroup { /// `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/|G| normalization the complex merge applies. +/// 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 diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index b367a7244..3504cd712 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -416,7 +416,12 @@ def momentum_merge(self, other: "PauliSum", group, momentum) -> None: 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. + 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 diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py index 6dccfc84d..8e5fb953f 100644 --- a/ppvm-python/test/test_momentum_merge.py +++ b/ppvm-python/test/test_momentum_merge.py @@ -91,19 +91,16 @@ def test_momentum_merge_idempotent(k): assert max(abs(once.get(x, 0j) - twice.get(x, 0j)) for x in keys) < 1e-12 -@pytest.mark.xfail( - reason="momentum_merge rescales by |G| but the projector averages over the " - "|orbit| DISTINCT members, so orbits with a non-trivial stabilizer are " - "amplified by |G|/|orbit| on every merge. `_seed_pair` only produces free " - "orbits, which is why test_momentum_merge_idempotent passes.", - strict=True, -) -@pytest.mark.parametrize("word, orbit_size", [("ZZZZ", 1), ("ZIZI", 2)]) -def test_momentum_merge_idempotent_on_stabilized_orbit(word, orbit_size): +@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 they pick up factors of 4 and 2 per merge. + ``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) @@ -111,6 +108,8 @@ def test_momentum_merge_idempotent_on_stabilized_orbit(word, orbit_size): 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) From 74327041901854f348a2c8ce4e609147e6af34b2 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 14:14:27 +0200 Subject: [PATCH 11/15] refactor(python-native): share the Pauli-array codec; wrap the symmetry API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related pieces of boilerplate cleanup on the `(basis_arr, coeffs)` array surface. Rust: the validate-decode-encode triple was written out three times in `symmetry.rs` and again in `lindblad.rs::pc_step_orbit_rep`, and `symmetry.rs` reached across into `crate::lindblad::decode_basis` for the decode half. All of it now lives in one `pauli_arr` module — `decode_basis` (moved), `encode_basis`, and the three argument checks (`check_group_width`, `check_coeffs_len`, `check_momentum_len`). Error messages are unchanged, so the existing tests that match on them still pass. Net -155 lines across `lindblad.rs` and `symmetry.rs`. One incidental improvement: `check_momentum_sector_arr` never validated its `coeffs` / `momentum` lengths, so a mismatch reached the core's `assert_eq!` and surfaced as a PanicException. It now raises ValueError like its siblings. Python: the three `_core` symmetry functions had no wrapper, so they demanded exact dtypes and rejected the natural `np.array([1])` momentum (numpy's default int64) with `TypeError: 'ndarray' object is not an instance of 'ndarray'`. A new `ppvm.symmetry` module wraps them with the same dtype coercion `lindblad.py` already applies, and re-exports `TranslationGroup` — which previously had to be imported from the private `ppvm._core` despite the docstrings pointing users at it. `ppvm` now exports `TranslationGroup`, `canonicalize_basis_arr`, `canonicalize_basis_arr_complex` and `check_momentum_sector_arr`; the docstrings that said `ppvm._core.TranslationGroup` are updated, and griffe picks the module up for the docs site automatically. 285 Python tests pass (one new, for the coercion), `cargo test --workspace` is green, clippy/ruff/ty are clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/ppvm-python-native/src/interface.rs | 2 +- crates/ppvm-python-native/src/lib.rs | 1 + crates/ppvm-python-native/src/lindblad.rs | 71 +------- crates/ppvm-python-native/src/pauli_arr.rs | 94 +++++++++++ crates/ppvm-python-native/src/symmetry.rs | 84 ++-------- ppvm-python/src/ppvm/__init__.py | 4 + ppvm-python/src/ppvm/paulisum.py | 4 +- ppvm-python/src/ppvm/symmetry.py | 158 ++++++++++++++++++ .../test/lindblad/test_pc_step_orbit_rep.py | 7 +- ppvm-python/test/test_momentum_merge.py | 3 +- ppvm-python/test/test_symmetry_arrays.py | 34 +++- ppvm-python/test/test_symmetry_merge.py | 3 +- 12 files changed, 322 insertions(+), 143 deletions(-) create mode 100644 crates/ppvm-python-native/src/pauli_arr.rs create mode 100644 ppvm-python/src/ppvm/symmetry.py diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 7949b9b24..20450252b 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -62,7 +62,7 @@ macro_rules! create_interface_symmetry_methods { /// entry count by up to `|group|×` for translation-invariant /// operators. /// - /// See `ppvm._core.TranslationGroup` for constructors + /// See `ppvm.TranslationGroup` for constructors /// (`chain_1d`, `torus_2d`, `torus_3d`, `ladder`). /// /// Plain real-coefficient merge (the `k=0` symmetry sector). diff --git a/crates/ppvm-python-native/src/lib.rs b/crates/ppvm-python-native/src/lib.rs index efb190104..e4f9228c8 100644 --- a/crates/ppvm-python-native/src/lib.rs +++ b/crates/ppvm-python-native/src/lib.rs @@ -15,6 +15,7 @@ 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; diff --git a/crates/ppvm-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index 88799a321..e56b78351 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -13,10 +13,8 @@ use std::collections::HashMap; use num::Complex; -use numpy::{ - Complex64, IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, -}; -use ppvm_lindblad::{JumpInput, LindbladSpec as CoreSpec, Word, codes_from_word, word_from_codes}; +use numpy::{Complex64, IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use ppvm_lindblad::{JumpInput, LindbladSpec as CoreSpec, Word, word_from_codes}; use pyo3::{exceptions::PyValueError, prelude::*}; type PyPauliMap<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); @@ -27,7 +25,7 @@ type PyCoo<'py> = ( Bound<'py, PyArray1>, ); -fn map_err(e: ppvm_lindblad::Error) -> PyErr { +pub(crate) fn map_err(e: ppvm_lindblad::Error) -> PyErr { PyValueError::new_err(e.to_string()) } @@ -46,29 +44,7 @@ fn assert_basis_unique(basis: &[Word]) -> PyResult<()> { Ok(()) } -/// 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) -} +use crate::pauli_arr::{check_coeffs_len, check_momentum_len, decode_basis, encode_basis}; /// Pack `Vec<(Word, f64)>` into the standard PyO3 return shape. fn pack_pauli_map<'py>( @@ -76,17 +52,8 @@ fn pack_pauli_map<'py>( pairs: Vec<(Word, f64)>, n_qubits: usize, ) -> PyResult> { - let m = pairs.len(); - let mut basis = vec![0u8; m * n_qubits]; - let mut coeffs = vec![0f64; m]; - for (i, (w, c)) in pairs.into_iter().enumerate() { - codes_from_word(&w, &mut basis[i * n_qubits..(i + 1) * n_qubits]); - coeffs[i] = c; - } - let basis_arr = basis - .into_pyarray(py) - .reshape([m, n_qubits]) - .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + 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))) } @@ -178,13 +145,7 @@ impl LindbladSpec { let basis_view = basis.as_array(); let basis_words = decode_basis(&basis_view, n_q)?; let coeffs_slice = coeffs.as_slice()?; - if coeffs_slice.len() != basis_words.len() { - return Err(PyValueError::new_err(format!( - "coeffs has length {} but basis has {} rows", - coeffs_slice.len(), - basis_words.len() - ))); - } + 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)? @@ -414,13 +375,7 @@ impl LindbladSpec { Vec::new() }; let k_slice = momentum.as_slice()?; - if k_slice.len() != group.core().n_generators() { - return Err(PyValueError::new_err(format!( - "momentum has {} entries but group has {} generators", - k_slice.len(), - group.core().n_generators() - ))); - } + check_momentum_len(k_slice.len(), group.core().n_generators())?; if canonicalize_first { canonicalize_basis_to_rep(&mut basis_words, group.core()); } @@ -441,19 +396,11 @@ impl LindbladSpec { ) .map_err(map_err)?; - let m = basis_words.len(); - let mut out_basis = vec![0u8; m * n_q]; - for (i, w) in basis_words.iter().enumerate() { - codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); - } let out_coeffs: Vec = coeffs_vec .iter() .map(|c| Complex64::new(c.re, c.im)) .collect(); - let basis_arr = out_basis - .into_pyarray(py) - .reshape([m, n_q]) - .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + let basis_arr = encode_basis(py, &basis_words, n_q)?; Ok((basis_arr, out_coeffs.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..a90402cc4 --- /dev/null +++ b/crates/ppvm-python-native/src/pauli_arr.rs @@ -0,0 +1,94 @@ +// 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; + +/// 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>( + 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 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 index bd03a1b72..6c060ec51 100644 --- a/crates/ppvm-python-native/src/symmetry.rs +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -11,13 +11,15 @@ //! used by `Lindbladian.pc_step_arr`. use num::Complex; -use numpy::{ - Complex64, IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2, -}; +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, +}; + type PyPauliMap<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); type PyPauliMapComplex<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); @@ -188,30 +190,12 @@ pub fn canonicalize_basis_arr_complex<'py>( ) -> PyResult> { let basis_view = basis.as_array(); let n_q = group.inner.n_qubits(); - if basis_view.shape().get(1).copied() != Some(n_q) { - return Err(PyValueError::new_err(format!( - "basis has {} qubits per row but group acts on {n_q}", - basis_view.shape().get(1).copied().unwrap_or(0) - ))); - } - let n = basis_view.shape()[0]; + check_group_width(&basis_view, n_q)?; let coeffs_slice = coeffs.as_slice()?; - if coeffs_slice.len() != n { - return Err(PyValueError::new_err(format!( - "coeffs has length {} but basis has {} rows", - coeffs_slice.len(), - n - ))); - } + check_coeffs_len(coeffs_slice.len(), basis_view.shape()[0])?; let k_slice = momentum.as_slice()?; - if k_slice.len() != group.inner.n_generators() { - return Err(PyValueError::new_err(format!( - "momentum has {} entries but group has {} generators", - k_slice.len(), - group.inner.n_generators() - ))); - } - let mut basis_words = crate::lindblad::decode_basis(&basis_view, n_q)?; + check_momentum_len(k_slice.len(), group.inner.n_generators())?; + 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)) @@ -224,19 +208,11 @@ pub fn canonicalize_basis_arr_complex<'py>( k_slice, ); - let m = basis_words.len(); - let mut out_basis = vec![0u8; m * n_q]; - for (i, w) in basis_words.iter().enumerate() { - codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); - } let out_coeffs: Vec = coeffs_vec .iter() .map(|c| Complex64::new(c.re, c.im)) .collect(); - let basis_arr = out_basis - .into_pyarray(py) - .reshape([m, n_q]) - .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + let basis_arr = encode_basis(py, &basis_words, n_q)?; Ok((basis_arr, out_coeffs.into_pyarray(py))) } @@ -257,15 +233,12 @@ pub fn check_momentum_sector_arr<'py>( ) -> PyResult<()> { let basis_view = basis.as_array(); let n_q = group.inner.n_qubits(); - if basis_view.shape().get(1).copied() != Some(n_q) { - return Err(PyValueError::new_err(format!( - "basis has {} qubits per row but group acts on {n_q}", - basis_view.shape().get(1).copied().unwrap_or(0) - ))); - } + 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()?; - let basis_words = crate::lindblad::decode_basis(&basis_view, n_q)?; + check_momentum_len(k_slice.len(), group.inner.n_generators())?; + let basis_words = decode_basis(&basis_view, n_q)?; let coeffs_vec: Vec> = coeffs_slice .iter() .map(|c| Complex::new(c.re, c.im)) @@ -295,36 +268,15 @@ pub fn canonicalize_basis_arr<'py>( ) -> PyResult> { let basis_view = basis.as_array(); let n_q = group.inner.n_qubits(); - if basis_view.shape().get(1).copied() != Some(n_q) { - return Err(PyValueError::new_err(format!( - "basis has {} qubits per row but group acts on {n_q}", - basis_view.shape().get(1).copied().unwrap_or(0) - ))); - } - let n = basis_view.shape()[0]; + check_group_width(&basis_view, n_q)?; let coeffs_slice = coeffs.as_slice()?; - if coeffs_slice.len() != n { - return Err(PyValueError::new_err(format!( - "coeffs has length {} but basis has {} rows", - coeffs_slice.len(), - n - ))); - } + check_coeffs_len(coeffs_slice.len(), basis_view.shape()[0])?; - let mut basis_words = crate::lindblad::decode_basis(&basis_view, n_q)?; + 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); - // Re-encode. - let m = basis_words.len(); - let mut out_basis = vec![0u8; m * n_q]; - for (i, w) in basis_words.iter().enumerate() { - codes_from_word(w, &mut out_basis[i * n_q..(i + 1) * n_q]); - } - let basis_arr = out_basis - .into_pyarray(py) - .reshape([m, n_q]) - .map_err(|e| PyValueError::new_err(format!("reshape failed: {e}")))?; + let basis_arr = encode_basis(py, &basis_words, n_q)?; Ok((basis_arr, coeffs_vec.into_pyarray(py))) } diff --git a/ppvm-python/src/ppvm/__init__.py b/ppvm-python/src/ppvm/__init__.py index 286250622..7c1b0fa8b 100644 --- a/ppvm-python/src/ppvm/__init__.py +++ b/ppvm-python/src/ppvm/__init__.py @@ -16,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/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index 3504cd712..f71c6d846 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -401,7 +401,7 @@ def symmetry_merge(self, group) -> None: sector only. Args: - group: A `ppvm._core.TranslationGroup` + group: A `ppvm.TranslationGroup` (use ``TranslationGroup.chain_1d(n)``, ``.torus_2d``, ``.torus_3d``, ``.ladder``, or ``.from_generators``). """ @@ -429,7 +429,7 @@ def momentum_merge(self, other: "PauliSum", group, momentum) -> None: Args: other: the PauliSum holding the imaginary component (modified in place). - group: a `ppvm._core.TranslationGroup`. + 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). """ 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/test_pc_step_orbit_rep.py b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py index fdb234cdb..36dddaf1b 100644 --- a/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py +++ b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py @@ -20,8 +20,7 @@ import numpy as np import pytest -from ppvm import Lindbladian -from ppvm._core import TranslationGroup, canonicalize_basis_arr_complex +from ppvm import Lindbladian, TranslationGroup, canonicalize_basis_arr_complex from ._helpers import all_strings @@ -45,7 +44,9 @@ def to_dict(basis, coeffs): def momentum(*modes): - return np.array(modes, dtype=np.int32) + """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): diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py index 8e5fb953f..217863299 100644 --- a/ppvm-python/test/test_momentum_merge.py +++ b/ppvm-python/test/test_momentum_merge.py @@ -18,8 +18,7 @@ import numpy as np import pytest -from ppvm import PauliSum -from ppvm._core import TranslationGroup +from ppvm import PauliSum, TranslationGroup # ── dense Pauli helpers (exact references) ─────────────────────────────────── _I = np.eye(2, dtype=complex) diff --git a/ppvm-python/test/test_symmetry_arrays.py b/ppvm-python/test/test_symmetry_arrays.py index 11ec2aa59..7630e6cc0 100644 --- a/ppvm-python/test/test_symmetry_arrays.py +++ b/ppvm-python/test/test_symmetry_arrays.py @@ -2,7 +2,8 @@ # 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``: +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 @@ -19,7 +20,7 @@ import numpy as np import pytest -from ppvm._core import ( +from ppvm import ( TranslationGroup, canonicalize_basis_arr, canonicalize_basis_arr_complex, @@ -48,15 +49,38 @@ def rep_of(group, s): def momentum(*modes): - """The ``_core`` free functions take momentum as an int32 array; numpy's - default integer dtype is int64, which they reject.""" - return np.array(modes, dtype=np.int32) + """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 diff --git a/ppvm-python/test/test_symmetry_merge.py b/ppvm-python/test/test_symmetry_merge.py index d151cc168..4de560263 100644 --- a/ppvm-python/test/test_symmetry_merge.py +++ b/ppvm-python/test/test_symmetry_merge.py @@ -12,8 +12,7 @@ import numpy as np import pytest -from ppvm import PauliSum -from ppvm._core import TranslationGroup +from ppvm import PauliSum, TranslationGroup _CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} _CHAR = {v: k for k, v in _CODE.items()} From 3a56041c03e037ef6c36d47875a80fbe745e7d9e Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 14:23:01 +0200 Subject: [PATCH 12/15] fix(lindblad): honour num_threads on the orbit-rep step; API polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes closing out the symmetry-evolution cleanup. `pc_step_orbit_rep` silently ignored `cfg.num_threads` while its sibling `pc_step` honoured it — the orbit-rep path never went through `run_in_pool`. It does now, and `num_threads` is threaded through the PyO3 signature, the `.pyi` stub and the `lindblad.py` wrapper so the two adaptive step entry points take the same knobs. Previously the binding hardcoded `num_threads: None`, so the argument could not even be passed. `PauliSum.momentum_merge` took `other` as `PyRefMut`, so passing the same object as both the real and imaginary part died with PyO3's raw already-borrowed error instead of the "must be distinct objects" the docstring promised. It now takes a `Bound` and converts the failed borrow into that message. `group` / `momentum` parameters on `PauliSum.symmetry_merge`, `PauliSum.momentum_merge` and `Lindbladian.pc_step_orbit_rep` were unannotated; they now carry `_core.TranslationGroup` / `Sequence[int]` / `npt.ArrayLike`. Also finishes a dedup that 74327041 got wrong: the `coeffs has length ...` check appears four times in `lindblad.rs`, and the replacement there landed on the first occurrence rather than the intended one in `pc_step_orbit_rep`. All four now call `check_coeffs_len`. Messages were identical, so no behavior changed either way. 288 Python tests pass (three new: the same-object rejection and num_threads=1/2 result-equivalence), `cargo test --workspace` green, clippy/ruff/ty clean. Co-Authored-By: Claude Opus 5 (1M context) --- crates/ppvm-lindblad/src/step.rs | 16 +++++++++++ crates/ppvm-python-native/src/interface.rs | 11 +++++++- crates/ppvm-python-native/src/lindblad.rs | 28 ++++--------------- ppvm-python/src/ppvm/_core.pyi | 1 + ppvm-python/src/ppvm/lindblad.py | 11 ++++++-- ppvm-python/src/ppvm/paulisum.py | 9 ++++-- .../test/lindblad/test_pc_step_orbit_rep.py | 22 +++++++++++++++ ppvm-python/test/test_momentum_merge.py | 11 ++++++++ 8 files changed, 82 insertions(+), 27 deletions(-) diff --git a/crates/ppvm-lindblad/src/step.rs b/crates/ppvm-lindblad/src/step.rs index d0e380a0e..7a168a77e 100644 --- a/crates/ppvm-lindblad/src/step.rs +++ b/crates/ppvm-lindblad/src/step.rs @@ -198,6 +198,8 @@ impl LindbladSpec { /// 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, @@ -206,6 +208,20 @@ impl LindbladSpec { 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, diff --git a/crates/ppvm-python-native/src/interface.rs b/crates/ppvm-python-native/src/interface.rs index 20450252b..6657b5451 100644 --- a/crates/ppvm-python-native/src/interface.rs +++ b/crates/ppvm-python-native/src/interface.rs @@ -102,10 +102,19 @@ macro_rules! create_interface_symmetry_methods { #[pyo3(signature = (other, group, momentum))] pub fn momentum_merge( &mut self, - mut other: pyo3::PyRefMut<'_, 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()), diff --git a/crates/ppvm-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index e56b78351..87f538cf6 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -202,13 +202,7 @@ impl LindbladSpec { let mut basis_words = decode_basis(&basis_view, n_q)?; assert_basis_unique(&basis_words)?; let mut coeffs_vec = coeffs.as_slice()?.to_vec(); - if coeffs_vec.len() != basis_words.len() { - return Err(PyValueError::new_err(format!( - "coeffs has length {} but basis has {} rows", - coeffs_vec.len(), - basis_words.len() - ))); - } + 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 { @@ -264,13 +258,7 @@ impl LindbladSpec { let mut basis_words = decode_basis(&basis_view, n_q)?; assert_basis_unique(&basis_words)?; let mut coeffs_vec = coeffs.as_slice()?.to_vec(); - if coeffs_vec.len() != basis_words.len() { - return Err(PyValueError::new_err(format!( - "coeffs has length {} but basis has {} rows", - coeffs_vec.len(), - basis_words.len() - ))); - } + 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 { @@ -334,6 +322,7 @@ impl LindbladSpec { canonicalize_first = false, admit_basis = None, tau_add = None, + num_threads = None, ))] #[allow(clippy::too_many_arguments)] fn pc_step_orbit_rep<'py>( @@ -350,6 +339,7 @@ impl LindbladSpec { canonicalize_first: bool, admit_basis: Option, tau_add: Option, + num_threads: Option, ) -> PyResult> { use num::Complex; use ppvm_lindblad::{Sector, canonicalize_basis_to_rep}; @@ -358,13 +348,7 @@ impl LindbladSpec { let basis_view = basis.as_array(); let mut basis_words = decode_basis(&basis_view, n_q)?; let coeffs_slice = coeffs.as_slice()?; - if coeffs_slice.len() != basis_words.len() { - return Err(PyValueError::new_err(format!( - "coeffs has length {} but basis has {} rows", - coeffs_slice.len(), - basis_words.len() - ))); - } + 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)) @@ -391,7 +375,7 @@ impl LindbladSpec { admit_basis, drop_tol, tau_add, - num_threads: None, + num_threads, }, ) .map_err(map_err)?; diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index b0350fc55..6ea1696ca 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -413,6 +413,7 @@ class LindbladSpec: 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]: ... diff --git a/ppvm-python/src/ppvm/lindblad.py b/ppvm-python/src/ppvm/lindblad.py index 82dfcd769..c2538ab5f 100644 --- a/ppvm-python/src/ppvm/lindblad.py +++ b/ppvm-python/src/ppvm/lindblad.py @@ -42,7 +42,9 @@ 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} @@ -297,13 +299,14 @@ def pc_step_orbit_rep( coeffs: np.ndarray, dt: float, max_basis: int, - group, - momentum: np.ndarray, + 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. @@ -330,6 +333,9 @@ def pc_step_orbit_rep( ``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: @@ -346,6 +352,7 @@ def pc_step_orbit_rep( 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( diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index f71c6d846..7d301fdd0 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -386,7 +386,7 @@ def trace(self, pattern: str) -> float: """ return self._interface.trace(pattern) - def symmetry_merge(self, group) -> None: + 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) @@ -407,7 +407,12 @@ def symmetry_merge(self, group) -> None: """ self._interface.symmetry_merge(group) - def momentum_merge(self, other: "PauliSum", group, momentum) -> None: + 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 diff --git a/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py index 36dddaf1b..b4a014d15 100644 --- a/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py +++ b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py @@ -199,6 +199,28 @@ def test_protected_reps_are_never_dropped(): 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) diff --git a/ppvm-python/test/test_momentum_merge.py b/ppvm-python/test/test_momentum_merge.py index 217863299..5f8c02a70 100644 --- a/ppvm-python/test/test_momentum_merge.py +++ b/ppvm-python/test/test_momentum_merge.py @@ -115,6 +115,17 @@ def test_momentum_merge_idempotent_on_stabilized_orbit(word): 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 From d8313e0ebf115b2f367c3884efa60e313e8138ba Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 2 Sep 2026 15:26:28 +0200 Subject: [PATCH 13/15] fix(symmetry): orbit-rep evolution on stabilized orbits; validate group inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from a review of the orbit-rep CTPP path. **Stabilized orbits.** The phase-aware action sums characters over the output orbit's *distinct* members, which makes it the orbit-rep generator in the summing convention `ĉ_r = |orbit_r| · c_r`. The public API carries averaged coefficients (`c_r` = the plain coefficient of the rep word, as `canonicalize_pauli_sum_complex` produces), so every matrix entry needs the similarity factor `|orbit_in| / |orbit_out|` — which is 1 only when both orbits are free. Without it, evolving `ZIZI + IZIZ` on a 4-site chain came out a factor 2 too large on every coefficient. Reps whose stabilizer is incompatible with the sector are now dropped too, matching what the reference projection does. `TranslationGroup::canonicalize_in_sector` computes the rep, the shift counter, the distinct-orbit size and sector compatibility from ONE orbit traversal (orbit-stabilizer, counting stabilizer elements during the lex-min walk), so the hot action loop pays one extra word compare. **FFI validation.** `pc_step_orbit_rep` never checked the group's qubit count against the spec's, and ran `canonicalize_first` — documented as not deduplicating — without a uniqueness check, so a release wheel silently collapsed same-orbit rows onto one CSC index. The lattice constructors and the unchecked half of `from_generators` (zero order, inexact order, non-commuting generators) aborted through `assert!`. All now raise `ValueError`. The order and commutation checks move into a fallible core constructor, `TranslationGroup::try_from_generators` -> `GroupError`, rather than being duplicated in the binding; `from_generators` panics with the same messages as before. `LossyPauliSum` gained explicit `symmetry_merge` / `momentum_merge` overrides raising `NotImplementedError`, and the `.pyi` stubs moved onto a non-loss base so type checkers reject the call. Co-Authored-By: Claude Opus 5 (1M context) --- crates/ppvm-lindblad/src/basis.rs | 22 ++- crates/ppvm-lindblad/src/mf_expm.rs | 31 ++- crates/ppvm-lindblad/src/sector.rs | 47 ++++- crates/ppvm-lindblad/src/tests.rs | 130 ++++++++----- crates/ppvm-pauli-sum/src/symmetry/group.rs | 179 ++++++++++++++---- crates/ppvm-pauli-sum/src/symmetry/mod.rs | 2 +- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 48 +++++ crates/ppvm-pauli-sum/src/symmetry/tests.rs | 117 ++++++++++++ crates/ppvm-python-native/src/lindblad.rs | 9 +- crates/ppvm-python-native/src/pauli_arr.rs | 13 ++ crates/ppvm-python-native/src/symmetry.rs | 90 ++++----- ppvm-python/src/ppvm/_core.pyi | 39 ++-- ppvm-python/src/ppvm/lindblad.py | 8 + ppvm-python/src/ppvm/paulisum.py | 30 +++ .../test/lindblad/test_pc_step_orbit_rep.py | 75 ++++++++ ppvm-python/test/test_symmetry_merge.py | 42 +++- 16 files changed, 720 insertions(+), 162 deletions(-) diff --git a/crates/ppvm-lindblad/src/basis.rs b/crates/ppvm-lindblad/src/basis.rs index 941c67b6c..153780213 100644 --- a/crates/ppvm-lindblad/src/basis.rs +++ b/crates/ppvm-lindblad/src/basis.rs @@ -241,9 +241,15 @@ impl LindbladSpec { /// /// 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)` via [`Sector::canonicalize_phase`]. + /// 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`. + /// `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. /// @@ -293,12 +299,20 @@ impl LindbladSpec { |(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 (r_q, phase) = sector.canonicalize_phase(q); + let Some((r_q, phase, orbit_out)) = sector.canonicalize_phase(q) else { + continue; + }; if !in_basis.contains(&r_q) && !protected_set.contains(&r_q) { - out.push((r_q, phase * *v * c_r)); + let rate = phase * *v * c_r * (orbit_in as f64 / orbit_out as f64); + out.push((r_q, rate)); } } out diff --git a/crates/ppvm-lindblad/src/mf_expm.rs b/crates/ppvm-lindblad/src/mf_expm.rs index 491f686d5..9437396f8 100644 --- a/crates/ppvm-lindblad/src/mf_expm.rs +++ b/crates/ppvm-lindblad/src/mf_expm.rs @@ -92,11 +92,21 @@ fn build_mf_cols( /// 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)` 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`]). +/// `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 @@ -124,14 +134,21 @@ fn build_orbit_rep_cols( ) }, |(s1, s2, lm), (c, r)| { + // 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 (Vec::new(), (0.0, Complex::new(0.0, 0.0))); + }; let terms = spec.compute_action_terms(r, s1, s2, lm); let mut out = Vec::with_capacity(terms.len()); let mut raw = 0.0; let mut diag = Complex::new(0.0, 0.0); for (q, v) in terms.iter() { - let (r_q, phase) = sector.canonicalize_phase(q); + 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; + let val = phase * *v * (orbit_in as f64 / orbit_out as f64); raw += val.norm(); if row as usize == c { diag += val; diff --git a/crates/ppvm-lindblad/src/sector.rs b/crates/ppvm-lindblad/src/sector.rs index ab588f7aa..b76ad2687 100644 --- a/crates/ppvm-lindblad/src/sector.rs +++ b/crates/ppvm-lindblad/src/sector.rs @@ -9,9 +9,17 @@ //! 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` -//! (where `v` is the matrix element of `L*` between input rep `r` and -//! output `q`). [`Sector::canonicalize_phase`] is that step. +//! 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. @@ -51,13 +59,36 @@ impl<'a> Sector<'a> { /// 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`. The phase weights the matrix - /// element of `L*` when it is accumulated onto `r_q`. + /// 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) -> (Word, Complex) { - let (rep, counter) = self.group.canonicalize_with_shift(q); + pub fn canonicalize_phase(&self, q: &Word) -> Option<(Word, Complex, usize)> { + let (rep, counter, orbit_size) = self.group.canonicalize_in_sector(q, self.k_modes)?; let phase = self.group.character(self.k_modes, &counter); - (rep, phase) + Some((rep, phase, 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(w, self.k_modes) + .map(|(_, _, orbit_size)| orbit_size) } } diff --git a/crates/ppvm-lindblad/src/tests.rs b/crates/ppvm-lindblad/src/tests.rs index 5687c0b31..85526d37f 100644 --- a/crates/ppvm-lindblad/src/tests.rs +++ b/crates/ppvm-lindblad/src/tests.rs @@ -94,68 +94,61 @@ fn word_codec_roundtrip() { assert_eq!(out.as_slice(), &codes); } -/// Per-step orbit-rep evolution gives the SAME final orbit-rep -/// state as full-basis complex evolution followed by a single -/// projection at the end. Validates that the phase-aware complex -/// action machinery is consistent with the full-basis reference. -#[test] -fn pc_step_orbit_rep_matches_full_basis_projection() { - use std::f64::consts::PI; - - use ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex; - let n = 4usize; - let dt = 0.01f64; - let n_steps = 3usize; +/// 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"] { + for op in ['X', 'Y'] { let mut s = vec!['I'; n]; - s[j] = op.chars().next().unwrap(); - s[nxt] = op.chars().next().unwrap(); + s[j] = op; + s[nxt] = op; h_terms.push((s.into_iter().collect(), 1.0)); } } - let spec = LindbladSpec::new(n, &h_terms, &[]).unwrap(); - let group = ppvm_pauli_sum::symmetry::TranslationGroup::chain_1d(n); - let k_mode: i32 = 1; - let k = vec![k_mode]; + h_terms +} - // Build the k=1 eigenstate in FULL basis form. - let basis_full: Vec = (0..n) - .map(|j| { - let mut s = vec!['I'; n]; - s[j] = 'Z'; - let (w, _) = parse_pauli_string(&s.into_iter().collect::(), n).unwrap(); - w - }) - .collect(); - let coeffs_full: Vec> = (0..n as i32) - .map(|a| Complex::from_polar(1.0, -2.0 * PI * (k_mode as f64) * (a as f64) / (n as f64))) +/// 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 = LindbladSpec::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 ----- + // ----- 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 { - // Full enrichment (tau_add = 0.0 adds every leakage string): - // for a momentum eigenstate the leakage is pure-sector, so the - // full-basis and orbit-rep paths build corresponding bases and - // the projection theorem gives an exact match. The orbit-rep - // side uses a large max_basis so its rank cap never binds. pc_step_complex_full(&spec, &mut bf, &mut cf, dt); } - // Project at the end. - canonicalize_pauli_sum_complex(&mut bf, &mut cf, &group, &k); + canonicalize_pauli_sum_complex(&mut bf, &mut cf, &group, k); - // ----- Orbit-rep path ----- - // Initial orbit-rep form: project the full-basis input. + // ----- 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); - // Evolve in orbit-rep form (max_basis large ⇒ full enrichment). - let sector = Sector::new(&group, &k); + 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, @@ -171,7 +164,6 @@ fn pc_step_orbit_rep_matches_full_basis_projection() { .unwrap(); } - // Compare. let mf: FxHashMap> = bf.into_iter().zip(cf).collect(); let mr: FxHashMap> = br.into_iter().zip(cr).collect(); assert_eq!( @@ -186,7 +178,7 @@ fn pc_step_orbit_rep_matches_full_basis_projection() { let cf_val = mf .get(w) .copied() - .unwrap_or_else(|| panic!("rep {:?} in orbit-rep but not in full-basis", w)); + .unwrap_or_else(|| panic!("rep {w} in orbit-rep but not in full-basis")); max_diff = max_diff.max((cm - cf_val).norm()); } assert!( @@ -195,6 +187,54 @@ fn pc_step_orbit_rep_matches_full_basis_projection() { ); } +/// 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] diff --git a/crates/ppvm-pauli-sum/src/symmetry/group.rs b/crates/ppvm-pauli-sum/src/symmetry/group.rs index cda4ae7c7..73deab159 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. /// @@ -94,61 +180,90 @@ pub struct TranslationGroup { } 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 { + Ok(Self { n_qubits, perms, orders, order, phase_modulus, - } + }) } /// 1D chain of `n` sites with periodic boundary conditions. diff --git a/crates/ppvm-pauli-sum/src/symmetry/mod.rs b/crates/ppvm-pauli-sum/src/symmetry/mod.rs index 22fdbb291..b8ee315e1 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/mod.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/mod.rs @@ -72,7 +72,7 @@ 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, diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index 19563f27e..501cbceaa 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -51,6 +51,54 @@ impl TranslationGroup { let phase = 2.0 * PI * numerator as f64 / self.phase_modulus() as f64; Complex::from_polar(1.0, phase) } + + /// 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 `O(|G| × n_qubits)` cost as [`Self::canonicalize_with_shift`]. + pub fn canonicalize_in_sector( + &self, + w: &PauliWord, + k_modes: &[i32], + ) -> Option<(PauliWord, Vec, usize)> + where + A: PauliStorage, + S: BuildHasher + Clone + Default + HashFinalize, + { + let mut best: Option<(PauliWord, Vec)> = None; + let mut stabilizer = 0usize; + for (candidate, counter) in self.orbit_with_counters(w) { + if candidate == *w { + if self.character_numerator(k_modes, &counter) != 0 { + return None; + } + stabilizer += 1; + } + if best.as_ref().is_none_or(|(b, _)| candidate < *b) { + best = Some((candidate, counter)); + } + } + let (rep, counter_from_word) = best.expect("a finite group contains the identity element"); + let shift = (0..self.n_generators()) + .map(|g| { + let order = self.generator_order(g); + (order - counter_from_word[g]) % order + }) + .collect(); + Some((rep, shift, self.order() / stabilizer)) + } } /// Replace `(basis, complex_coeffs)` in-place with the orbit-rep form diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 8af9a01bf..66c261d3f 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -136,6 +136,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); @@ -626,6 +666,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()); diff --git a/crates/ppvm-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index 87f538cf6..477e1e834 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -44,7 +44,9 @@ fn assert_basis_unique(basis: &[Word]) -> PyResult<()> { Ok(()) } -use crate::pauli_arr::{check_coeffs_len, check_momentum_len, decode_basis, encode_basis}; +use crate::pauli_arr::{ + check_coeffs_len, check_group_qubits, check_momentum_len, decode_basis, encode_basis, +}; /// Pack `Vec<(Word, f64)>` into the standard PyO3 return shape. fn pack_pauli_map<'py>( @@ -360,9 +362,14 @@ impl LindbladSpec { }; 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)?; self.inner .pc_step_orbit_rep( &mut basis_words, diff --git a/crates/ppvm-python-native/src/pauli_arr.rs b/crates/ppvm-python-native/src/pauli_arr.rs index a90402cc4..45580d638 100644 --- a/crates/ppvm-python-native/src/pauli_arr.rs +++ b/crates/ppvm-python-native/src/pauli_arr.rs @@ -73,6 +73,19 @@ pub(crate) fn check_group_width( 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 { diff --git a/crates/ppvm-python-native/src/symmetry.rs b/crates/ppvm-python-native/src/symmetry.rs index 6c060ec51..cd3dfa32b 100644 --- a/crates/ppvm-python-native/src/symmetry.rs +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -53,75 +53,75 @@ impl TranslationGroup { } } +/// 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) -> Self { - Self { + 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) -> Self { - Self { + 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) -> Self { - Self { + 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) -> Self { - Self { + 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 { - if perms.len() != orders.len() { - return Err(PyValueError::new_err(format!( - "perms ({} generators) and orders ({}) must have the same length", - perms.len(), - orders.len() - ))); - } - for (g, perm) in perms.iter().enumerate() { - if perm.len() != n_qubits { - return Err(PyValueError::new_err(format!( - "generator {g}: permutation length {} != n_qubits {n_qubits}", - perm.len() - ))); - } - let mut seen = vec![false; n_qubits]; - for &p in perm { - let p = p as usize; - if p >= n_qubits { - return Err(PyValueError::new_err(format!( - "generator {g}: target {p} out of range [0, {n_qubits})" - ))); - } - if seen[p] { - return Err(PyValueError::new_err(format!( - "generator {g}: not a permutation (duplicate target {p})" - ))); - } - seen[p] = true; - } - } - Ok(Self { - inner: core_sym::TranslationGroup::from_generators(n_qubits, perms, orders), - }) + 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. diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index 6ea1696ca..e9877b7f8 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -62,11 +62,14 @@ class _PauliSumBase: def terms(self) -> list[tuple[str, float]]: ... def weights(self) -> list[tuple[str, int]]: ... def current_max_weight(self) -> int: ... - # Only on non-loss variants (see create_interface_symmetry_methods). + +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: _PauliSumBase, + other: _PauliSumNoLossBase, group: TranslationGroup, momentum: list[int], ) -> None: ... @@ -78,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): ... diff --git a/ppvm-python/src/ppvm/lindblad.py b/ppvm-python/src/ppvm/lindblad.py index c2538ab5f..a71dce745 100644 --- a/ppvm-python/src/ppvm/lindblad.py +++ b/ppvm-python/src/ppvm/lindblad.py @@ -316,6 +316,14 @@ def pc_step_orbit_rep( 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 diff --git a/ppvm-python/src/ppvm/paulisum.py b/ppvm-python/src/ppvm/paulisum.py index 7d301fdd0..6d1922915 100644 --- a/ppvm-python/src/ppvm/paulisum.py +++ b/ppvm-python/src/ppvm/paulisum.py @@ -551,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/test/lindblad/test_pc_step_orbit_rep.py b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py index b4a014d15..6bb8a81d2 100644 --- a/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py +++ b/ppvm-python/test/lindblad/test_pc_step_orbit_rep.py @@ -144,6 +144,59 @@ def test_orbit_rep_matches_dense_full_space_then_project(k): 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.""" @@ -230,6 +283,28 @@ def test_pc_step_orbit_rep_validates_inputs(): 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(): diff --git a/ppvm-python/test/test_symmetry_merge.py b/ppvm-python/test/test_symmetry_merge.py index 4de560263..f91536534 100644 --- a/ppvm-python/test/test_symmetry_merge.py +++ b/ppvm-python/test/test_symmetry_merge.py @@ -12,7 +12,7 @@ import numpy as np import pytest -from ppvm import PauliSum, TranslationGroup +from ppvm import LossyPauliSum, PauliSum, TranslationGroup _CODE = {"I": 0, "X": 1, "Z": 2, "Y": 3} _CHAR = {v: k for k, v in _CODE.items()} @@ -63,13 +63,40 @@ def test_from_generators_matches_chain_1d(): ([[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"] @@ -139,3 +166,16 @@ 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]) From e6c9f7c57a014352b2dd43625b12a26983c98b01 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Mon, 28 Sep 2026 13:25:54 +0200 Subject: [PATCH 14/15] perf(symmetry): restore the fast orbit canonicalizers lost in the split The orbit-rep (momentum-sector) step was 3-5x slower on the split stack than on `wide-pauli-words`: three canonicalization commits from that branch (7e8ff64b, b0420efa, 5f343b35) never made it into the PRs. Port them onto the current `symmetry/` layout, keeping this stack's stabilized-orbit semantics (`canonicalize_in_sector` still reports |orbit| and rejects stabilizer-incompatible orbits on exact numerators). - Odometer by index: the group walk tracks a mixed-radix index instead of cloning a counter `Vec` per element. `canonicalize_with_index` returns the element index; `canonicalize_with_shift` decodes it. - `TranslationGroup::character_table(k)` -> `CharacterTable`, built once per `Sector::new`; `canonicalize_in_sector_indexed` looks characters (and stabilizer triviality) up by index, so the hot loops in `build_orbit_rep_cols` / `leakage_orbit_rep` no longer do a counter Vec, numerator arithmetic and sin/cos per action term. `Sector` now owns that table and is passed by reference. - O(N) least-rotation canonicalizer (plane-by-plane Booth/Duval) for single-generator block-cyclic groups (chain_1d, ladder). The final survivor spacing is the stabilizer generator, which gives |orbit| and the sector-compatibility check for free. Tie-break matches the odometer, so reps and shifts are bit-identical. - Block-rotation generators (every lattice translation) are applied as masked shifts of the two bit planes instead of a per-qubit gather, and intermediates are no longer rehashed; only returned/yielded words are. Tests: masked shift vs gather (u64 and u32 chunk layouts), block-cyclic vs odometer (rep, shift, stabilizer) on stabilized words, and every fast entry point vs the previous counter-carrying traversal, including a non-faithful action. bench_orbit.py torus 4 3000 6, RAYON_NUM_THREADS=2 (2-vCPU sandbox): 13.2 s/step -> ~3.9 s/step (wide-pauli-words: ~2.8-3.9 s/step on the same box), C(t=0.6) unchanged to 12 digits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmnGNkxQqpmFmGszAV7dm5 --- Cargo.lock | 1 + crates/ppvm-lindblad/src/basis.rs | 2 +- crates/ppvm-lindblad/src/mf_expm.rs | 4 +- crates/ppvm-lindblad/src/sector.rs | 36 +- crates/ppvm-lindblad/src/step.rs | 4 +- crates/ppvm-lindblad/src/tests.rs | 2 +- crates/ppvm-pauli-sum/Cargo.toml | 1 + crates/ppvm-pauli-sum/src/symmetry/group.rs | 611 ++++++++++++++++-- crates/ppvm-pauli-sum/src/symmetry/mod.rs | 2 +- .../ppvm-pauli-sum/src/symmetry/momentum.rs | 137 +++- crates/ppvm-pauli-sum/src/symmetry/tests.rs | 275 ++++++++ crates/ppvm-python-native/src/lindblad.rs | 2 +- 12 files changed, 1003 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 110a5f3c1..874699a71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2053,6 +2053,7 @@ version = "0.1.0" dependencies = [ "approx", "bon", + "bytemuck", "criterion 0.7.0", "dashmap", "fxhash", diff --git a/crates/ppvm-lindblad/src/basis.rs b/crates/ppvm-lindblad/src/basis.rs index 153780213..64cbeeec8 100644 --- a/crates/ppvm-lindblad/src/basis.rs +++ b/crates/ppvm-lindblad/src/basis.rs @@ -263,7 +263,7 @@ impl LindbladSpec { basis: &[Word], coeffs: &[Complex], protected: &[Word], - sector: Sector<'_>, + sector: &Sector<'_>, max_basis: usize, ) -> Result)>, Error> { if basis.len() != coeffs.len() { diff --git a/crates/ppvm-lindblad/src/mf_expm.rs b/crates/ppvm-lindblad/src/mf_expm.rs index 9437396f8..7f9f58323 100644 --- a/crates/ppvm-lindblad/src/mf_expm.rs +++ b/crates/ppvm-lindblad/src/mf_expm.rs @@ -117,7 +117,7 @@ fn build_orbit_rep_cols( spec: &LindbladSpec, basis: &[Word], index: &FxHashMap, - sector: Sector<'_>, + sector: &Sector<'_>, ) -> (Cols>, PerCol>) { basis .par_iter() @@ -389,7 +389,7 @@ pub(crate) fn expm_apply_mf( pub(crate) fn expm_apply_orbit_rep( spec: &LindbladSpec, basis: &[Word], - sector: Sector<'_>, + sector: &Sector<'_>, dt: f64, coeffs: &[Complex], ) -> Vec> { diff --git a/crates/ppvm-lindblad/src/sector.rs b/crates/ppvm-lindblad/src/sector.rs index b76ad2687..3973de327 100644 --- a/crates/ppvm-lindblad/src/sector.rs +++ b/crates/ppvm-lindblad/src/sector.rs @@ -37,7 +37,7 @@ use crate::Word; use num::Complex; -use ppvm_pauli_sum::symmetry::TranslationGroup; +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 @@ -45,16 +45,35 @@ use ppvm_pauli_sum::symmetry::TranslationGroup; /// is the trivial sector. /// /// The two halves are meaningless apart — every phase-aware routine -/// needs both — so they travel as one value. -#[derive(Clone, Copy)] +/// 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 { - Self { group, k_modes } + 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 @@ -70,9 +89,10 @@ impl<'a> Sector<'a> { /// identically zero, so the term is dropped. #[inline] pub fn canonicalize_phase(&self, q: &Word) -> Option<(Word, Complex, usize)> { - let (rep, counter, orbit_size) = self.group.canonicalize_in_sector(q, self.k_modes)?; - let phase = self.group.character(self.k_modes, &counter); - Some((rep, phase, orbit_size)) + 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 @@ -87,7 +107,7 @@ impl<'a> Sector<'a> { #[inline] pub fn orbit_size(&self, w: &Word) -> Option { self.group - .canonicalize_in_sector(w, self.k_modes) + .canonicalize_in_sector_indexed(w, &self.characters) .map(|(_, _, orbit_size)| orbit_size) } } diff --git a/crates/ppvm-lindblad/src/step.rs b/crates/ppvm-lindblad/src/step.rs index 7a168a77e..97e593ead 100644 --- a/crates/ppvm-lindblad/src/step.rs +++ b/crates/ppvm-lindblad/src/step.rs @@ -206,7 +206,7 @@ impl LindbladSpec { coeffs: &mut Vec>, dt: f64, protected: &[Word], - sector: Sector<'_>, + sector: &Sector<'_>, cfg: &PcStepConfig, ) -> Result<(), Error> { self.run_in_pool(cfg, |this| { @@ -220,7 +220,7 @@ impl LindbladSpec { coeffs: &mut Vec>, dt: f64, protected: &[Word], - sector: Sector<'_>, + sector: &Sector<'_>, cfg: &PcStepConfig, ) -> Result<(), Error> { let PcStepConfig { diff --git a/crates/ppvm-lindblad/src/tests.rs b/crates/ppvm-lindblad/src/tests.rs index 85526d37f..7a1e4a81a 100644 --- a/crates/ppvm-lindblad/src/tests.rs +++ b/crates/ppvm-lindblad/src/tests.rs @@ -155,7 +155,7 @@ fn assert_orbit_rep_matches_projection( &mut cr, dt, &protected, - sector, + §or, &PcStepConfig { max_basis: 10_000_000, ..Default::default() 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 73deab159..5175941b5 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/group.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/group.rs @@ -164,8 +164,9 @@ impl std::error::Error for GroupError {} /// 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. @@ -177,6 +178,206 @@ 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 { @@ -257,12 +458,19 @@ impl TranslationGroup { let phase_modulus = orders.iter().fold(1usize, |acc, &value| { checked_lcm(acc, value as usize, "character phase modulus") }); + 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, }) } @@ -420,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, @@ -434,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) { @@ -446,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, @@ -472,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 @@ -505,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, @@ -514,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) + } + } + } + + /// 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)) } } - let counter_to_word = counter_from_word - .iter() - .zip(self.orders.iter()) - .map(|(&counter, &order)| (order - counter) % order) - .collect(); - (best, counter_to_word) + } + + /// 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 @@ -571,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 b8ee315e1..f18b4dac3 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/mod.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/mod.rs @@ -75,7 +75,7 @@ mod momentum; 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, + CharacterTable, SectorCheckError, canonicalize_pauli_sum_complex, check_momentum_sector, momentum_merge_pauli_sum_pair, }; diff --git a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs index 501cbceaa..d294ca1ec 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/momentum.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/momentum.rs @@ -52,6 +52,45 @@ impl TranslationGroup { 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 @@ -67,7 +106,9 @@ impl TranslationGroup { /// `|orbit| = |G| / |stabilizer|` (orbit-stabilizer), and equals /// `|G|` only for free orbits. /// - /// Same `O(|G| × n_qubits)` cost as [`Self::canonicalize_with_shift`]. + /// 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, @@ -77,27 +118,79 @@ impl TranslationGroup { A: PauliStorage, S: BuildHasher + Clone + Default + HashFinalize, { - let mut best: Option<(PauliWord, Vec)> = None; - let mut stabilizer = 0usize; - for (candidate, counter) in self.orbit_with_counters(w) { - if candidate == *w { - if self.character_numerator(k_modes, &counter) != 0 { - return None; - } - stabilizer += 1; - } - if best.as_ref().is_none_or(|(b, _)| candidate < *b) { - best = Some((candidate, counter)); - } - } - let (rep, counter_from_word) = best.expect("a finite group contains the identity element"); - let shift = (0..self.n_generators()) - .map(|g| { - let order = self.generator_order(g); - (order - counter_from_word[g]) % order - }) - .collect(); - Some((rep, shift, self.order() / stabilizer)) + 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() } } diff --git a/crates/ppvm-pauli-sum/src/symmetry/tests.rs b/crates/ppvm-pauli-sum/src/symmetry/tests.rs index 66c261d3f..5e09d5c47 100644 --- a/crates/ppvm-pauli-sum/src/symmetry/tests.rs +++ b/crates/ppvm-pauli-sum/src/symmetry/tests.rs @@ -6,6 +6,7 @@ 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>; @@ -825,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/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index 477e1e834..2564f0a9f 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -376,7 +376,7 @@ impl LindbladSpec { &mut coeffs_vec, dt, &protected_words, - Sector::new(group.core(), k_slice), + &Sector::new(group.core(), k_slice), &ppvm_lindblad::PcStepConfig { max_basis, admit_basis, From 5f58273dd1ce5056f7c23f421bca9583f6ac09aa Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Mon, 28 Sep 2026 15:44:46 +0200 Subject: [PATCH 15/15] Kossakowski-form dissipator (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the general GKSL form `D*(O) = Σ_nm K_nm (A_n† O A_m − ½{A_n† A_m, O})` to `ppvm-lindblad`, alongside the existing jump-operator form, with Python bindings, tests and benchmarks. Replaces #179, which was stacked on the pre-split #178 and could not be reopened usefully: its branch had never been rebased, so its diff against the split stack was 160 files and reverted work that #180–#182 had since landed. This branch is the same feature ported onto `split/3` and cleaned up; the original commits are preserved on `kossakowski-dissipator`. Stacked on #182 — **review after that one settles**, see the note below. ## What's here | Commit | | |---|---| | `7b52cf6b` | core dissipator: `kossakowski.rs`, `PairShape`, `add_kossakowski` | | `e6266b52` | superradiance + dipolar-relaxation benchmarks, profiling example | | `dc8c6234` | `Lindbladian(..., kossakowski=(ops, K))` Python surface | | `b52d22cc` | dense-reference and orbit-representative tests | Conjugate pairs `(n,m)`/`(m,n)` are folded into a single upper-triangle entry — both sandwiches produce the same output words with conjugate phase and the action keeps only the real part — halving the pair count. `PairShape` distinguishes the diagonal entry from a folded off-diagonal one and carries the term list only the folded case needs, so the off-diagonal-only path is unreachable on a diagonal pair. Against the equivalent eigenmode-jump decomposition on a superradiant chain, the pair-matrix path costs 27.7 ms vs 103 ms at n=10 and 96 ms vs 1.71 s at n=30: the per-string action scales with the number of nonzero `K_nm` entries instead of carrying an extra factor of `M`. ## Differences from #179 Ported into the `spec`/`basis`/`step`/`algebra` layout `split/2` and `split/3` introduced, rather than appended to `lib.rs`: - New `kossakowski.rs` module. `spec.rs` grows 65 lines, not 520. - `add_kossakowski` (147 lines) split into `validate_k` / `parse_ops` / `compile_pair` / `compile`; the ~90-line action arm became `Pair::accumulate` + the one-sided and both-sided halves. - `PairShape` enum replaces an `off_diag` bool that drove three separate branches in the hot path. Benchmarked both shapes: the deltas (+2.4% / −1.3% / +2.2%, n30 p=0.17) are smaller than the drift on the `eigenmode_*` control benchmarks (+3.4% to +6.6%) in the same run, so this is noise, not a regression. - **7 clippy `allow`s removed, 0 added.** `split/3` had none in `ppvm-lindblad`; three `needless_range_loop` became iterator loops and four `type_complexity` became two named aliases. - `precompute_ldagger_l` generalized to `precompute_adag_b(a, b)`, moved to `algebra.rs` beside `PauliTerm` and a named `COEFF_DROP_TOL` (was a `1e-14` literal in five places). - Dropped a dead `let _ = i;`, and row-length errors now report which row. - `support_mask` lifted out of the pair loop, where it was being rebuilt per iteration. - Deleted the `k_ops` field — `LindbladSpec` state that never outlived the constructor. - Did not port the original's `:func:`/`:meth:` docstrings; `split/3` had already converted those to backticks per AGENTS.md. ## One thing to check `test_orbit_dissipative.py` failed on arrival. `d8313e0e` on `split/3` ("orbit-rep evolution on stabilized orbits") made rep coefficients member coefficients on *every* orbit, where stabilized orbits were previously divided by `|G|`; the tests encoded the old behaviour and `c_I` came out exactly 6× the asserted value on a 6-site ring. I rewrote the reconstruction as `Σ |orbit(rep)| · c_rep` and updated the identity-bookkeeping expectation to `coeff_I`. The new values match the closed form exactly. **If `d8313e0e` was not meant to change that convention, the bug is there and not in these tests** — worth confirming while reviewing #182. ## Verification - `cargo test -p ppvm-lindblad` — 8 passed - `pytest test/lindblad/` — 35 passed, 1 skipped - `cargo clippy -p ppvm-lindblad --all-targets` — zero warnings - pre-commit green on all four commits 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 Co-authored-by: Alexander Schuckert --- Cargo.lock | 188 ++++++++ crates/ppvm-lindblad/Cargo.toml | 13 + 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 | 61 ++- crates/ppvm-lindblad/src/basis.rs | 56 +-- crates/ppvm-lindblad/src/error.rs | 29 +- crates/ppvm-lindblad/src/kossakowski.rs | 333 +++++++++++++ crates/ppvm-lindblad/src/lib.rs | 13 +- crates/ppvm-lindblad/src/mf_expm.rs | 275 ++++++----- crates/ppvm-lindblad/src/sector.rs | 9 +- crates/ppvm-lindblad/src/spec.rs | 111 +++-- crates/ppvm-lindblad/src/step.rs | 98 ++-- crates/ppvm-lindblad/src/tests.rs | 24 +- crates/ppvm-lindblad/src/truncate.rs | 33 +- crates/ppvm-lindblad/src/word.rs | 88 +++- crates/ppvm-lindblad/tests/word_width.rs | 274 +++++++++++ crates/ppvm-python-native/src/lindblad.rs | 446 +++++++++++------- crates/ppvm-python-native/src/pauli_arr.rs | 39 +- crates/ppvm-python-native/src/symmetry.rs | 75 +-- ppvm-python/src/ppvm/_core.pyi | 2 + ppvm-python/src/ppvm/lindblad.py | 67 ++- .../test/lindblad/_dissipative_refs.py | 195 ++++++++ ppvm-python/test/lindblad/test_kossakowski.py | 170 +++++++ .../test/lindblad/test_orbit_dissipative.py | 232 +++++++++ ppvm-python/test/lindblad/test_word_width.py | 86 ++++ 27 files changed, 3071 insertions(+), 505 deletions(-) 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/kossakowski.rs create mode 100644 crates/ppvm-lindblad/tests/word_width.rs create mode 100644 ppvm-python/test/lindblad/_dissipative_refs.py create mode 100644 ppvm-python/test/lindblad/test_kossakowski.py create mode 100644 ppvm-python/test/lindblad/test_orbit_dissipative.py create mode 100644 ppvm-python/test/lindblad/test_word_width.py diff --git a/Cargo.lock b/Cargo.lock index 874699a71..67e079cc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1343,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" @@ -1738,6 +1846,51 @@ 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" @@ -2036,7 +2189,10 @@ dependencies = [ name = "ppvm-lindblad" version = "0.1.0" dependencies = [ + "approx", + "criterion 0.7.0", "fxhash", + "nalgebra", "ndarray", "num", "ppvm-pauli-sum", @@ -2731,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" @@ -2894,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" @@ -3564,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" diff --git a/crates/ppvm-lindblad/Cargo.toml b/crates/ppvm-lindblad/Cargo.toml index 900388d36..25bd6dd32 100644 --- a/crates/ppvm-lindblad/Cargo.toml +++ b/crates/ppvm-lindblad/Cargo.toml @@ -19,3 +19,16 @@ quspin-expm = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2 # 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 index da44c3042..5fd3f4750 100644 --- a/crates/ppvm-lindblad/src/algebra.rs +++ b/crates/ppvm-lindblad/src/algebra.rs @@ -10,10 +10,57 @@ //! keeps a copy that returns the unpacked `(word, phase)` pair without //! constructing a phased wrapper. -use crate::word::{W_CHUNKS, Word}; +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 { @@ -29,9 +76,9 @@ pub(crate) fn phase_factor(phase: u8) -> Complex { /// 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 { +pub(crate) fn anti_commutes(a: &Word, b: &Word) -> bool { let mut bits: u32 = 0; - for i in 0..W_CHUNKS { + 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(); } @@ -44,7 +91,7 @@ pub(crate) fn anti_commutes(a: &Word, b: &Word) -> bool { /// - `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) { +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, @@ -57,11 +104,11 @@ pub(crate) fn comm_product(h: &Word, p: &Word) -> (Word, f64) { /// 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()); +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..W_CHUNKS { + for i in 0..C { let a = p.xbits.data[i]; let b = p.zbits.data[i]; let c = q.xbits.data[i]; diff --git a/crates/ppvm-lindblad/src/basis.rs b/crates/ppvm-lindblad/src/basis.rs index 64cbeeec8..09e162177 100644 --- a/crates/ppvm-lindblad/src/basis.rs +++ b/crates/ppvm-lindblad/src/basis.rs @@ -18,8 +18,8 @@ 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 { - let mut index: FxHashMap = FxHashMap::default(); +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!( @@ -32,15 +32,15 @@ pub fn build_basis_index(basis: &[Word]) -> FxHashMap { index } -impl LindbladSpec { +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], + basis: &[Word], coeffs: &[f64], - protected: &[Word], - ) -> Result, Error> { + protected: &[Word], + ) -> Result, f64)>, Error> { self.leakage_with_prune(basis, coeffs, protected, usize::MAX, 0.0) } @@ -56,12 +56,12 @@ impl LindbladSpec { /// `room ≥ all candidates`, nothing is dropped — the near-exact case. pub fn leakage_with_prune( &self, - basis: &[Word], + basis: &[Word], coeffs: &[f64], - protected: &[Word], + protected: &[Word], max_basis: usize, tau_add: f64, - ) -> Result, Error> { + ) -> Result, f64)>, Error> { if basis.len() != coeffs.len() { return Err(Error::LengthMismatch { what: "basis and coeffs", @@ -80,16 +80,16 @@ impl LindbladSpec { 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 = FxHashMap::default(); + let mut merged: FxHashMap, f64> = FxHashMap::default(); for chunk_indices in order.chunks(CHUNK_SIZE) { - let local: Vec> = chunk_indices + let local: Vec, f64)>> = chunk_indices .par_iter() .map_init( || { ( Vec::::with_capacity(n_qubits), Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( + FxHashMap::, Complex>::with_capacity_and_hasher( 128, FxBuildHasher::default(), ), @@ -132,7 +132,7 @@ impl LindbladSpec { /// /// Precondition: `basis` must not contain duplicate Pauli words /// (asserted in debug builds). - pub fn generator(&self, basis: &[Word]) -> Vec<(usize, usize, f64)> { + pub fn generator(&self, basis: &[Word]) -> Vec<(usize, usize, f64)> { let index = build_basis_index(basis); let n_qubits = self.n_qubits(); @@ -146,7 +146,7 @@ impl LindbladSpec { ( Vec::::with_capacity(n_qubits), Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( + FxHashMap::, Complex>::with_capacity_and_hasher( 128, FxBuildHasher::default(), ), @@ -178,10 +178,10 @@ impl LindbladSpec { /// component of `L*( Σ_j coeffs[j] · basis[j] )` with complex `coeffs`. pub fn leakage_complex( &self, - basis: &[Word], + basis: &[Word], coeffs: &[Complex], - protected: &[Word], - ) -> Result)>, Error> { + protected: &[Word], + ) -> Result, Complex)>, Error> { if basis.len() != coeffs.len() { return Err(Error::LengthMismatch { what: "basis and coeffs", @@ -194,12 +194,12 @@ impl LindbladSpec { protected.iter().map(|w| (word_hash(w), ())).collect(); let n_qubits = self.n_qubits(); - let mut merged: FxHashMap> = FxHashMap::default(); + 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)>> = chunk_basis + let local: Vec, Complex)>> = chunk_basis .par_iter() .zip(chunk_coeffs.par_iter()) .map_init( @@ -207,7 +207,7 @@ impl LindbladSpec { ( Vec::::with_capacity(n_qubits), Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( + FxHashMap::, Complex>::with_capacity_and_hasher( 128, FxBuildHasher::default(), ), @@ -260,12 +260,12 @@ impl LindbladSpec { /// (room ≥ all candidates) disables the cap — the near-exact case. pub fn leakage_orbit_rep( &self, - basis: &[Word], + basis: &[Word], coeffs: &[Complex], - protected: &[Word], + protected: &[Word], sector: &Sector<'_>, max_basis: usize, - ) -> Result)>, Error> { + ) -> Result, Complex)>, Error> { if basis.len() != coeffs.len() { return Err(Error::LengthMismatch { what: "basis and coeffs", @@ -275,22 +275,22 @@ impl LindbladSpec { } // 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 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> = FxHashMap::default(); + let mut merged: FxHashMap, Complex> = FxHashMap::default(); for chunk_indices in order.chunks(CHUNK_SIZE) { - let local: Vec)>> = chunk_indices + let local: Vec, Complex)>> = chunk_indices .par_iter() .map_init( || { ( Vec::::with_capacity(n_qubits), Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( + FxHashMap::, Complex>::with_capacity_and_hasher( 128, FxBuildHasher::default(), ), diff --git a/crates/ppvm-lindblad/src/error.rs b/crates/ppvm-lindblad/src/error.rs index e0c1eb5f0..1e10c53dd 100644 --- a/crates/ppvm-lindblad/src/error.rs +++ b/crates/ppvm-lindblad/src/error.rs @@ -3,14 +3,15 @@ //! Error type for [`crate::LindbladSpec`] construction and stepping. -use crate::MAX_QUBITS; 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, @@ -34,17 +35,25 @@ pub enum Error { 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 } => { - write!( - f, - "LindbladSpec supports n_qubits ≤ {MAX_QUBITS}; got {got}" - ) + 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}") @@ -68,6 +77,14 @@ impl fmt::Display for Error { "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}"), } } 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 index 57dff8aa2..683e34751 100644 --- a/crates/ppvm-lindblad/src/lib.rs +++ b/crates/ppvm-lindblad/src/lib.rs @@ -28,8 +28,11 @@ //! FP noise). //! //! Pauli strings are stored as [`ppvm_pauli_word::word::PauliWord`] backed by -//! two 64-bit chunks (≤128 qubits; four 32-bit chunks on 32-bit targets) -//! with cached hashes for fast HashMap lookup. The hot-path commutator/ +//! 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. @@ -38,6 +41,7 @@ mod basis; pub mod config; pub mod error; pub(crate) mod expm; +mod kossakowski; mod scalar; pub mod sector; mod spec; @@ -54,7 +58,10 @@ pub use error::Error; pub use sector::{Sector, canonicalize_basis_to_rep}; pub use spec::{JumpInput, LindbladSpec}; pub use step::PcStepTimings; -pub use word::{MAX_QUBITS, Word, codes_from_word, parse_pauli_string, word_from_codes}; +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 index 7f9f58323..2b99be096 100644 --- a/crates/ppvm-lindblad/src/mf_expm.rs +++ b/crates/ppvm-lindblad/src/mf_expm.rs @@ -35,12 +35,105 @@ use rayon::prelude::*; use std::iter::Sum; use std::ops::{AddAssign, Div, Mul, Sub}; -/// CSC columns of a cached in-basis action: `cols[c]` = `(row, coeff)`. -type Cols = Vec>; /// 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. /// @@ -50,42 +143,28 @@ type PerCol = Vec<(f64, T)>; /// 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, -) -> (Cols, PerCol) { - basis - .par_iter() - .map_init( - || { - ( - Vec::::with_capacity(spec.n_qubits()), - Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( - 128, - FxBuildHasher::default(), - ), - ) - }, - |(s1, s2, lm), p| { - let terms = spec.compute_action_terms(p, s1, s2, lm); - let mut out = Vec::with_capacity(terms.len()); - let mut raw = 0.0; - let mut diag = 0.0; - for (w, c) in terms.iter() { - raw += c.abs(); - if w == p { - diag = *c; - } - if let Some(&row) = index.get(w) { - out.push((row, *c)); - } - } - (out, (raw, diag)) - }, - ) - .unzip() +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 @@ -113,53 +192,38 @@ fn build_mf_cols( /// 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, +fn build_orbit_rep_cols( + spec: &LindbladSpec, + basis: &[Word], + index: &FxHashMap, u32>, sector: &Sector<'_>, -) -> (Cols>, PerCol>) { - basis - .par_iter() - .enumerate() - .map_init( - || { - ( - Vec::::with_capacity(spec.n_qubits()), - Vec::::with_capacity(128), - FxHashMap::>::with_capacity_and_hasher( - 128, - FxBuildHasher::default(), - ), - ) - }, - |(s1, s2, lm), (c, r)| { - // 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 (Vec::new(), (0.0, Complex::new(0.0, 0.0))); - }; - let terms = spec.compute_action_terms(r, s1, s2, lm); - let mut out = Vec::with_capacity(terms.len()); - 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; - } - out.push((row, val)); - } +) -> (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; } - (out, (raw, diag)) - }, - ) - .unzip() + rows.push(row); + vals.push(val); + } + } + (raw, diag) + }) } /// Borrowed CSC-style view of an in-basis-restricted generator `M`, backed @@ -172,8 +236,7 @@ fn build_orbit_rep_cols( /// 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 [Vec<(u32, T)>], - pub(crate) dim: usize, + pub(crate) cols: &'a BlockCsc, } impl LinearOperator for CscOp<'_, T> @@ -188,7 +251,7 @@ where + Sync, { fn dim(&self) -> usize { - self.dim + self.cols.dim } fn parallel_hint(&self) -> bool { @@ -199,7 +262,7 @@ where } fn dot(&self, overwrite: bool, input: &[T], output: &mut [T]) -> Result<(), QuSpinError> { - let n = self.dim; + let n = self.cols.dim; if n == 0 { return Ok(()); } @@ -209,22 +272,20 @@ where // 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> = self - .cols - .par_chunks(chunk_size) - .enumerate() - .map(|(chunk_idx, chunk)| { - let c_offset = chunk_idx * chunk_size; + 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]; - for (c_local, col) in chunk.iter().enumerate() { - let xc = input[c_offset + c_local]; + self.cols.for_each_col(cols, |c, rows, vals| { + let xc = input[c]; if xc == T::zero() { - continue; + return; } - for &(row, val) in col.iter() { + for (&row, &val) in rows.iter().zip(vals) { y_local[row as usize] += val * xc; } - } + }); y_local }) .collect(); @@ -305,7 +366,7 @@ where /// `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: &Cols, + cols: &BlockCsc, per_col: &PerCol, dt: f64, coeffs: &[T], @@ -323,7 +384,7 @@ where + From + Sum, { - let n = cols.len(); + let n = cols.dim; let trace: T = per_col.iter().map(|(_, d)| *d).sum(); let mu = trace / n as f64; let onenorm = per_col @@ -333,7 +394,7 @@ where let (m_star, s, expm_tol) = select(dt.abs() * onenorm); let mut v = coeffs.to_vec(); - let op = CscOp { cols, dim: n }; + 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())) @@ -348,9 +409,9 @@ where /// 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], +pub(crate) fn expm_apply_mf( + spec: &LindbladSpec, + basis: &[Word], dt: f64, coeffs: &[f64], drop_tol: f64, @@ -386,9 +447,9 @@ pub(crate) fn expm_apply_mf( /// 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], +pub(crate) fn expm_apply_orbit_rep( + spec: &LindbladSpec, + basis: &[Word], sector: &Sector<'_>, dt: f64, coeffs: &[Complex], diff --git a/crates/ppvm-lindblad/src/sector.rs b/crates/ppvm-lindblad/src/sector.rs index 3973de327..181d9359b 100644 --- a/crates/ppvm-lindblad/src/sector.rs +++ b/crates/ppvm-lindblad/src/sector.rs @@ -88,7 +88,10 @@ impl<'a> Sector<'a> { /// 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)> { + 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)?; @@ -105,7 +108,7 @@ impl<'a> Sector<'a> { /// `ĉ_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 { + pub fn orbit_size(&self, w: &Word) -> Option { self.group .canonicalize_in_sector_indexed(w, &self.characters) .map(|(_, _, orbit_size)| orbit_size) @@ -119,7 +122,7 @@ impl<'a> Sector<'a> { /// /// 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) { +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 index 4a8046eea..013884d0a 100644 --- a/crates/ppvm-lindblad/src/spec.rs +++ b/crates/ppvm-lindblad/src/spec.rs @@ -4,59 +4,35 @@ //! Precompiled Lindbladian: construction and the single-Pauli `L*` kernel. use crate::Error; -use crate::algebra::{anti_commutes, comm_product, pauli_mul, phase_factor}; -use crate::word::{MAX_QUBITS, Word, parse_pauli_string, word_support}; +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, +struct HTerm { + word: Word, coeff: f64, } -/// One Pauli term in a complex linear combination (a single summand of -/// `L = Σ_a λ_a P_a` or of the precomputed `L†L`). -#[derive(Clone)] -struct PauliTerm { - word: Word, - coeff: Complex, -} - -/// One jump operator `L_k` with rate `γ_k`. The `HermitianPauli` variant -/// is a fast path; `General` handles arbitrary complex Pauli sums. -#[derive(Clone)] -enum JumpKind { +/// 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, + word: Word, rate: f64, }, General { - terms: Vec, // L = Σ_a λ_a P_a - dagger_dagger: Vec, // L†L = Σ_c μ_c P_c (μ_c ∈ ℝ) + terms: Vec>, // L = Σ_a λ_a P_a + dagger_dagger: Vec>, // L†L = Σ_c μ_c P_c (μ_c ∈ ℝ) rate: f64, }, -} - -/// Expand `L†L = (Σ_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. Coefficients are -/// real because `L†L` is Hermitian; we keep them complex for arithmetic -/// uniformity. -fn precompute_ldagger_l(terms: &[PauliTerm]) -> Vec { - let zero = Complex::new(0.0, 0.0); - let mut acc: FxHashMap> = FxHashMap::default(); - for a in terms { - for b in 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() > 1e-14) - .map(|(word, coeff)| PauliTerm { word, coeff }) - .collect() + Kossakowski(kossakowski::Pair), } /// Union of `index[q]` for each `q ∈ p_support`, deduped. @@ -76,10 +52,10 @@ fn candidate_terms(p_support: &[u32], index: &[Vec], scratch: &mut Vec /// 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 { +pub struct LindbladSpec { n_qubits: usize, - h_terms: Vec, - j_kinds: Vec, + 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`. @@ -96,7 +72,7 @@ pub struct JumpInput { pub rate: f64, } -impl LindbladSpec { +impl LindbladSpec { /// Construct a Lindbladian spec from Hamiltonian terms and jump operators. /// /// `h_terms` are `(pauli_string, coefficient)` pairs forming the Hermitian @@ -108,11 +84,9 @@ impl LindbladSpec { h_terms: &[(String, f64)], jumps: &[JumpInput], ) -> Result { - if n_qubits > MAX_QUBITS { - return Err(Error::TooManyQubits { got: n_qubits }); - } + check_width::(n_qubits)?; - let mut h_parsed: Vec = Vec::with_capacity(h_terms.len()); + 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)?; @@ -122,7 +96,7 @@ impl LindbladSpec { h_parsed.push(HTerm { word, coeff: *c }); } - let mut j_kinds: Vec = Vec::with_capacity(jumps.len()); + 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 { @@ -150,7 +124,7 @@ impl LindbladSpec { } // General path: parse all terms, precompute L†L, record union support. - let mut terms: Vec = Vec::with_capacity(jump.lincomb.len()); + 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 { @@ -163,7 +137,7 @@ impl LindbladSpec { for q in union_support { j_support_idx[q as usize].push(k as u32); } - let dagger_dagger = precompute_ldagger_l(&terms); + let dagger_dagger = precompute_adag_b(&terms, &terms); j_kinds.push(JumpKind::General { terms, dagger_dagger, @@ -180,6 +154,28 @@ impl LindbladSpec { }) } + /// 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 } @@ -194,8 +190,8 @@ impl LindbladSpec { /// 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 = FxHashMap::default(); + 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); @@ -206,11 +202,11 @@ impl LindbladSpec { /// `L*(p)` contributes (without the input coefficient). pub(crate) fn compute_action_terms( &self, - p: &Word, + p: &Word, scratch_support: &mut Vec, scratch_cands: &mut Vec, - scratch_local: &mut FxHashMap>, - ) -> Vec<(Word, f64)> { + scratch_local: &mut FxHashMap, Complex>, + ) -> Vec<(Word, f64)> { word_support(p, scratch_support); let zero = Complex::new(0.0, 0.0); scratch_local.clear(); @@ -263,6 +259,7 @@ impl LindbladSpec { } } } + JumpKind::Kossakowski(pair) => pair.accumulate(p, local), } } @@ -284,9 +281,9 @@ impl LindbladSpec { /// Accumulate `scale · L*(p)` into `out`. fn accumulate_action( &self, - p: &Word, + p: &Word, scale: f64, - out: &mut FxHashMap, + out: &mut FxHashMap, f64>, scratch_support: &mut Vec, scratch_cands: &mut Vec, ) { diff --git a/crates/ppvm-lindblad/src/step.rs b/crates/ppvm-lindblad/src/step.rs index 97e593ead..debd9666f 100644 --- a/crates/ppvm-lindblad/src/step.rs +++ b/crates/ppvm-lindblad/src/step.rs @@ -50,7 +50,7 @@ impl Phase { } } -impl LindbladSpec { +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 @@ -58,13 +58,21 @@ impl LindbladSpec { /// 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, + basis: &mut Vec>, coeffs: &mut Vec, dt: f64, - protected: &[Word], + protected: &[Word], cfg: &PcStepConfig, ) -> Result<(), Error> { self.run_in_pool(cfg, |this| { @@ -78,10 +86,10 @@ impl LindbladSpec { /// spots. pub fn pc_step_timed( &self, - basis: &mut Vec, + basis: &mut Vec>, coeffs: &mut Vec, dt: f64, - protected: &[Word], + protected: &[Word], cfg: &PcStepConfig, ) -> Result { self.run_in_pool(cfg, |this| { @@ -107,10 +115,10 @@ impl LindbladSpec { fn pc_step_inner( &self, - basis: &mut Vec, + basis: &mut Vec>, coeffs: &mut Vec, dt: f64, - protected: &[Word], + protected: &[Word], cfg: &PcStepConfig, timed: bool, ) -> Result { @@ -149,23 +157,35 @@ impl LindbladSpec { 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. After leakage2 - // we no longer need `coeffs_predict`. Extend `coeffs` with zeros for - // any newly-added second-hop strings so it remains a valid input - // (pre-step state) for the corrector. - let p = Phase::start(timed); - let leak2 = self.leakage_with_prune(basis, &coeffs_predict, protected, admit, tau_add)?; - p.stop(&mut t.leakage2_us); - drop(coeffs_predict); + // 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); + 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. - let p = Phase::start(timed); - *coeffs = self.expm_step(basis, dt, coeffs, drop_tol); - p.stop(&mut t.expm2_us); + // 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); @@ -175,7 +195,7 @@ impl LindbladSpec { /// 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 { + 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) } @@ -202,10 +222,10 @@ impl LindbladSpec { /// Honours `cfg.num_threads` the same way [`Self::pc_step`] does. pub fn pc_step_orbit_rep( &self, - basis: &mut Vec, + basis: &mut Vec>, coeffs: &mut Vec>, dt: f64, - protected: &[Word], + protected: &[Word], sector: &Sector<'_>, cfg: &PcStepConfig, ) -> Result<(), Error> { @@ -216,10 +236,10 @@ impl LindbladSpec { fn pc_step_orbit_rep_inner( &self, - basis: &mut Vec, + basis: &mut Vec>, coeffs: &mut Vec>, dt: f64, - protected: &[Word], + protected: &[Word], sector: &Sector<'_>, cfg: &PcStepConfig, ) -> Result<(), Error> { @@ -250,16 +270,26 @@ impl LindbladSpec { // 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. - let mut leak2 = self.leakage_orbit_rep(basis, &coeffs_predict, protected, sector, admit)?; - drop(coeffs_predict); - if tau_add > 0.0 { - leak2.retain(|(_, c)| c.norm() > tau_add); + // 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); } - add_leakage_capped(basis, coeffs, leak2, admit); - // 4. Corrector: redo from the pre-step state (the basis grew). - *coeffs = mf_expm::expm_apply_orbit_rep(self, basis, sector, dt, coeffs); + // 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); diff --git a/crates/ppvm-lindblad/src/tests.rs b/crates/ppvm-lindblad/src/tests.rs index 7a1e4a81a..8d4651ef0 100644 --- a/crates/ppvm-lindblad/src/tests.rs +++ b/crates/ppvm-lindblad/src/tests.rs @@ -45,13 +45,13 @@ fn jump_hpauli(s: &str, rate: f64) -> JumpInput { #[test] fn z_dephasing_action_on_x() { // L = Z on a single qubit; L*(X) = γ(ZXZ - X) = γ(-X - X) = -2γ X. - let spec = LindbladSpec::new( + 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 (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 @@ -68,10 +68,10 @@ fn amplitude_damping_action_on_z() { ], rate: 1.0, }; - let spec = LindbladSpec::new(1, &[], &[sigma_minus]).unwrap(); - let (z, _) = parse_pauli_string("Z", 1).unwrap(); + 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 (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 { @@ -88,7 +88,7 @@ fn amplitude_damping_action_on_z() { #[test] fn word_codec_roundtrip() { let codes = [0u8, 1, 2, 3, 1, 0, 3, 2]; - let w = word_from_codes(&codes).unwrap(); + 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); @@ -127,11 +127,11 @@ fn assert_orbit_rep_matches_projection( ) { use ppvm_pauli_sum::symmetry::canonicalize_pauli_sum_complex; - let spec = LindbladSpec::new(n, h_terms, &[]).unwrap(); + 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) + .map(|(s, _)| parse_pauli_string::(s, n).unwrap().0) .collect(); let coeffs_full: Vec> = seed.iter().map(|(_, c)| *c).collect(); @@ -252,14 +252,14 @@ fn complex_full_matches_real_at_kzero() { h_terms.push((s.into_iter().collect(), 1.0)); } } - let spec = LindbladSpec::new(n, &h_terms, &[]).unwrap(); + 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(); + let (w, _) = parse_pauli_string::(&st, n).unwrap(); w }) .collect(); @@ -344,7 +344,7 @@ fn pc_step_matches_symmetry_merged_on_small_chain() { } } // No dissipation. - let spec = LindbladSpec::new(n, &h_terms, &[]).unwrap(); + let spec = ::new(n, &h_terms, &[]).unwrap(); let group = TranslationGroup::chain_1d(n); // Initial: O(0) = Σ_j Z_j (translation-invariant). @@ -353,7 +353,7 @@ fn pc_step_matches_symmetry_merged_on_small_chain() { let mut s = vec!['I'; n]; s[j] = 'Z'; let st: String = s.into_iter().collect(); - let (w, _) = parse_pauli_string(&st, n).unwrap(); + let (w, _) = parse_pauli_string::(&st, n).unwrap(); w }) .collect(); diff --git a/crates/ppvm-lindblad/src/truncate.rs b/crates/ppvm-lindblad/src/truncate.rs index c96a927fb..1fe7a9fb3 100644 --- a/crates/ppvm-lindblad/src/truncate.rs +++ b/crates/ppvm-lindblad/src/truncate.rs @@ -24,7 +24,10 @@ pub(crate) fn order_by_desc_mag(coeffs: &[T]) -> Vec { /// 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, room: usize) { +pub(crate) fn cap_map_to_room( + merged: &mut FxHashMap, T>, + room: usize, +) { if merged.len() <= room { return; } @@ -41,17 +44,17 @@ pub(crate) fn cap_map_to_room(merged: &mut FxHashMap, room: u /// 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, +pub(crate) fn prune_basis( + basis: &mut Vec>, coeffs: &mut Vec, drop_tol: f64, - protected: &[Word], + protected: &[Word], ) { if drop_tol <= 0.0 { return; } debug_assert_eq!(basis.len(), coeffs.len()); - let protected_set: FxHashSet<&Word> = protected.iter().collect(); + let protected_set: FxHashSet<&Word> = protected.iter().collect(); retain_in_place(basis, coeffs, |w, c| { c.mag() >= drop_tol || protected_set.contains(w) }); @@ -61,16 +64,16 @@ pub(crate) fn prune_basis( /// `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, +pub(crate) fn cap_basis( + basis: &mut Vec>, coeffs: &mut Vec, max_basis: usize, - protected: &[Word], + protected: &[Word], ) { if basis.len() <= max_basis { return; } - let protected_set: FxHashSet<&Word> = protected.iter().collect(); + 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 @@ -96,10 +99,10 @@ pub(crate) fn cap_basis( /// 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, +pub(crate) fn add_leakage_capped( + basis: &mut Vec>, coeffs: &mut Vec, - mut leak: Vec<(Word, T)>, + mut leak: Vec<(Word, T)>, max_basis: usize, ) { let room = max_basis.saturating_sub(basis.len()); @@ -117,10 +120,10 @@ pub(crate) fn add_leakage_capped( /// Keep the `basis`/`coeffs` entries satisfying `keep`, preserving order, /// by swapping survivors down and truncating. -fn retain_in_place( - basis: &mut Vec, +fn retain_in_place( + basis: &mut Vec>, coeffs: &mut Vec, - mut keep: impl FnMut(&Word, &T) -> bool, + mut keep: impl FnMut(&Word, &T) -> bool, ) { let mut write = 0; for read in 0..basis.len() { diff --git a/crates/ppvm-lindblad/src/word.rs b/crates/ppvm-lindblad/src/word.rs index 15c762d9b..d79e9fe57 100644 --- a/crates/ppvm-lindblad/src/word.rs +++ b/crates/ppvm-lindblad/src/word.rs @@ -16,31 +16,68 @@ pub(crate) type Chunk = u64; #[cfg(not(target_pointer_width = "64"))] pub(crate) type Chunk = u32; -/// Chunks per word; words pack up to 128 qubits on every target. -#[cfg(target_pointer_width = "64")] -pub(crate) const W_CHUNKS: usize = 2; -#[cfg(not(target_pointer_width = "64"))] -pub(crate) const W_CHUNKS: usize = 4; +/// 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 supported by [`Word`]. -pub const MAX_QUBITS: usize = 128; +/// Maximum number of qubits of the default-width [`Word`] (128). +pub const MAX_QUBITS: usize = max_qubits::(); -/// The Pauli-word storage type used throughout this crate. +/// 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. /// -/// `[Chunk; W_CHUNKS]` covers up to 128 qubits; 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; W_CHUNKS], FxBuildHasher, true>; +/// 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 { +pub fn word_from_codes(codes: &[u8]) -> Result, Error> { let n_qubits = codes.len(); - if n_qubits > MAX_QUBITS { - return Err(Error::TooManyQubits { got: n_qubits }); - } - let mut w = Word::new(n_qubits); + 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 }); @@ -57,7 +94,7 @@ pub fn word_from_codes(codes: &[u8]) -> Result { } /// Inverse of [`word_from_codes`]: write `n_qubits` Pauli labels into `out`. -pub fn codes_from_word(w: &Word, out: &mut [u8]) { +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; @@ -68,10 +105,11 @@ pub fn codes_from_word(w: &Word, out: &mut [u8]) { /// 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> { - if n_qubits > MAX_QUBITS { - return Err(Error::TooManyQubits { got: n_qubits }); - } +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 { @@ -79,7 +117,7 @@ pub fn parse_pauli_string(s: &str, n_qubits: usize) -> Result<(Word, Vec), got: chars.len(), }); } - let mut w = Word::new(n_qubits); + let mut w = Word::::new(n_qubits); let mut support = Vec::new(); for (q, c) in chars.into_iter().enumerate() { match c { @@ -105,7 +143,7 @@ pub fn parse_pauli_string(s: &str, n_qubits: usize) -> Result<(Word, Vec), } /// Compute the support (non-identity qubits) of `w`. -pub(crate) fn word_support(w: &Word, out: &mut Vec) { +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] { @@ -120,7 +158,7 @@ pub(crate) fn word_support(w: &Word, out: &mut Vec) { /// cached hash once through `FxHasher` and never touches the 32-byte /// payload. #[inline(always)] -pub(crate) fn word_hash(w: &Word) -> u64 { +pub(crate) fn word_hash(w: &Word) -> u64 { use std::hash::{Hash, Hasher}; let mut h = fxhash::FxHasher::default(); w.hash(&mut h); 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-python-native/src/lindblad.rs b/crates/ppvm-python-native/src/lindblad.rs index 2564f0a9f..796fcb995 100644 --- a/crates/ppvm-python-native/src/lindblad.rs +++ b/crates/ppvm-python-native/src/lindblad.rs @@ -9,12 +9,21 @@ //! 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, Word, word_from_codes}; +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>); @@ -32,8 +41,8 @@ pub(crate) fn map_err(e: ppvm_lindblad::Error) -> PyErr { /// 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()); +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!( @@ -48,21 +57,79 @@ 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>( +fn pack_pauli_map<'py, const C: usize>( py: Python<'py>, - pairs: Vec<(Word, f64)>, + pairs: Vec<(Word, f64)>, n_qubits: usize, ) -> PyResult> { - let (words, coeffs): (Vec, Vec) = pairs.into_iter().unzip(); + 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: CoreSpec, + inner: AnySpec, } #[pymethods] @@ -72,14 +139,22 @@ impl LindbladSpec { /// `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))] + #[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( @@ -96,30 +171,53 @@ impl LindbladSpec { .into_iter() .zip(jump_rates) .map(|(lincomb, rate)| JumpInput { - lincomb: lincomb - .into_iter() - .map(|(s, re, im)| (s, Complex::new(re, im))) - .collect(), + lincomb: to_lincomb(lincomb), rate, }) .collect(); - let inner = CoreSpec::new(n_qubits, &h, &jumps).map_err(map_err)?; + 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 { - self.inner.n_qubits() + with_spec!(&self.inner, inner, C => { + inner.n_qubits() + }) } #[getter] fn num_h_terms(&self) -> usize { - self.inner.num_h_terms() + with_spec!(&self.inner, inner, C => { + inner.num_h_terms() + }) } #[getter] fn num_jump_terms(&self) -> usize { - self.inner.num_jump_terms() + with_spec!(&self.inner, inner, C => { + inner.num_jump_terms() + }) } /// Apply `L*` to a single Pauli string `p`. @@ -128,10 +226,12 @@ impl LindbladSpec { py: Python<'py>, p: PyReadonlyArray1<'py, u8>, ) -> PyResult> { - let p_slice = p.as_slice()?; - let p_word = word_from_codes(p_slice).map_err(map_err)?; - let pairs = self.inner.action(&p_word); - pack_pauli_map(py, pairs, self.inner.n_qubits()) + 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] )`. @@ -143,22 +243,23 @@ impl LindbladSpec { coeffs: PyReadonlyArray1<'py, f64>, protected: Option>, ) -> PyResult> { - let n_q = self.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 = self - .inner - .leakage(&basis_words, coeffs_slice, &protected_words) - .map_err(map_err)?; - pack_pauli_map(py, pairs, n_q) + 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. @@ -199,36 +300,38 @@ impl LindbladSpec { admit_basis: Option, tau_add: Option, ) -> PyResult> { - let n_q = self.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() - }; - self.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)?; + 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) + // 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 @@ -255,44 +358,45 @@ impl LindbladSpec { admit_basis: Option, tau_add: Option, ) -> PyResult<(PyPauliMap<'py>, Bound<'py, pyo3::types::PyDict>)> { - let n_q = self.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 = self - .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)?; + 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)) + 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 @@ -343,56 +447,58 @@ impl LindbladSpec { tau_add: Option, num_threads: Option, ) -> PyResult> { - use num::Complex; - use ppvm_lindblad::{Sector, canonicalize_basis_to_rep}; + with_spec!(&self.inner, inner, C => { + use num::Complex; + use ppvm_lindblad::{Sector, canonicalize_basis_to_rep}; - let n_q = self.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)?; - self.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 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))) + 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)`. @@ -401,24 +507,26 @@ impl LindbladSpec { py: Python<'py>, basis: PyReadonlyArray2<'py, u8>, ) -> PyResult> { - let n_q = self.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 = self.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), - )) + 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 index 45580d638..c7d08b0e9 100644 --- a/crates/ppvm-python-native/src/pauli_arr.rs +++ b/crates/ppvm-python-native/src/pauli_arr.rs @@ -15,11 +15,42 @@ 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( +pub(crate) fn decode_basis( view: &numpy::ndarray::ArrayView2, n_qubits: usize, -) -> PyResult> { +) -> PyResult>> { let n_basis = view.shape()[0]; let n_cols = view.shape()[1]; if n_cols != n_qubits { @@ -40,9 +71,9 @@ pub(crate) fn decode_basis( } /// Encode packed [`Word`]s back into an `(M, n_qubits)` uint8 array. -pub(crate) fn encode_basis<'py>( +pub(crate) fn encode_basis<'py, const C: usize>( py: Python<'py>, - words: &[Word], + words: &[Word], n_qubits: usize, ) -> PyResult>> { let m = words.len(); diff --git a/crates/ppvm-python-native/src/symmetry.rs b/crates/ppvm-python-native/src/symmetry.rs index cd3dfa32b..4d8129a8a 100644 --- a/crates/ppvm-python-native/src/symmetry.rs +++ b/crates/ppvm-python-native/src/symmetry.rs @@ -17,7 +17,7 @@ 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, + check_coeffs_len, check_group_width, check_momentum_len, decode_basis, encode_basis, with_width, }; type PyPauliMap<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray1>); @@ -158,10 +158,13 @@ impl TranslationGroup { self.inner.n_qubits() ))); } - let w = word_from_codes(codes).map_err(|e| PyValueError::new_err(e.to_string()))?; - let canon = self.inner.canonicalize(&w); let mut out = vec![0u8; codes.len()]; - codes_from_word(&canon, &mut out); + 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)) } } @@ -195,25 +198,27 @@ pub fn canonicalize_basis_arr_complex<'py>( 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())?; - 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(); + 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, - ); + 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))) + 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 @@ -238,13 +243,15 @@ pub fn check_momentum_sector_arr<'py>( 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())?; - 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}"))) + 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 @@ -272,11 +279,13 @@ pub fn canonicalize_basis_arr<'py>( let coeffs_slice = coeffs.as_slice()?; check_coeffs_len(coeffs_slice.len(), basis_view.shape()[0])?; - let mut basis_words = decode_basis(&basis_view, n_q)?; - let mut coeffs_vec = coeffs_slice.to_vec(); + 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); + 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))) + let basis_arr = encode_basis(py, &basis_words, n_q)?; + Ok((basis_arr, coeffs_vec.into_pyarray(py))) + }) } diff --git a/ppvm-python/src/ppvm/_core.pyi b/ppvm-python/src/ppvm/_core.pyi index e9877b7f8..86819da03 100644 --- a/ppvm-python/src/ppvm/_core.pyi +++ b/ppvm-python/src/ppvm/_core.pyi @@ -365,6 +365,8 @@ class LindbladSpec: 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: ... diff --git a/ppvm-python/src/ppvm/lindblad.py b/ppvm-python/src/ppvm/lindblad.py index a71dce745..501eb7d12 100644 --- a/ppvm-python/src/ppvm/lindblad.py +++ b/ppvm-python/src/ppvm/lindblad.py @@ -143,7 +143,10 @@ class Lindbladian: Parameters ---------- n_qubits: - Number of 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 @@ -156,6 +159,21 @@ class Lindbladian: 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 -------- @@ -167,13 +185,23 @@ class Lindbladian: >>> 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] = [] @@ -186,7 +214,42 @@ def __init__( for jump_op, rate in jump_terms: j_lincombs.append(_normalize_jump(jump_op)) j_rates.append(float(rate)) - self._spec = _LindbladSpec(self.n_qubits, h_strs, h_coeffs, j_lincombs, j_rates) + 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: 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/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_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_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)