Skip to content

Commit f5487af

Browse files
AlexSchuckertclaude
andcommitted
feat(ppvm-lindblad): continuous-time Pauli propagation — real-space adaptive Lindbladian evolution
Direct Heisenberg-picture evolution O ← exp(dt·L*)O on an adaptively truncated Pauli-string basis: exact in dt within the working basis, no Trotter splitting. LindbladSpec precompiles Hermitian-Pauli (fast diagonal) and general complex Pauli-sum jumps (σ±, amplitude damping, precomputed L†L); pc_step does two-hop leakage enrichment + predictor/ corrector matrix-free exponential via quspin-expm (MIT), with the truncation policy in one PcStepConfig (max_basis rank cap, admit_basis displacement bound, drop_tol prune, tau_add admission filter). Python: ppvm.Lindbladian with numpy hot path + string convenience API, mimalloc global allocator (~50% peak-RSS cut on adaptive paths), two jupytext demos, tests against dense-Liouvillian / closed-form / eig references. Split 2/4 of the CTPP work; the momentum-sector (orbit-rep) evolution follows in split 3. Full history: branch continuous-time-pauli-propagation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 66ed747 commit f5487af

23 files changed

Lines changed: 6146 additions & 51 deletions

Cargo.lock

Lines changed: 1226 additions & 51 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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+
sprs = { version = "0.11", default-features = false, features = ["multi_thread"] }
16+
# Matrix-exponential action (Al-Mohy & Higham). QuSpin-rust is MIT-licensed;
17+
# the pinned rev is the commit that added the LICENSE file.
18+
quspin-expm = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" }
19+
# `QuSpinError` (the error type returned by the `LinearOperator` trait methods
20+
# we implement in `mf_expm.rs`) is not re-exported from `quspin-expm`'s root,
21+
# so we depend on `quspin-types` directly. Same git rev as `quspin-expm`.
22+
quspin-types = { git = "https://github.com/QuSpin/QuSpin-rust", rev = "a0ad6c9fe2e8063208f9ba1c6677150c993bb554" }
23+
24+
[dev-dependencies]
25+
approx = "0.5.1"

crates/ppvm-lindblad/src/config.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//! Configuration objects for the predictor-corrector stepper.
2+
3+
/// Truncation and execution policy for a single predictor-corrector step
4+
/// ([`crate::LindbladSpec::pc_step`], [`crate::LindbladSpec::pc_step_timed`],
5+
/// [`crate::orbit_rep::pc_step_orbit_rep`]).
6+
///
7+
/// These are the per-run *tuning knobs*, kept separate from the per-call data
8+
/// (`basis`, `coeffs`, `dt`, `protected`, and — on the orbit path — the
9+
/// translation group and momentum).
10+
///
11+
/// `max_basis` is the primary accuracy/cost dial; `admit_basis` selects the
12+
/// displacement scheme; `drop_tol` is the churn valve of the admission-bound
13+
/// scheme; `tau_add` is a wall optimization at most.
14+
#[derive(Debug, Clone, Copy)]
15+
pub struct PcStepConfig {
16+
/// Hard rank cap on the retained basis: after the corrector, only the
17+
/// top-`max_basis` strings by `|coeff|` are kept (protected words always
18+
/// survive). The primary convergence dial — verify by re-running at 2×.
19+
pub max_basis: usize,
20+
/// Working-set (admission) bound. When `Some(a)` with `a > max_basis`,
21+
/// enrichment may grow the live basis to `a` and the final cap performs a
22+
/// genuine top-`max_basis`-of-union rank displacement (the analog of
23+
/// two-site TDVP truncation at `χ_max`); `drop_tol` is then not needed
24+
/// for membership turnover. `None` bounds admission by `max_basis`
25+
/// itself — the valve scheme, which requires `drop_tol > 0` to keep the
26+
/// basis adapting once it fills.
27+
pub admit_basis: Option<usize>,
28+
/// Magnitude prune applied after the corrector: basis entries whose
29+
/// `|coeff|` is below `drop_tol` are discarded (protected words are
30+
/// always kept). `<= 0.0` disables pruning — valid only with
31+
/// `admit_basis` set, otherwise the basis freezes once it fills the cap.
32+
pub drop_tol: f64,
33+
/// Optional absolute rate threshold on leakage admission: a candidate is
34+
/// admitted only if its inflow rate exceeds `tau_add`. This is the
35+
/// natural (dt- and drop_tol-independent) parameterization — the
36+
/// admission accuracy cliff sits at a fixed `tau_add`. `None` = no
37+
/// filter, the recommended default with cap-based truncation.
38+
pub tau_add: Option<f64>,
39+
/// When `Some(n)`, run the entire step inside a freshly built rayon
40+
/// thread pool of `n` threads (useful for benchmarking parallel
41+
/// scaling). When `None`, the global rayon pool is used.
42+
pub num_threads: Option<usize>,
43+
}
44+
45+
impl Default for PcStepConfig {
46+
/// Uncapped, unfiltered, no pruning: the near-exact reference
47+
/// configuration. Production runs should set `max_basis` (and usually
48+
/// `admit_basis ≈ 2-3×` it).
49+
fn default() -> Self {
50+
Self {
51+
max_basis: usize::MAX,
52+
admit_basis: None,
53+
drop_tol: 0.0,
54+
tau_add: None,
55+
num_threads: None,
56+
}
57+
}
58+
}

crates/ppvm-lindblad/src/error.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! Error type for [`crate::LindbladSpec`] construction and stepping.
2+
3+
use crate::MAX_QUBITS;
4+
use std::fmt;
5+
6+
/// Errors raised when constructing a [`crate::LindbladSpec`].
7+
#[derive(Debug, Clone)]
8+
pub enum Error {
9+
TooManyQubits {
10+
got: usize,
11+
},
12+
LengthMismatch {
13+
what: &'static str,
14+
a: usize,
15+
b: usize,
16+
},
17+
InvalidPauliCode {
18+
code: u8,
19+
},
20+
InvalidPauliChar {
21+
c: char,
22+
},
23+
WrongLength {
24+
expected: usize,
25+
got: usize,
26+
},
27+
NegativeRate {
28+
index: usize,
29+
rate: f64,
30+
},
31+
EmptyLincomb {
32+
index: usize,
33+
},
34+
Internal(String),
35+
}
36+
37+
impl fmt::Display for Error {
38+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39+
match self {
40+
Error::TooManyQubits { got } => {
41+
write!(
42+
f,
43+
"LindbladSpec supports n_qubits ≤ {MAX_QUBITS}; got {got}"
44+
)
45+
}
46+
Error::LengthMismatch { what, a, b } => {
47+
write!(f, "{what}: expected matching lengths, got {a} and {b}")
48+
}
49+
Error::InvalidPauliCode { code } => write!(
50+
f,
51+
"Pauli code must be 0 (I), 1 (X), 2 (Z), or 3 (Y); got {code}"
52+
),
53+
Error::InvalidPauliChar { c } => {
54+
write!(f, "invalid Pauli character '{c}'; expected I, X, Y, or Z")
55+
}
56+
Error::WrongLength { expected, got } => {
57+
write!(f, "Pauli string has length {got} but n_qubits = {expected}")
58+
}
59+
Error::NegativeRate { index, rate } => {
60+
write!(f, "jump rate must be non-negative; got γ_{index} = {rate}")
61+
}
62+
Error::EmptyLincomb { index } => {
63+
write!(
64+
f,
65+
"jump {index}: lincomb must contain at least one Pauli term"
66+
)
67+
}
68+
Error::Internal(msg) => write!(f, "internal error: {msg}"),
69+
}
70+
}
71+
}
72+
73+
impl std::error::Error for Error {}

crates/ppvm-lindblad/src/expm.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// SPDX-FileCopyrightText: 2026 The PPVM Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Taylor-partition selection for the `quspin-expm`-backed `exp(t·A)·b`
5+
//! engine (driven from [`crate::mf_expm`]): the `(m, s)` selection tables
6+
//! [`THETA`] / [`THETA_LOOSE`] from Al-Mohy & Higham (2011), used to pick
7+
//! the partition handed to `quspin-expm`'s `from_parts`.
8+
9+
/// `θ_m` table from Al-Mohy & Higham (2011), Table A.3, for double
10+
/// precision (unit roundoff `u = 2^{-53}`).
11+
///
12+
/// `θ_m` bounds `‖A‖₁` such that the degree-`m` Taylor polynomial
13+
/// approximates `exp(A)` to within `u`. We pick `(m, s)` with
14+
/// `s ≥ ⌈‖tA‖₁ / θ_m⌉` and minimise `m·s` (total SpMV count).
15+
pub(crate) const THETA: &[(u32, f64)] = &[
16+
(1, 2.29e-16),
17+
(2, 2.58e-8),
18+
(3, 1.39e-5),
19+
(4, 3.40e-4),
20+
(5, 2.40e-3),
21+
(6, 9.07e-3),
22+
(7, 2.38e-2),
23+
(8, 5.00e-2),
24+
(9, 8.96e-2),
25+
(10, 1.44e-1),
26+
(11, 2.14e-1),
27+
(12, 3.00e-1),
28+
(13, 4.00e-1),
29+
(14, 5.14e-1),
30+
(15, 6.41e-1),
31+
(16, 7.81e-1),
32+
(17, 9.31e-1),
33+
(18, 1.09),
34+
(19, 1.26),
35+
(20, 1.44),
36+
(21, 1.62),
37+
(22, 1.82),
38+
(23, 2.01),
39+
(24, 2.22),
40+
(25, 2.43),
41+
(26, 2.64),
42+
(27, 2.86),
43+
(28, 3.08),
44+
(29, 3.31),
45+
(30, 3.54),
46+
];
47+
48+
/// `θ_m` table for a relaxed backward-error tolerance `tol = 1e-6`, computed
49+
/// with the same Al-Mohy & Higham (2011) construction as [`THETA`] (the
50+
/// backward-error series `h_{m+1}(x) = log(e^{-x} T_m(x))`; validated by
51+
/// reproducing the `u = 2^{-53}` table above to ~2 significant figures).
52+
///
53+
/// The predictor-corrector truncates the Pauli basis at `drop_tol` (typically
54+
/// 1e-3), so computing `exp` to double-precision backward error (~1e-16) is
55+
/// ~10 orders more accurate than the state it acts on. Using `tol = 1e-6`
56+
/// (still ~1000x tighter than the truncation) admits a lower-degree Taylor
57+
/// polynomial for the same `‖tA‖`, cutting the SpMV count (e.g. 23 -> 13 at
58+
/// `‖tA‖ ≈ 2`) with no measurable effect on the truncated result.
59+
pub(crate) const THETA_LOOSE: &[(u32, f64)] = &[
60+
(1, 2.000e-06),
61+
(2, 2.447e-03),
62+
(3, 2.863e-02),
63+
(4, 1.025e-01),
64+
(5, 2.262e-01),
65+
(6, 3.911e-01),
66+
(7, 5.866e-01),
67+
(8, 8.045e-01),
68+
(9, 1.039),
69+
(10, 1.285),
70+
(11, 1.539),
71+
(12, 1.801),
72+
(13, 2.067),
73+
(14, 2.337),
74+
(15, 2.610),
75+
(16, 2.885),
76+
(17, 3.162),
77+
(18, 3.441),
78+
(19, 3.721),
79+
(20, 4.001),
80+
(21, 4.282),
81+
(22, 4.564),
82+
(23, 4.847),
83+
(24, 5.129),
84+
(25, 5.412),
85+
(26, 5.696),
86+
(27, 5.979),
87+
(28, 6.263),
88+
(29, 6.546),
89+
(30, 6.830),
90+
];
91+
92+
/// Pick `(m, s)` minimising `s·m` subject to `s ≥ ⌈t_norm / θ_m⌉, s ≥ 1`,
93+
/// using the `θ_m` table `theta`. Restricted to the table's `m` range; for
94+
/// larger norms `s` simply grows linearly.
95+
fn select_ms_with(t_norm: f64, theta: &[(u32, f64)]) -> (u32, u32) {
96+
if t_norm <= 0.0 {
97+
return (1, 1);
98+
}
99+
let mut best_m = 1u32;
100+
let mut best_s = 1u32;
101+
let mut best_cost = u64::MAX;
102+
for &(m, th) in theta {
103+
let s_f = (t_norm / th).ceil();
104+
let s = if s_f >= 1.0 { s_f as u32 } else { 1 };
105+
let cost = (m as u64) * (s as u64);
106+
if cost < best_cost {
107+
best_cost = cost;
108+
best_m = m;
109+
best_s = s;
110+
}
111+
}
112+
(best_m, best_s)
113+
}
114+
115+
/// `(m, s)` selection at double-precision backward error ([`THETA`]).
116+
pub(crate) fn select_ms(t_norm: f64) -> (u32, u32) {
117+
select_ms_with(t_norm, THETA)
118+
}
119+
120+
/// `(m, s)` selection at the relaxed `tol = 1e-6` backward error
121+
/// ([`THETA_LOOSE`]) — fewer SpMVs, used on the truncated PC expm path.
122+
pub(crate) fn select_ms_loose(t_norm: f64) -> (u32, u32) {
123+
select_ms_with(t_norm, THETA_LOOSE)
124+
}
125+
126+
#[cfg(test)]
127+
mod tests {
128+
use super::*;
129+
130+
#[test]
131+
fn ms_selection_sane() {
132+
// tiny norm → small m, s = 1
133+
let (m, s) = select_ms(1e-9);
134+
assert!(m <= 5, "expected small m for tiny norm, got m={m}");
135+
assert_eq!(s, 1);
136+
137+
// moderate norm → m·s should be ~10-50
138+
let (m, s) = select_ms(1.0);
139+
assert!((m * s) <= 50, "moderate norm cost too high: m={m} s={s}");
140+
141+
// large norm → s grows
142+
let (_m, s) = select_ms(100.0);
143+
assert!(s >= 20, "large norm should require many steps, got s={s}");
144+
}
145+
}

0 commit comments

Comments
 (0)