1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
//! Dependency-free preconditioned CG solver for `(I + Δt·Â) x = b` (§59, ADR-0190).
//!
//! ## Design
//!
//! [`pcg_shifted`] solves the shifted SPD system `S x = b` where `S = I + Δt·Â` by
//! preconditioned Conjugate Gradient, reusing `SymmetricLinearOp::apply_into_slice`.
//! No new dependency; no heap allocation inside the CG loop (scratch pool, §ADR-0041).
//!
//! [`implicit_euler_action`] builds the preconditioner ONCE per `(Â, Δt)` and loops
//! `n_steps` PCG solves to compute `(I + Δt·Â)^{-n_steps} · w0` (backward-Euler, §59.1).
//!
//! The same CG loop also serves the SPD resolvent `(λ·diag(m) + A) x = b`
//! (ADR-0202, §62.2.b) through the second [`Shift`] variant; the `ImplicitEuler`
//! variant keeps its original expressions, so its numerics are bit-identical.
//!
//! **SPD guarantee (§59.3):** `σ(I + Δt·Â) ⊂ [1, 1+Δt·λ_max] ⊂ (0,∞)` for every
//! `Δt > 0`, even when `Â` is singular — CG cannot break down.
use alloc::vec::Vec;
// `f64` has inherent math methods only when `std` is linked; otherwise they come
// from `num_traits::Float` (libm). Test builds link `std` even without the
// feature (harness, dev-dependencies), hence the `allow`.
#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
use num_traits::Float;
use crate::{
error::SemiflowError, float::SemiflowFloat, scratch::ScratchPool,
symmetric_operator::SymmetricLinearOp,
};
// ── Preconditioner trait ──────────────────────────────────────────────────────
/// Applied once per CG iteration: `z ← P⁻¹ r` (§59.2).
///
/// Built once per `(Â, Δt)` and reused across all sub-steps and channels.
pub(crate) trait Preconditioner<F: SemiflowFloat> {
fn apply(&self, r: &[F], z: &mut [F]);
}
// ── Jacobi preconditioner ─────────────────────────────────────────────────────
/// Diagonal preconditioner `P = diag(I + Δt·Â)` (§59.2 v1 default).
///
/// Built from [`SymmetricLinearOp::diagonal_into`]: an `O(nnz)` CSR read for
/// `Laplacian` / `SymmetricOperator` (it used to probe `n` unit vectors, `O(n·nnz)`,
/// although this comment always promised `O(nnz)`); the values are identical.
#[derive(Clone)]
pub(crate) struct Jacobi<F> {
inv_diag: Vec<F>,
}
impl<F: SemiflowFloat> Jacobi<F> {
/// `P = diag(S)` from an explicit positive diagonal (SPD resolvent, §62.2.b).
///
/// The caller guarantees `diag[i] > 0` (checked in `SpdResolvent::new`).
pub(crate) fn from_diag(diag: &[F]) -> Self {
Self {
inv_diag: diag.iter().map(|&d| F::one() / d).collect(),
}
}
/// `inv_diag[i] = 1 / (1 + Δt · Â[i,i])`. If a pivot is non-positive
/// (unexpected for SPD `S`) it is silently replaced by `1` (§59.2 fallback).
pub(crate) fn build(
op: &dyn SymmetricLinearOp<F>,
dt: F,
scratch: &mut ScratchPool<F>,
) -> Self {
let n = op.n();
let tiny = F::from(1e-300_f64).unwrap();
let mut diag = scratch.take_vec(n);
op.diagonal_into(&mut diag);
let inv_diag = diag
.iter()
.map(|&a_ii| {
let s_ii = F::one() + dt * a_ii;
F::one() / if s_ii > tiny { s_ii } else { F::one() }
})
.collect();
scratch.return_vec(diag);
Self { inv_diag }
}
}
impl<F: SemiflowFloat> Preconditioner<F> for Jacobi<F> {
fn apply(&self, r: &[F], z: &mut [F]) {
for (zi, (ri, &pi)) in z.iter_mut().zip(r.iter().zip(self.inv_diag.iter())) {
*zi = *ri * pi;
}
}
}
// ── BLAS-like helpers (no heap, all slice-based) ──────────────────────────────
fn vec_norm_sq<F: SemiflowFloat>(v: &[F]) -> F {
v.iter().fold(F::zero(), |s, &x| s + x * x)
}
fn vec_dot<F: SemiflowFloat>(a: &[F], b: &[F]) -> F {
a.iter()
.zip(b.iter())
.fold(F::zero(), |s, (&ai, &bi)| s + ai * bi)
}
/// Which shifted SPD matrix `S` the CG loop solves with.
///
/// - `Euler { dt }`: `S = I + dt·A` (ADR-0190 backward Euler). Its expressions are
/// unchanged since §59, so `ImplicitEuler` stays bit-identical.
/// - `Mass { lambda, mass }`: `S = λ·diag(m) + A` (ADR-0202 resolvent, §62.2.b).
/// Not rewritten as `λ = 1/dt`: that would change the rounding of the Euler path.
pub(crate) enum Shift<'a, F> {
Euler { dt: F },
Mass { lambda: F, mass: &'a [F] },
}
impl<F: SemiflowFloat> Shift<'_, F> {
/// `r ← b − S·x`, using `sp` as temporary for `A·x`.
fn residual(&self, op: &dyn SymmetricLinearOp<F>, b: &[F], x: &[F], sp: &mut [F], r: &mut [F]) {
op.apply_into_slice(x, sp); // sp = A·x
match self {
Shift::Euler { dt } => {
for i in 0..x.len() {
r[i] = b[i] - x[i] - *dt * sp[i]; // r = b - S·x
}
}
Shift::Mass { lambda, mass } => {
for i in 0..x.len() {
r[i] = b[i] - (*lambda * mass[i] * x[i] + sp[i]);
}
}
}
}
/// `sp ← S·p` (one matvec).
fn matvec(&self, op: &dyn SymmetricLinearOp<F>, p: &[F], sp: &mut [F]) {
op.apply_into_slice(p, sp); // sp = A·p
match self {
Shift::Euler { dt } => {
for i in 0..p.len() {
sp[i] = p[i] + *dt * sp[i]; // sp = S·p
}
}
Shift::Mass { lambda, mass } => {
for i in 0..p.len() {
sp[i] = *lambda * mass[i] * p[i] + sp[i];
}
}
}
}
}
/// Returns `Some(‖b‖²)` when ‖b‖ ≥ 1e-300; zeroes `x` and returns `None` otherwise.
///
/// Short-circuits `pcg_shifted` for a zero right-hand side.
fn rhs_norm_sq_or_zero_out<F: SemiflowFloat>(b: &[F], x: &mut [F]) -> Option<F> {
let b_norm_sq = vec_norm_sq(b);
if b_norm_sq < F::from(1e-300_f64).unwrap() {
for xi in x.iter_mut() {
*xi = F::zero();
}
return None;
}
Some(b_norm_sq)
}
// ── PCG solver ────────────────────────────────────────────────────────────────
/// Solve `S x = b` (`S` given by `shift`) by preconditioned CG (§59.4, §62.2.b).
///
/// `x` on entry: warm start (caller sets `x ← b`).
/// `x` on exit: solution or last iterate on failure.
///
/// Returns `Ok(iters)` on convergence; `Err(ConvergenceFailed)` otherwise.
/// Borrows four scratch vectors and releases them before returning — no allocation.
// 8 args by necessity — op/shift/b/x/precond/tol/max_iter/scratch: no grouping possible.
#[allow(clippy::too_many_arguments, clippy::many_single_char_names)]
pub(crate) fn pcg_shifted<F: SemiflowFloat>(
op: &dyn SymmetricLinearOp<F>,
shift: &Shift<'_, F>,
b: &[F],
x: &mut [F],
precond: &dyn Preconditioner<F>,
tol_cg: F,
max_iter: usize,
scratch: &mut ScratchPool<F>,
) -> Result<usize, SemiflowError> {
let n = op.n();
let Some(b_norm_sq) = rhs_norm_sq_or_zero_out(b, x) else {
return Ok(0);
};
let tol_abs_sq = tol_cg * tol_cg * b_norm_sq;
let mut r = scratch.take_vec(n);
let mut z = scratch.take_vec(n);
let mut p = scratch.take_vec(n);
let mut sp = scratch.take_vec(n);
shift.residual(op, b, x, &mut sp, &mut r);
// ── Pre-loop convergence guard (§59.6) ────────────────────────────────────
// If ‖r‖² ≤ tol already, warm start x already solves S·x = b (e.g. null-space
// input on Neumann: ·c·1 = 0 ⇒ S·c·1 = c·1 ⇒ r = 0). Return Ok(0) immediately;
// x is unchanged and correct. Without this guard, p=0 ⇒ psp=0 ⇒ loop breaks with
// last_r_sq = ∞ ⇒ ConvergenceFailed (false failure on an already-solved system).
let r_sq_init = vec_norm_sq(&r);
if r_sq_init <= tol_abs_sq {
scratch.return_vec(r);
scratch.return_vec(z);
scratch.return_vec(p);
scratch.return_vec(sp);
return Ok(0);
}
// ─────────────────────────────────────────────────────────────────────────
precond.apply(&r, &mut z);
p.copy_from_slice(&z);
let rz = vec_dot(&r, &z);
let result = cg_loop(
op, shift, x, tol_abs_sq, max_iter, precond, &mut r, &mut z, &mut p, &mut sp, rz,
);
scratch.return_vec(r);
scratch.return_vec(z);
scratch.return_vec(p);
scratch.return_vec(sp);
result
}
/// Apply one CG update: `x += α·p`, `r -= α·(S·p)`, where `α = rz / psp`.
fn cg_update_x_r<F: SemiflowFloat>(x: &mut [F], r: &mut [F], p: &[F], sp: &[F], rz: F, psp: F) {
let alpha = rz / psp;
for (xi, &pi) in x.iter_mut().zip(p.iter()) {
*xi += alpha * pi;
}
for (ri, &si) in r.iter_mut().zip(sp.iter()) {
*ri -= alpha * si;
}
}
/// Inner CG iterate loop (extracted to keep `pcg_shifted` ≤ 50 lines).
// 11 args by necessity — 4 CG state slices + 4 scalar/op params: no grouping possible.
#[allow(clippy::too_many_arguments)]
fn cg_loop<F: SemiflowFloat>(
op: &dyn SymmetricLinearOp<F>,
shift: &Shift<'_, F>,
x: &mut [F],
tol_abs_sq: F,
max_iter: usize,
precond: &dyn Preconditioner<F>,
r: &mut [F],
z: &mut [F],
p: &mut [F],
sp: &mut [F],
mut rz: F,
) -> Result<usize, SemiflowError> {
let tiny = F::from(1e-300_f64).unwrap();
let mut last_r_sq = F::from(f64::INFINITY).unwrap_or(F::zero());
for iter in 0..max_iter {
shift.matvec(op, p, sp); // sp = S·p
let psp = vec_dot(p, sp);
if psp <= F::zero() {
// p≈0 on SPD system ⇒ check convergence
last_r_sq = vec_norm_sq(r);
if last_r_sq <= tol_abs_sq {
return Ok(iter);
}
break; // genuine breakdown (should not occur)
}
cg_update_x_r(x, r, p, sp, rz, psp);
last_r_sq = vec_norm_sq(r);
if last_r_sq <= tol_abs_sq {
return Ok(iter + 1);
}
precond.apply(r, z);
let rz_new = vec_dot(r, z);
if rz_new.abs() < tiny {
return Ok(iter + 1);
} // stagnated
let beta = rz_new / rz;
for (pi, &zi) in p.iter_mut().zip(z.iter()) {
*pi = zi + beta * *pi;
}
rz = rz_new;
}
let last_residual = last_r_sq.sqrt().to_f64().unwrap_or(f64::NAN);
Err(SemiflowError::ConvergenceFailed {
last_residual,
max_iter,
})
}
// ── Implicit-Euler action ─────────────────────────────────────────────────────
/// `dst ← (I + Δt·op)^{-n_steps} · src` (backward-Euler, §59.1).
///
/// Builds the Jacobi preconditioner once for the fixed `Δt = tau / n_steps`,
/// then loops `n_steps` PCG solves with warm start `x₀ ← u_k` each step.
///
/// # Errors
/// - [`SemiflowError::DomainViolation`] if `n_steps < 1`.
/// - [`SemiflowError::ConvergenceFailed`] if CG stalls within `max_iter`.
///
/// # `max_iter_override`
/// If `Some(m)`, uses `m` as the CG iteration cap instead of the theory-derived
/// bound `ceil(√κ · ln(2/tol))`. Useful when the Gershgorin `λ_max` bound is
/// loose and the user knows fewer iterations suffice. `None` → auto-computed.
// 8 args by necessity — op/src/dst/tau/n_steps/tol/max_iter_override/scratch.
#[allow(clippy::too_many_arguments)]
pub(crate) fn implicit_euler_action<F: SemiflowFloat>(
op: &dyn SymmetricLinearOp<F>,
src: &[F],
dst: &mut [F],
tau: F,
n_steps: usize,
tol: F,
max_iter_override: Option<usize>,
scratch: &mut ScratchPool<F>,
) -> Result<(), SemiflowError> {
if n_steps < 1 {
return Err(SemiflowError::DomainViolation {
what: "implicit_euler_action: n_steps must be >= 1",
value: 0.0,
});
}
let n = op.n();
// n_steps is a loop counter — precision loss only for n_steps > 2^53 (impossible in practice).
#[allow(clippy::cast_precision_loss)]
let dt = tau / F::from(n_steps as f64).unwrap();
let tol_cg = tol.max(F::from(1e-12_f64).unwrap());
let max_it = max_iter_override.unwrap_or_else(|| compute_max_iter(op, dt, n, tol_cg));
let precond = Jacobi::build(op, dt, scratch);
run_substeps(op, src, dst, dt, n_steps, tol_cg, max_it, &precond, scratch)
}
/// `max_iter = min(N, max(MIN_CG_ITER, ceil(√κ · ln(2/tol))))` (§59.4, CG iteration budget).
///
/// Corrects the pre-fix formula `ceil(4·√κ)` which omitted the `ln(2/tol)` factor and
/// caused `ConvergenceFailed` on the library's own conservative operator (issue #18).
///
/// Derivation: standard CG bound for SPD `S = I + Δt·Â` with condition number
/// `κ(S) ≤ 1 + Δt·λ_max` gives `m ≤ ½·√κ·ln(2/tol)`. We use `safety=1.0` (not ½)
/// so the cap is generous enough to never truncate a still-converging solve.
/// The minimum `MIN_CG_ITER` guarantees a floor for very well-conditioned systems.
const MIN_CG_ITER: usize = 16;
fn compute_max_iter<F: SemiflowFloat>(
op: &dyn SymmetricLinearOp<F>,
dt: F,
n: usize,
tol: F,
) -> usize {
let lam = op.lambda_max_bound().to_f64().unwrap_or(1.0);
let dt_f = dt.to_f64().unwrap_or(0.0);
// Clamp tol to a reasonable range: [1e-15, 1.0). tol=0 or tol<0 never occurs
// (caller uses tol_cg = user_tol.max(1e-12)), but guard for safety.
let tol_f = tol
.to_f64()
.unwrap_or(1e-10)
.clamp(1e-15, 1.0 - f64::EPSILON);
let kappa_sqrt = (1.0_f64 + dt_f * lam).sqrt();
let ln_factor = (2.0_f64 / tol_f).libm_ln().max(1.0);
// ceil() ≥ 0 (both factors ≥ 1); value bounded by n < usize::MAX in practice.
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let raw = (kappa_sqrt * ln_factor).ceil() as usize;
n.min(raw).max(MIN_CG_ITER)
}
/// Loop `n_steps` backward-Euler sub-steps; each solves `S · u_{k+1} = u_k`.
// 9 args by necessity — op/src/dst/dt/n_steps/tol/max_iter/precond/scratch.
#[allow(clippy::too_many_arguments)]
fn run_substeps<F: SemiflowFloat>(
op: &dyn SymmetricLinearOp<F>,
src: &[F],
dst: &mut [F],
dt: F,
n_steps: usize,
tol_cg: F,
max_iter: usize,
precond: &Jacobi<F>,
scratch: &mut ScratchPool<F>,
) -> Result<(), SemiflowError> {
let n = op.n();
let mut u = scratch.take_vec(n); // u_k — current iterate
u.copy_from_slice(src);
for _ in 0..n_steps {
// x and u are independent scratch Vecs (separate pool allocations) — no aliasing.
let mut x = scratch.take_vec(n);
x.copy_from_slice(&u); // warm start x₀ = b = u_k
let shift = Shift::Euler { dt };
pcg_shifted(op, &shift, &u, &mut x, precond, tol_cg, max_iter, scratch)?;
core::mem::swap(&mut u, &mut x); // u = solution; x = old u (returned to pool)
scratch.return_vec(x);
}
dst.copy_from_slice(&u);
scratch.return_vec(u);
Ok(())
}