Skip to content

Commit 99ca34f

Browse files
AlexSchuckertclaudedavid-plcursoragent
authored
Translation symmetry: TranslationGroup + orbit-representative Pauli-sum merging (#180)
**PR 1 of 4** splitting #178 into reviewable chunks (this → CTPP core → symmetry-merged evolution → ledgers). Full development history: branch [`continuous-time-pauli-propagation`](https://github.com/QuEraComputing/ppvm/tree/continuous-time-pauli-propagation). Adds `ppvm_pauli_sum::symmetry` (pure Rust, no new dependencies): - **`TranslationGroup`**: finite abelian permutation groups — 1D chains, 2D/3D tori, multi-leg ladders, or arbitrary generator lists — with lex-min orbit canonicalization, shift counters, and momentum-sector characters `χ_k(g)`. - **k = 0 merging**: `canonicalize_pauli_sum` (Vec-pair form used by the upcoming adaptive evolution) and `symmetry_merge_pauli_sum` (`PauliSum` form). Preserves all G-invariant expectation values for G-commuting dynamics (Theorem 1 of Teng, Chang, Rudolph & Holmes, arXiv:2512.12094). - **k ≠ 0**: `canonicalize_pauli_sum_complex` (character-weighted projection with 1/|G| normalization) and `check_momentum_sector` to validate inputs before projecting (silent projection discards physics). Tests: canonicalization/orbit properties on chains, tori, ladders; merge correctness; momentum-eigenstate round trips; a Trotter end-to-end check that per-step merging matches merge-at-end in the dt → 0 limit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: David Plankensteiner <david-pl@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: David Plankensteiner <dplankensteiner@quera.com>
1 parent 97a2142 commit 99ca34f

62 files changed

Lines changed: 13694 additions & 59 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Cargo.lock‎

Lines changed: 1356 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ members = [
1414
"crates/ppvm-pauli-word",
1515
"crates/ppvm-pauli-sum",
1616
"crates/ppvm-sym",
17+
"crates/ppvm-lindblad",
1718
"crates/ppvm-python-native",
1819
"crates/ppvm-tableau",
1920
"crates/ppvm-stim", "crates/stim-parser",

‎crates/ppvm-lindblad/Cargo.toml‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[package]
2+
name = "ppvm-lindblad"
3+
version = "0.1.0"
4+
edition = "2024"
5+
description = "Direct Heisenberg-picture Lindbladian evolution on an adaptive Pauli-string basis."
6+
7+
[dependencies]
8+
fxhash = "0.2.1"
9+
ndarray = "0.17"
10+
num = "0.4.3"
11+
ppvm-traits = { version = "0.1.0", path = "../ppvm-traits" }
12+
ppvm-pauli-word = { version = "0.1.0", path = "../ppvm-pauli-word" }
13+
ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" }
14+
rayon = "1.11"
15+
# Matrix-exponential action (Al-Mohy & Higham). QuSpin-rust is MIT-licensed;
16+
# the pinned rev is the commit that added the LICENSE file.
17+
quspin-expm = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" }
18+
# `QuSpinError` (the error type returned by the `LinearOperator` trait methods
19+
# we implement in `mf_expm.rs`) is not re-exported from `quspin-expm`'s root,
20+
# so we depend on `quspin-types` directly. Same git rev as `quspin-expm`.
21+
quspin-types = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" }
22+
23+
[dev-dependencies]
24+
approx = "0.5.1"
25+
criterion = "0.7.0"
26+
nalgebra = "0.34"
27+
28+
[[bench]]
29+
name = "kossakowski"
30+
harness = false
31+
32+
[[bench]]
33+
name = "drug_dipolar"
34+
harness = false
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// SPDX-FileCopyrightText: 2026 The PPVM Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Per-step cost of the Kossakowski-form dissipator on a *molecular dipolar
5+
//! relaxation* workload (the ZULF-NMR drug-FID application), which stresses
6+
//! the path differently from the superradiance chain in `kossakowski.rs`:
7+
//!
8+
//! - operators are **2-local rank-2 tensors** with ~4 Pauli terms each (one
9+
//! channel per site pair, per spatial harmonic `m`), not single-site σ⁻;
10+
//! - `K` is **block-diagonal** over the 5 spatial components `m ∈ −2..2`,
11+
//! each block a dense `P×P` Gram matrix over the `P = C(N,2)` pairs;
12+
//! - the basis strings are dense/high-weight, so most candidate pairs hit
13+
//! the **both-sided sandwich** (12-product) path — the arm the 2026-07-16
14+
//! ledger flagged as remaining headroom.
15+
//!
16+
//! Both specs (eigenmode jumps vs Kossakowski) generate the identical action;
17+
//! the benchmark measures representation cost on one full `pc_step`.
18+
19+
use criterion::{Criterion, criterion_group, criterion_main};
20+
use num::Complex;
21+
use ppvm_lindblad::{JumpInput, LindbladSpec, PcStepConfig, W_CHUNKS, Word, parse_pauli_string};
22+
use std::hint::black_box;
23+
24+
const B: usize = 4096;
25+
const GROW_STEPS: usize = 3;
26+
const DT: f64 = 1e-3;
27+
const N_M: usize = 5;
28+
29+
/// Dipolar-pair geometry: the `(a, b)` site pairs, each pair's coupling
30+
/// magnitude, and its unit separation vector.
31+
type Geometry = (Vec<(usize, usize)>, Vec<f64>, Vec<[f64; 3]>);
32+
33+
/// A Kossakowski dissipator as handed to `LindbladSpec::add_kossakowski`:
34+
/// the operators `A_n` as Pauli lincombs, and the pair matrix `K`.
35+
type KossakowskiModel = (Vec<Vec<(String, Complex<f64>)>>, Vec<Vec<Complex<f64>>>);
36+
// spatial harmonics m = -2..2
37+
38+
fn pstr(n: usize, sites: &[(usize, char)]) -> String {
39+
let mut s = vec!['I'; n];
40+
for &(q, c) in sites {
41+
s[q] = c;
42+
}
43+
s.into_iter().collect()
44+
}
45+
46+
/// Deterministic pseudo-random 3D unit direction + distance for pair (a,b),
47+
/// so the model is reproducible without an RNG dependency in the bench.
48+
fn hashf(mut x: u64) -> f64 {
49+
x ^= x >> 33;
50+
x = x.wrapping_mul(0xff51afd7ed558ccd);
51+
x ^= x >> 33;
52+
(x >> 11) as f64 / (1u64 << 53) as f64
53+
}
54+
55+
/// Dipolar coupling `b` and unit vector for every pair, from placing spins on
56+
/// a jittered chain (real molecules: `b ∝ 1/r³`, generic directions).
57+
fn geometry(n: usize) -> Geometry {
58+
let pos: Vec<[f64; 3]> = (0..n)
59+
.map(|i| {
60+
[
61+
i as f64 + 0.3 * hashf(i as u64 * 3 + 1),
62+
0.4 * hashf(i as u64 * 3 + 2),
63+
0.4 * hashf(i as u64 * 3 + 3),
64+
]
65+
})
66+
.collect();
67+
let mut pairs = Vec::new();
68+
let mut bmag = Vec::new();
69+
let mut dir = Vec::new();
70+
for a in 0..n {
71+
for b in (a + 1)..n {
72+
let d = [
73+
pos[a][0] - pos[b][0],
74+
pos[a][1] - pos[b][1],
75+
pos[a][2] - pos[b][2],
76+
];
77+
let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
78+
pairs.push((a, b));
79+
bmag.push(1.0 / (r * r * r));
80+
dir.push([d[0] / r, d[1] / r, d[2] / r]);
81+
}
82+
}
83+
(pairs, bmag, dir)
84+
}
85+
86+
/// Real rank-2 spherical harmonics (up to normalization) of a unit vector,
87+
/// ordered m = -2,-1,0,1,2 — the spatial factors that make `Γ` rank 5.
88+
fn y2(u: &[f64; 3]) -> [f64; N_M] {
89+
let (x, y, z) = (u[0], u[1], u[2]);
90+
[
91+
x * y,
92+
y * z,
93+
(3.0 * z * z - 1.0) / 2.0,
94+
x * z,
95+
(x * x - y * y) / 2.0,
96+
]
97+
}
98+
99+
/// The 2-local rank-2 tensor operator on pair (a,b) for tensor component
100+
/// `mt` — a representative 4-term Pauli lincomb matching the high-field
101+
/// dressed-tensor forms (T^(2,±2): XX∓YY ± i(XY±YX), etc.). The exact
102+
/// coefficients are immaterial to the cost profile; the term *count* and
103+
/// 2-locality are what matter.
104+
fn tensor_op(n: usize, a: usize, b: usize, mt: usize) -> Vec<(String, Complex<f64>)> {
105+
let (i, j) = (Complex::new(0.0, 1.0), Complex::new(1.0, 0.0));
106+
match mt {
107+
0 | 4 => {
108+
let s = if mt == 0 { i } else { -i }; // ±2 components
109+
vec![
110+
(pstr(n, &[(a, 'X'), (b, 'X')]), j),
111+
(pstr(n, &[(a, 'Y'), (b, 'Y')]), -j),
112+
(pstr(n, &[(a, 'X'), (b, 'Y')]), s),
113+
(pstr(n, &[(a, 'Y'), (b, 'X')]), s),
114+
]
115+
}
116+
1 | 3 => {
117+
let s = if mt == 1 { i } else { -i }; // ±1 components
118+
vec![
119+
(pstr(n, &[(a, 'X'), (b, 'Z')]), j),
120+
(pstr(n, &[(a, 'Y'), (b, 'Z')]), s),
121+
(pstr(n, &[(a, 'Z'), (b, 'X')]), j),
122+
(pstr(n, &[(a, 'Z'), (b, 'Y')]), s),
123+
]
124+
}
125+
_ => vec![
126+
// m = 0
127+
(pstr(n, &[(a, 'X'), (b, 'X')]), j),
128+
(pstr(n, &[(a, 'Y'), (b, 'Y')]), j),
129+
(pstr(n, &[(a, 'Z'), (b, 'Z')]), Complex::new(2.0, 0.0)),
130+
],
131+
}
132+
}
133+
134+
fn hamiltonian_terms(n: usize, pairs: &[(usize, usize)], bmag: &[f64]) -> Vec<(String, f64)> {
135+
let mut h = Vec::new();
136+
for (k, &(a, b)) in pairs.iter().enumerate() {
137+
let jc = 0.1 * bmag[k]; // scalar J-coupling, XX+YY
138+
h.push((pstr(n, &[(a, 'X'), (b, 'X')]), jc));
139+
h.push((pstr(n, &[(a, 'Y'), (b, 'Y')]), jc));
140+
}
141+
h
142+
}
143+
144+
/// Kossakowski ops (`N_M` blocks of `P` pair tensors) and the block-diagonal
145+
/// `K = blockdiag(Γ_m)`, `Γ_m[μν] = Σ_{m'} c_μ^{m'} c_ν^{m'}` with
146+
/// `c_μ^{m'} = b_μ Y_2^{m'}(r̂_μ)` — a rank-5 Gram block, exactly as the drug
147+
/// pickles decompose.
148+
fn kossakowski_model(n: usize) -> KossakowskiModel {
149+
let (pairs, bmag, dir) = geometry(n);
150+
let p = pairs.len();
151+
let c: Vec<[f64; N_M]> = (0..p)
152+
.map(|k| {
153+
let y = y2(&dir[k]);
154+
std::array::from_fn(|mp| bmag[k] * y[mp])
155+
})
156+
.collect();
157+
let mut ops = Vec::with_capacity(N_M * p);
158+
for mt in 0..N_M {
159+
for &(a, b) in &pairs {
160+
ops.push(tensor_op(n, a, b, mt));
161+
}
162+
}
163+
let m_ops = N_M * p;
164+
let mut k = vec![vec![Complex::new(0.0, 0.0); m_ops]; m_ops];
165+
for mt in 0..N_M {
166+
let off = mt * p;
167+
for mu in 0..p {
168+
for nu in 0..p {
169+
let g: f64 = (0..N_M).map(|mp| c[mu][mp] * c[nu][mp]).sum();
170+
k[off + mu][off + nu] = Complex::new(g, 0.0);
171+
}
172+
}
173+
}
174+
(ops, k)
175+
}
176+
177+
/// Eigenmode jumps of the block-diagonal `K` (the dense representation the
178+
/// Kossakowski path replaces): per block, `L_ν = √γ_ν Σ_μ V_μν T_μ`.
179+
fn eigenmode_jumps(ops: &[Vec<(String, Complex<f64>)>], k: &[Vec<Complex<f64>>]) -> Vec<JumpInput> {
180+
let m_ops = ops.len();
181+
let p = m_ops / N_M;
182+
let mut jumps = Vec::new();
183+
for mt in 0..N_M {
184+
let off = mt * p;
185+
let block = nalgebra::DMatrix::from_fn(p, p, |a, b| k[off + a][off + b].re);
186+
let eig = nalgebra::SymmetricEigen::new(block);
187+
for nu in 0..p {
188+
let g = eig.eigenvalues[nu];
189+
if g < 1e-12 {
190+
continue;
191+
}
192+
let mut lin = Vec::new();
193+
for mu in 0..p {
194+
let v = eig.eigenvectors[(mu, nu)];
195+
if v.abs() > 1e-14 {
196+
for (s, cc) in &ops[off + mu] {
197+
lin.push((s.clone(), cc * Complex::new(v, 0.0)));
198+
}
199+
}
200+
}
201+
jumps.push(JumpInput {
202+
lincomb: lin,
203+
rate: g,
204+
});
205+
}
206+
}
207+
jumps
208+
}
209+
210+
/// Initial observable: γ-weighted transverse magnetization Σ_i X_i (the coil
211+
/// quadrature), a sparse single-site sum like the drug FID initial operator.
212+
fn observable(n: usize) -> (Vec<Word>, Vec<f64>) {
213+
let mut basis = Vec::new();
214+
let mut coeffs = Vec::new();
215+
for a in 0..n {
216+
basis.push(
217+
parse_pauli_string::<W_CHUNKS>(&pstr(n, &[(a, 'X')]), n)
218+
.unwrap()
219+
.0,
220+
);
221+
coeffs.push(1.0);
222+
}
223+
(basis, coeffs)
224+
}
225+
226+
fn bench_drug(c: &mut Criterion) {
227+
let mut group = c.benchmark_group("pc_step_drug_dipolar");
228+
group.sample_size(10);
229+
for n in [10usize, 20, 32] {
230+
let (pairs, bmag, _) = geometry(n);
231+
let h = hamiltonian_terms(n, &pairs, &bmag);
232+
let (ops, k) = kossakowski_model(n);
233+
234+
let spec_eig = <LindbladSpec>::new(n, &h, &eigenmode_jumps(&ops, &k)).unwrap();
235+
let mut spec_koss = <LindbladSpec>::new(n, &h, &[]).unwrap();
236+
spec_koss.add_kossakowski(&ops, &k).unwrap();
237+
238+
let cfg = PcStepConfig {
239+
max_basis: B,
240+
admit_basis: Some(3 * B),
241+
..Default::default()
242+
};
243+
let (mut basis, mut coeffs) = observable(n);
244+
for _ in 0..GROW_STEPS {
245+
spec_koss
246+
.pc_step(&mut basis, &mut coeffs, DT, &[], &cfg)
247+
.unwrap();
248+
}
249+
250+
for (label, spec) in [("eigenmode", &spec_eig), ("kossakowski", &spec_koss)] {
251+
group.bench_function(format!("{label}_n{n}"), |bch| {
252+
bch.iter(|| {
253+
let mut b = basis.clone();
254+
let mut cf = coeffs.clone();
255+
spec.pc_step(&mut b, &mut cf, DT, &[], &cfg).unwrap();
256+
black_box(cf.len())
257+
})
258+
});
259+
}
260+
}
261+
group.finish();
262+
}
263+
264+
criterion_group!(benches, bench_drug);
265+
criterion_main!(benches);

0 commit comments

Comments
 (0)