gam_solve/residual_cascade.rs
1//! Multiresolution residual cascade for scattered 2-3D smooths at huge n
2//! (compute-first primitive #3, #1032; siblings: the 1-D scan in
3//! [`crate::spline_scan`], the 2-D grid in
4//! [`gam_terms::grid_spline_2d`]).
5//!
6//! Model. In metric-scaled coordinates `z = diag(metric)·x` the smooth is
7//! `f(z) = P(z)'γ + Σ_l Σ_j c_{l,j} · φ((z − ξ_{l,j})/δ_l)`,
8//! an unpenalized linear polynomial layer `P = {1, z_1, …, z_d}` at the root
9//! plus, per level `l = 0..L`, compactly supported Wendland bumps
10//! `φ(r) = (1−r)₊⁴(4r+1)` (positive definite and C² on ℝ³) of support radius
11//! `δ_l = OVERLAP·h_l` planted on the NEW centers of a nested net with
12//! covering radius `h_l = h₀·2^{−l}`. Coefficients are a-priori independent,
13//! `c_{l,j} ~ N(0, τ²·4^{−l(s−d/2)})` — the standard multilevel frame whose
14//! diagonal prior norm is equivalent to the Sobolev-`s` (semi)norm on
15//! quasi-uniform nested nets (Narcowich–Ward inverse estimates + Le Gia–
16//! Wendland multilevel stability; `d/2 < s ≤ (d+3)/2`, the native smoothness
17//! of the Wendland-(3,1) bump). The assembled claim is certified in-test
18//! against a dense kernel solve on small n (#904 style), not assumed.
19//!
20//! Nets. Each level's center set is a greedy hash-grid ε-net scanned in data
21//! order, seeded with the previous level's net: covering radius ≤ h_l over
22//! the data AND separation ≥ h_l — the same quasi-uniformity guarantees
23//! farthest-point sampling gives, at O(n) per level (each point checks the
24//! 3^d neighboring cells of one hash grid of cell size h_l). Nets are nested
25//! (`Ξ_0 ⊂ Ξ_1 ⊂ …`); a center carries a bump only at its birth level.
26//!
27//! Fit. With `W = diag(w)`, `D = diag(0 on the polynomial layer, d_l =
28//! 4^{l(s−d/2)} on level-l bumps)` and `λ = σ²/τ²`, the posterior mode solves
29//! `(X'WX + λD)c = X'Wy`. `X` is sparse — a row touches the O(1) bumps per
30//! level whose supports cover it, O(qL) nonzeros — and is held in CSR. For
31//! moderate column counts (`m ≤ DENSE_GRAM_MAX`) the normal equations are
32//! solved by dense Cholesky with the EXACT log-determinant (same route as the
33//! grid sibling); beyond that the solve is preconditioned CG with the two-level
34//! additive-Schwarz coarse-space preconditioner `P = blockdiag(A_CC,
35//! diag(A_FF))`. The multilevel Wendland frame is redundant across scales — a
36//! coarse bump and the fine bumps in its support are strongly correlated — so
37//! the data-fit Gram `X'WX` couples levels and a pure-diagonal preconditioner
38//! leaves a conditioning that GROWS with the number of data-identified levels
39//! (hence with n). The coarse space `C` (polynomial layer + the data-dominated
40//! coarsest levels, see `coarse_space_cols`) is solved EXACTLY by a small dense
41//! Cholesky and the penalty-dominated fine tail `F` — where `A_ll ≈ λ d_l I` is
42//! already uniformly conditioned — by its Jacobi diagonal. That deflation is
43//! what makes `P^{−1/2}(X'WX+λD)P^{−1/2}` uniformly conditioned, so the CG
44//! iteration count is genuinely n-independent (the in-test gate asserts an
45//! ADDITIVE bound across a 4× n jump, not a multiplicative one). Every CG solve
46//! reports its relative residual `‖b − Ac‖/‖b‖`: a computable backward-error
47//! certificate (`c` solves a system perturbed by no more than that fraction)
48//! inherited by every linear functional of the solution.
49//!
50//! REML. λ maximizes the profiled-σ² restricted criterion
51//! `ℓ_R(λ) = −½[ log|X'WX+λD| − log|λD|₊ + (n−d−1)·log σ̂²(λ) ] + const`,
52//! `log|λD|₊ = r·logλ + Σ_j log d_j` over the `r` penalized columns and
53//! `σ̂² = (y'Wy − c'X'Wy)/(n−d−1)` — the same shape as the siblings, with the
54//! penalty-logdet constant kept so criteria are comparable across cascade
55//! depths. Eliminating the polynomial null block once gives the
56//! penalty-whitened Schur complement `B`, for which the normalized determinant
57//! is `log|G₀₀| + log|I+B/λ|`. Its spectrum is exact on the dense route and
58//! represented by one fixed-probe Lanczos quadrature on the iterative route.
59//! Thus the score, gradient, and curvature are analytic functions of log λ
60//! with the SAME spectral nodes at every trial. Rigorous derivative enclosures
61//! isolate every stationary interval before safeguarded root refinement; both
62//! bounded-domain endpoints are compared exactly, with no basin-selecting
63//! lattice.
64//!
65//! Refinement certificate. After fitting L levels, the candidate level L+1
66//! is constructed (O(n)) and the EXACT objective decrease available from
67//! adding it is bounded: for the penalized objective `F(c) = ‖√W(y−Xc)‖² +
68//! λc'Dc`, appending columns `X₂` with penalty `λd_{L+1}I` decreases the
69//! minimum by `g'S⁻¹g`, `g = X₂'W r̂`, `S` the Schur complement; since
70//! `A₁₁ ⪰ X₁'WX₁` and `X₂'W^{1/2}·proj·W^{1/2}X₂ ⪯ X₂'WX₂`, `S ⪰ λd_{L+1}I`,
71//! so the decrease is at most `‖X₂'W r̂‖²/(λ·d_{L+1})` — a computable
72//! discretization certificate. The cascade refines (adds the level, refits,
73//! re-selects λ) until that bound drops below `REFINE_TOL` of the penalized
74//! residual, the net stops producing new centers (every point is a center),
75//! or the level/center caps are reached: certified-or-fallback, the same
76//! discipline as the radial-profile GL ladder.
77//!
78//! Posterior. Coefficient covariance is `σ²(X'WX+λD)^{−1}`; pointwise
79//! prediction variance routes the basis row through one (certified) solve.
80//! Exact posterior samples come from perturb-and-solve: `c_s = A^{−1}(X'Wy +
81//! σ(X'W^{1/2}z₁ + √λ D^{1/2}z₂))` with iid standard-normal `z₁, z₂` has
82//! mean `ĉ` and covariance exactly `σ²A^{−1}` (deterministically seeded; one
83//! certified solve per sample).
84//!
85//! Payoff. Build O(n·(L + 3^d)), fit O(nnz · iters) per λ trial with
86//! n-independent iters — O(n log n) end to end, against the dense n×k kernel
87//! Gram + O(k³) per trial that duchon/matern pay today. Gap behavior is
88//! mechanical: levels wider than a gap keep support across it (polynomial +
89//! coarse bumps bridge), finer levels have no data and revert to their prior
90//! variance, so the posterior mean bridges instead of sagging while the
91//! variance grows into the gap.
92
93use std::collections::HashMap;
94use std::sync::Arc;
95
96use faer::Side;
97use gam_linalg::faer_ndarray::FaerEigh;
98use gam_math::score_opt::{
99 AffineRemlProfile, ClosedInterval, DerivativeEnclosure, ScoreJet, ScoreSample,
100 maximize_score_1d,
101};
102use gam_terms::grid_spline_2d::{chol_solve, cholesky_logdet};
103use ndarray::Array2;
104
105/// Bump support radius as a multiple of the level's covering radius:
106/// `δ_l = OVERLAP·h_l`. Separation ≥ h_l caps the bumps covering a point at
107/// a packing constant per level (O(q) row nonzeros per level).
108const OVERLAP: f64 = 2.0;
109/// Root covering radius as a fraction of the largest scaled axis range.
110const H0_FRACTION: f64 = 0.5;
111/// Levels in the initial cascade before refinement certificates run.
112const INITIAL_LEVELS: usize = 3;
113/// Hard cap on cascade depth (h shrinks 2^16-fold below the root).
114const MAX_LEVELS: usize = 16;
115/// Hard cap on total centers across all levels.
116const MAX_CENTERS: usize = 200_000;
117/// Refinement stops when the exact next-level gain bound falls below this
118/// fraction of the penalized residual.
119const REFINE_TOL: f64 = 1e-3;
120
121/// Column count up to which the normal equations go through dense Cholesky
122/// (exact logdet, no iteration); above it, PCG + SLQ. 1536² doubles ≈ 18 MB.
123const DENSE_GRAM_MAX: usize = 1536;
124
125/// PCG convergence: relative residual ‖b − Ac‖/‖b‖ (the backward-error
126/// certificate) demanded of every solve, and the iteration cap past which
127/// the solve is an error rather than a silent approximation. The certification
128/// suite gates the iterative route at 1e-9; asking for more burns matvecs
129/// without strengthening any downstream certificate.
130const CG_RTOL: f64 = 1e-9;
131const CG_MAX_ITERS: usize = 4000;
132
133/// Coarse-space additive-Schwarz preconditioner controls (issue #1032: the
134/// "BPX/level-diagonal preconditioned CG, n-independent iters" spec).
135///
136/// The multilevel Wendland frame is redundant across scales — a coarse bump and
137/// the fine bumps inside its support are strongly correlated — so the data-fit
138/// Gram `X'WX` couples levels and a pure-diagonal (Jacobi) preconditioner leaves
139/// a conditioning that grows with the number of *data-identified* levels, hence
140/// with `n` (more rows ⇒ finer levels carry data ⇒ another collinear coarse
141/// scale the diagonal can't decouple). The cure is the textbook two-level
142/// additive Schwarz coarse space: solve the coarse block — the polynomial layer
143/// plus every level the penalty has NOT yet made diagonally dominant — EXACTLY,
144/// and precondition the remaining penalty-dominated fine levels (where
145/// `A_ll ≈ λ d_l I` is already uniformly conditioned) by their Jacobi diagonal.
146///
147/// A level is "data-dominated" while `λ d_l < COARSE_DOMINANCE · median diag
148/// (X'WX) over the level`. Because columns are laid out poly, level-0, level-1,
149/// … and `d_l` increases while the per-level data weight decreases, the
150/// data-dominated levels are exactly the coarsest prefix `[0, ncoarse)`, so the
151/// coarse space is a contiguous column prefix and the cut is a single scan. The
152/// crossover level grows only as `½ log₄(n/λ)` — `ncoarse = O(√(n/λ))` columns —
153/// so the exact coarse factorization stays small against the sparse matvecs at
154/// every n the primitive serves. [`COARSE_SPACE_MAX`] caps it as a safety valve
155/// (past the cap the finer data-dominated levels fall back to Jacobi and the
156/// iteration count rises, but the CG residual certificate still guarantees the
157/// solve); [`MIN_COARSE_LEVELS`] always deflates the two coarsest scales, which
158/// are near-collinear with the polynomial layer at every λ.
159const COARSE_DOMINANCE: f64 = 4.0;
160/// Safety ceiling on the exact-coarse column count. It must NOT bind at the n
161/// the primitive serves: the n-independent iteration count rests on the coarse
162/// block containing the WHOLE data-dominated prefix (`O(√(n/λ))` columns), so a
163/// cap that truncates that prefix is exactly what makes the iteration count
164/// climb with n (a finer data-dominated level demoted to Jacobi cannot be
165/// decoupled from the coarse scales it is collinear with). At the n-scales the
166/// iterative route engages (tens of thousands of rows → a ≈1.4k-column
167/// prefix) this is non-binding headroom; it only triggers in the genuinely
168/// degenerate case the quasi-uniformity guard is meant to catch first. The
169/// realized coarse factorization runs at the actual prefix length, not the cap,
170/// so the ceiling costs nothing until it fires.
171const COARSE_SPACE_MAX: usize = 4096;
172const MIN_COARSE_LEVELS: usize = 2;
173
174/// Quasi-uniformity guard (issue #1032, caveat 2). The BPX n-independent CG
175/// iteration bound rests on the nested ε-nets being quasi-uniform *in the
176/// metric-scaled coordinates `z = diag(metric)·x` the bumps live in*. The
177/// greedy net guarantees covering ≤ h and separation ≥ h in `z` by
178/// construction, so the only way the BPX norm-equivalence constant blows up is
179/// when the metric is so anisotropic that the metric-scaled point cloud is
180/// effectively degenerate along a direction — the data collapses onto a lower
181/// dimension in `z`, the root covering radius `h₀ = ½·max_a range_a` swamps the
182/// collapsed axis, the level-`l` bumps overlap pathologically, and the
183/// preconditioner constant (hence the iteration count) grows without an
184/// n-independent bound. The realized symptom is `solve_iters` climbing toward
185/// [`CG_MAX_ITERS`]; this guard detects the *cause* up front from the
186/// metric-scaled per-axis spread so the auto-route can fall back to the dense
187/// kernel BEFORE paying an unbounded iterative solve, rather than discovering
188/// the blow-up only after `CG_MAX_ITERS` work.
189///
190/// Condition measure: the ratio of the largest to smallest metric-scaled
191/// per-axis standard deviation (a scale-free aspect ratio of the scaled
192/// cloud). Past this threshold the net is no longer quasi-uniform in every
193/// direction and the BPX bound is not trustworthy. Derived, not a knob: a
194/// `10³` aspect ratio means the collapsed axis carries <0.1% of the dominant
195/// axis's variation, at which point its bumps span the whole cloud and the
196/// multilevel hierarchy degenerates to a single ill-conditioned level.
197const QUASI_UNIFORMITY_MAX_ASPECT: f64 = 1.0e3;
198
199/// SLQ controls: fixed Rademacher probes (shared across λ trials) and the
200/// Lanczos depth per probe (full reorthogonalization; early exit on
201/// breakdown).
202const SLQ_PROBES: usize = 24;
203const SLQ_LANCZOS_STEPS: usize = 48;
204
205/// Deterministic seed for the SLQ probes and posterior samples.
206const RNG_SEED: u64 = 0x1032_CA5C_ADE0_5EED;
207
208/// Floor for eigenvalues/pivots before the system is declared singular.
209const EIG_FLOOR: f64 = 1e-300;
210
211// ───────────────────────────── deterministic RNG ────────────────────────────
212
213/// SplitMix64: tiny, deterministic, full-period stream generator.
214struct SplitMix64(u64);
215
216impl SplitMix64 {
217 fn new(seed: u64) -> Self {
218 SplitMix64(seed)
219 }
220
221 fn next_u64(&mut self) -> u64 {
222 gam_linalg::utils::splitmix64(&mut self.0)
223 }
224
225 /// Uniform in (0, 1): 53-bit mantissa, shifted off zero.
226 fn next_unit(&mut self) -> f64 {
227 ((self.next_u64() >> 11) as f64 + 0.5) / 9_007_199_254_740_992.0
228 }
229
230 /// Standard normal via Box–Muller.
231 fn next_normal(&mut self) -> f64 {
232 let u1 = self.next_unit();
233 let u2 = self.next_unit();
234 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
235 }
236
237 /// Rademacher ±1.
238 fn next_sign(&mut self) -> f64 {
239 if self.next_u64() & 1 == 0 { 1.0 } else { -1.0 }
240 }
241}
242
243// ─────────────────────────────── hash grids ─────────────────────────────────
244
245/// Integer cell of a point at a given cell width (coordinates are already
246/// metric-scaled and shifted to be ≥ 0, so indices are small and exact).
247#[inline]
248fn cell_of(z: &[f64; 3], dim: usize, width: f64) -> (i32, i32, i32) {
249 let mut c = [0_i32; 3];
250 for a in 0..dim {
251 c[a] = (z[a] / width).floor() as i32;
252 }
253 (c[0], c[1], c[2])
254}
255
256/// Hash grid over a point set: cell → indices. Lookup scans the 3^d
257/// neighborhood, which covers every point within one cell width.
258struct HashGrid {
259 width: f64,
260 dim: usize,
261 cells: HashMap<(i32, i32, i32), Vec<u32>>,
262}
263
264impl HashGrid {
265 fn new(width: f64, dim: usize) -> Self {
266 HashGrid {
267 width,
268 dim,
269 cells: HashMap::new(),
270 }
271 }
272
273 fn insert(&mut self, idx: u32, z: &[f64; 3]) {
274 let key = cell_of(z, self.dim, self.width);
275 self.cells.entry(key).or_default().push(idx);
276 }
277
278 /// Visit every stored index in the 3^d cells around `z` (deterministic
279 /// order: lexicographic cells, insertion order within a cell).
280 fn for_neighbors(&self, z: &[f64; 3], mut visit: impl FnMut(u32)) {
281 let (c0, c1, c2) = cell_of(z, self.dim, self.width);
282 let d2 = if self.dim > 2 { 1 } else { 0 };
283 let d1 = if self.dim > 1 { 1 } else { 0 };
284 for i0 in -1..=1_i32 {
285 for i1 in -d1..=d1 {
286 for i2 in -d2..=d2 {
287 if let Some(bucket) = self.cells.get(&(c0 + i0, c1 + i1, c2 + i2)) {
288 for &idx in bucket {
289 visit(idx);
290 }
291 }
292 }
293 }
294 }
295 }
296}
297
298#[inline]
299fn dist2(a: &[f64; 3], b: &[f64; 3], dim: usize) -> f64 {
300 let mut s = 0.0;
301 for k in 0..dim {
302 let d = a[k] - b[k];
303 s += d * d;
304 }
305 s
306}
307
308/// Wendland-(3,1) bump `(1−r)₊⁴(4r+1)`: positive definite on ℝ^d, d ≤ 3,
309/// C², native space H^{(d+3)/2}.
310#[inline]
311fn wendland(r: f64) -> f64 {
312 if r >= 1.0 {
313 return 0.0;
314 }
315 let v = 1.0 - r;
316 let v2 = v * v;
317 v2 * v2 * (4.0 * r + 1.0)
318}
319
320// ───────────────────────────── design assembly ──────────────────────────────
321
322/// One resolution level: its NEW centers (scaled coordinates), covering
323/// radius, support radius, prior precision weight, and a lookup grid of cell
324/// width δ_l over those centers.
325struct Level {
326 h: f64,
327 delta: f64,
328 /// Prior precision weight `d_l = 4^{l(s−d/2)}` (prior variance τ²/d_l).
329 weight: f64,
330 centers: Vec<[f64; 3]>,
331 /// First flat column index of this level's coefficients.
332 col_offset: usize,
333 grid: HashGrid,
334}
335
336/// Immutable fitted-design core shared between the design handle and fits.
337struct Core {
338 dim: usize,
339 metric: [f64; 3],
340 /// Lower corner / range of the scaled bounding box (polynomial layer
341 /// coordinates are `2(z − lo)/range − 1` for conditioning).
342 z_lo: [f64; 3],
343 z_range: [f64; 3],
344 sobolev_s: f64,
345 levels: Vec<Level>,
346 /// Full nested net Ξ_L (scaled coords), retained so the candidate level
347 /// L+1 can extend it without re-deriving coarser levels.
348 net: Vec<[f64; 3]>,
349 /// Total columns: `dim + 1` polynomial + all level centers.
350 m: usize,
351 /// CSR design rows (column-sorted within a row).
352 row_ptr: Vec<usize>,
353 col_idx: Vec<u32>,
354 vals: Vec<f64>,
355 /// Inputs retained for matvecs, residuals, and refinement.
356 w: Vec<f64>,
357 y: Vec<f64>,
358 /// Scaled data coordinates (shifted to the box corner).
359 z: Vec<[f64; 3]>,
360 /// `X'Wy`, `y'Wy`, `diag(X'WX)`.
361 rhs: Vec<f64>,
362 ytwy: f64,
363 gram_diag: Vec<f64>,
364 /// Per-column prior precision weight (0 on the polynomial layer).
365 pen_diag: Vec<f64>,
366 /// `Σ_j log d_j` over penalized columns (the λ-free part of log|λD|₊,
367 /// kept so REML criteria compare across cascade depths).
368 pen_logdet_const: f64,
369 /// Dense upper-triangular `X'WX` when `m ≤ DENSE_GRAM_MAX` (row-major
370 /// m×m, lower mirror filled at solve time); None on the iterative route.
371 dense_gram: Option<Vec<f64>>,
372 /// Predict-only factored precision: the lower Cholesky factor `L` of
373 /// `A = X'WX + λD` at the FIT's λ, populated only on a core rebuilt from a
374 /// persisted [`ResidualCascadeState`] (where the training CSR is dropped).
375 /// When present, `solve_coeff` replays the posterior-variance solve through
376 /// this factor instead of the absent training design; `None` on a
377 /// training-built core, which solves through `dense_gram`/PCG as usual.
378 predict_chol: Option<Vec<f64>>,
379}
380
381/// Solver route a fit took for its log-determinant.
382#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383pub enum LogdetMethod {
384 /// Dense Cholesky: exact.
385 DenseExact,
386 /// Diagonal control variate + stochastic Lanczos quadrature on fixed
387 /// deterministic probes.
388 Slq,
389}
390
391/// Computable certificates attached to a fit.
392#[derive(Clone, Copy, Debug)]
393pub struct CascadeCertificate {
394 /// Backward error of the coefficient solve: ‖b − Aĉ‖/‖b‖ (0 on the dense
395 /// route).
396 pub solve_rel_residual: f64,
397 /// CG iterations of the coefficient solve (0 on the dense route); the
398 /// n-independence gate watches this.
399 pub solve_iters: usize,
400 /// Route the log-determinant took.
401 pub logdet_method: LogdetMethod,
402}
403
404/// Discretization certificate of the refinement loop: the exact upper bound
405/// on the penalized-objective decrease available from one more level.
406#[derive(Clone, Copy, Debug)]
407pub struct RefinementCertificate {
408 /// `‖X_{L+1}'W r̂‖² / (λ·d_{L+1})` at the accepted fit.
409 pub next_level_gain_bound: f64,
410 /// The absolute tolerance it was compared against (`REFINE_TOL·rss_pen`).
411 pub tolerance: f64,
412}
413
414/// A structural limit that prevented the cascade from assessing or adding the
415/// next resolution level. These are never convergence certificates: if the
416/// requested gain tolerance has not passed, they produce
417/// [`ResidualCascadeError::Underresolved`] instead of a fit.
418#[derive(Clone, Copy, Debug, PartialEq, Eq)]
419pub enum RefinementObstruction {
420 /// The representation reached its supported maximum number of levels.
421 LevelCapacity {
422 levels: usize,
423 maximum_levels: usize,
424 },
425 /// Extending the nested net would exceed its supported center capacity.
426 CenterCapacity {
427 centers: usize,
428 maximum_centers: usize,
429 },
430}
431
432impl std::fmt::Display for RefinementObstruction {
433 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434 match *self {
435 Self::LevelCapacity {
436 levels,
437 maximum_levels,
438 } => write!(
439 f,
440 "level capacity reached ({levels} of {maximum_levels} levels)"
441 ),
442 Self::CenterCapacity {
443 centers,
444 maximum_centers,
445 } => write!(
446 f,
447 "center capacity exceeded ({centers} centers for capacity {maximum_centers})"
448 ),
449 }
450 }
451}
452
453/// Result of assessing the candidate level immediately finer than a fitted
454/// design. Empty-net exhaustion is distinct from representation capacity:
455/// only the former proves that the remaining gain is exactly zero.
456#[derive(Clone, Copy, Debug, PartialEq)]
457pub enum NextLevelAssessment {
458 /// The nested net produced no new centers, so the next-level gain is zero.
459 EmptyNet,
460 /// The complete candidate level was assessed and has this gain bound.
461 GainBound(f64),
462 /// A representation limit was reached. `gain_bound` is the computed bound
463 /// when the candidate could be assessed (level capacity), and positive
464 /// infinity when center capacity prevented a complete assessment.
465 CapacityExceeded {
466 obstruction: RefinementObstruction,
467 gain_bound: f64,
468 },
469}
470
471/// Multiresolution residual-cascade design: nested nets, sparse design,
472/// diagonal multilevel prior — everything needed to evaluate the REML
473/// criterion and solve at any λ.
474pub struct ResidualCascadeDesign {
475 core: Arc<Core>,
476}
477
478/// Fitted cascade with factored-by-solve posterior access.
479pub struct ResidualCascadeFit {
480 core: Arc<Core>,
481 /// Dense-route prediction factor at the fit's λ. When present, pointwise
482 /// variance uses this one Cholesky factor instead of refactoring the same
483 /// precision matrix for every prediction point.
484 predict_chol: Option<Vec<f64>>,
485 /// Coefficients: `dim+1` polynomial entries, then level blocks.
486 pub coeff: Vec<f64>,
487 /// Selected (or supplied) log smoothing parameter `log λ = log σ²/τ²`.
488 log_lambda: f64,
489 /// Profiled (or supplied) observation variance σ².
490 pub sigma2: f64,
491 /// Restricted log-likelihood at the fit, up to λ- and data-independent
492 /// additive constants (exact REML differences across λ on the dense
493 /// route; SLQ-estimated on the iterative route).
494 pub restricted_loglik: f64,
495 /// Penalized residual quadratic `y'Wy − c'X'Wy`.
496 pub rss_pen: f64,
497 /// Solve/logdet certificates.
498 pub certificate: CascadeCertificate,
499 /// Present when the fit came from the refinement loop.
500 pub refinement: Option<RefinementCertificate>,
501}
502
503/// Opaque work checkpoint carried by an underresolved cascade result.
504///
505/// The current finite-resolution iterate is deliberately private: callers can
506/// inspect its numerical evidence, but cannot turn an uncertified iterate into
507/// a [`ResidualCascadeFit`]. The retained design and coefficients allow a
508/// future refinement backend to resume the work without minting a partial fit.
509pub struct ResidualCascadeCheckpoint {
510 iterate: ResidualCascadeFit,
511}
512
513impl ResidualCascadeCheckpoint {
514 fn new(iterate: ResidualCascadeFit) -> Self {
515 Self { iterate }
516 }
517
518 /// Number of levels already fitted in this checkpoint.
519 pub fn num_levels(&self) -> usize {
520 self.iterate.num_levels()
521 }
522
523 /// Number of centers already fitted in this checkpoint.
524 pub fn num_centers(&self) -> usize {
525 self.iterate.num_centers()
526 }
527
528 /// REML-selected log smoothing parameter of the retained iterate.
529 pub fn log_lambda(&self) -> f64 {
530 self.iterate.log_lambda
531 }
532
533 /// Penalized residual used to scale the requested refinement tolerance.
534 pub fn rss_pen(&self) -> f64 {
535 self.iterate.rss_pen
536 }
537
538 /// Linear-solve evidence attached to the retained iterate.
539 pub fn certificate(&self) -> CascadeCertificate {
540 self.iterate.certificate
541 }
542}
543
544impl std::fmt::Debug for ResidualCascadeCheckpoint {
545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546 f.debug_struct("ResidualCascadeCheckpoint")
547 .field("num_levels", &self.num_levels())
548 .field("num_centers", &self.num_centers())
549 .field("log_lambda", &self.log_lambda())
550 .field("rss_pen", &self.rss_pen())
551 .field("certificate", &self.certificate())
552 .finish_non_exhaustive()
553 }
554}
555
556/// Typed failure of the magic-default cascade fit.
557#[derive(Debug)]
558pub enum ResidualCascadeError {
559 /// Invalid input or a numerical failure in design construction/optimization.
560 Computation(String),
561 /// Refinement could not meet its requested tolerance before a structural
562 /// capacity was reached. The checkpoint preserves all completed work while
563 /// remaining unusable as a public fit.
564 Underresolved {
565 checkpoint: ResidualCascadeCheckpoint,
566 gain_bound: f64,
567 requested_tolerance: f64,
568 obstruction: RefinementObstruction,
569 },
570}
571
572impl From<String> for ResidualCascadeError {
573 fn from(reason: String) -> Self {
574 Self::Computation(reason)
575 }
576}
577
578impl std::fmt::Display for ResidualCascadeError {
579 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580 match self {
581 Self::Computation(reason) => f.write_str(reason),
582 Self::Underresolved {
583 checkpoint,
584 gain_bound,
585 requested_tolerance,
586 obstruction,
587 } => write!(
588 f,
589 "residual cascade underresolved after {} levels: next-level gain bound \
590 {gain_bound:.6e} exceeds requested tolerance {requested_tolerance:.6e}; \
591 {obstruction}",
592 checkpoint.num_levels()
593 ),
594 }
595 }
596}
597
598impl std::error::Error for ResidualCascadeError {}
599
600/// One resolution level's geometry in a persisted snapshot: the data needed to
601/// rebuild a [`Level`] (its lookup grid, bumps, and column block) without the
602/// training rows. Centers are flattened `dim`-major (`dim` floats per center).
603#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
604pub struct LevelState {
605 pub h: f64,
606 pub delta: f64,
607 pub weight: f64,
608 pub col_offset: u64,
609 /// `dim·n_centers` scaled-coordinate floats, center-major.
610 pub centers: Vec<f64>,
611}
612
613/// Serializable snapshot of a [`ResidualCascadeFit`] (#1032 persistence
614/// prerequisite). Holds everything `predict` needs and NOTHING about the
615/// training rows:
616/// - MEAN: the nested geometry (`dim`/`metric`/box/`sobolev_s` + per-level
617/// centers/δ/weights/col-offsets) and the root polynomial layer are all that
618/// `basis_row_scaled`·`coeff` reads;
619/// - VARIANCE: the factored precision `predict_chol` — the lower Cholesky factor
620/// `L` of `A = X'WX + λD` at the fit's λ — which the posterior-variance solve
621/// `x'A⁻¹x` replays against (the training design that originally assembled `A`
622/// is dropped).
623///
624/// `from_state` rebuilds a predict-capable fit whose `Core` carries empty
625/// training CSR and `predict_chol = Some(L)`; `solve_coeff` then routes the
626/// variance solve through `L`. The reconstructed fit cannot be re-fit or
627/// resampled (it has no rows), only predicted from — exactly the persistence
628/// contract.
629#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
630pub struct ResidualCascadeState {
631 pub dim: u64,
632 /// Per-axis metric scaling (length 3; trailing entries are 1 for `dim < 3`).
633 pub metric: [f64; 3],
634 pub z_lo: [f64; 3],
635 pub z_range: [f64; 3],
636 pub sobolev_s: f64,
637 pub levels: Vec<LevelState>,
638 /// Total column count `dim + 1 + Σ centers`.
639 pub m: u64,
640 /// `Σ_j log d_j` over penalized columns (kept so restored REML scalars stay
641 /// comparable across cascade depths).
642 pub pen_logdet_const: f64,
643 /// Posterior-mode coefficients (length `m`).
644 pub coeff: Vec<f64>,
645 pub log_lambda: f64,
646 pub sigma2: f64,
647 pub restricted_loglik: f64,
648 pub rss_pen: f64,
649 /// Lower Cholesky factor `L` of `A = X'WX + λD` at the fit's λ, `m × m`
650 /// row-major — the factored precision the variance solve replays through.
651 pub predict_chol: Vec<f64>,
652}
653
654/// Forward substitution `L y = b` (lower factor, row-major) into `out`.
655fn forward_sub_into(l: &[f64], p: usize, b: &[f64], out: &mut [f64]) {
656 for i in 0..p {
657 let mut s = b[i];
658 for t in 0..i {
659 s -= l[i * p + t] * out[t];
660 }
661 out[i] = s / l[i * p + i];
662 }
663}
664
665/// Back substitution `Lᵀ z = y` (lower factor, row-major) into `out`.
666fn back_sub_into(l: &[f64], p: usize, y: &[f64], out: &mut [f64]) {
667 for i in (0..p).rev() {
668 let mut s = y[i];
669 for t in i + 1..p {
670 s -= l[t * p + i] * out[t];
671 }
672 out[i] = s / l[i * p + i];
673 }
674}
675
676/// Coarse-space additive-Schwarz preconditioner for the iterative route
677/// (issue #1032). `A = X'WX + λD` is preconditioned by the symmetric positive
678/// definite block-diagonal `P = blockdiag(A_CC, diag(A_FF))`, where the coarse
679/// index set `C = [0, ncoarse)` is the polynomial layer plus the data-dominated
680/// (coarsest) levels and `F` the penalty-dominated fine tail — see the
681/// [`COARSE_DOMINANCE`]/[`COARSE_SPACE_MAX`] docs for why this delivers
682/// n-independent CG iteration counts where the pure-Jacobi diagonal does not.
683///
684/// `solve` applies `P⁻¹` (exact coarse Cholesky solve ⊕ fine Jacobi). For the
685/// SLQ log-determinant the symmetric factor `R = blockdiag(L_CC, diag√A_FF)`
686/// with `P = R Rᵀ` is exposed through `apply_r_inv`/`apply_r_inv_t`, and
687/// `log|P| = log|A_CC| + Σ_F log A_jj`.
688struct Preconditioner {
689 /// First fine column; coarse block is the principal `[0, ncoarse)` submatrix.
690 ncoarse: usize,
691 /// Lower Cholesky factor of the coarse block `A_CC` (`ncoarse × ncoarse`).
692 coarse_chol: Vec<f64>,
693 /// `log|A_CC|` (exact).
694 coarse_logdet: f64,
695 /// `1/A_jj` on the fine columns `[ncoarse, m)`.
696 inv_fine: Vec<f64>,
697 /// `1/√A_jj` on the fine columns (the `R⁻¹`/`R⁻ᵀ` fine scaling).
698 inv_sqrt_fine: Vec<f64>,
699 /// `Σ_F log A_jj` (the fine part of `log|P|`).
700 fine_logdet: f64,
701}
702
703impl Preconditioner {
704 /// `out = P⁻¹ r`: exact coarse solve on `[0, ncoarse)`, Jacobi on the tail.
705 fn solve(&self, r: &[f64], out: &mut [f64]) {
706 let nc = self.ncoarse;
707 let zc = chol_solve(&self.coarse_chol, nc, &r[..nc]);
708 out[..nc].copy_from_slice(&zc);
709 for (k, o) in out[nc..].iter_mut().enumerate() {
710 *o = r[nc + k] * self.inv_fine[k];
711 }
712 }
713
714 /// `out = R⁻ᵀ v` (coarse: `L_CCᵀ` back-solve; fine: `/√A_jj`).
715 fn apply_r_inv_t(&self, v: &[f64], out: &mut [f64]) {
716 let nc = self.ncoarse;
717 back_sub_into(&self.coarse_chol, nc, &v[..nc], &mut out[..nc]);
718 for (k, o) in out[nc..].iter_mut().enumerate() {
719 *o = v[nc + k] * self.inv_sqrt_fine[k];
720 }
721 }
722
723 /// `out = R⁻¹ v` (coarse: `L_CC` forward-solve; fine: `/√A_jj`).
724 fn apply_r_inv(&self, v: &[f64], out: &mut [f64]) {
725 let nc = self.ncoarse;
726 forward_sub_into(&self.coarse_chol, nc, &v[..nc], &mut out[..nc]);
727 for (k, o) in out[nc..].iter_mut().enumerate() {
728 *o = v[nc + k] * self.inv_sqrt_fine[k];
729 }
730 }
731
732 /// `log|P| = log|A_CC| + Σ_F log A_jj`.
733 fn logdet(&self) -> f64 {
734 self.coarse_logdet + self.fine_logdet
735 }
736}
737
738/// One positive-semidefinite eigenmode of the penalty-whitened Schur
739/// complement. `weight == 1` on the dense exact route; on the large route it
740/// is the fixed-probe Lanczos quadrature weight. The weights sum to the
741/// penalized rank, so constants have the same null-recovery limit on both
742/// routes.
743#[derive(Clone, Copy)]
744struct CascadeSpectralMode {
745 eigenvalue: f64,
746 weight: f64,
747}
748
749/// Lambda-independent spectral representation of the profiled REML score.
750///
751/// Partition the normal matrix into the polynomial null space `0` and the
752/// penalized cascade columns `1`. Eliminating the null block gives
753///
754/// `|G + lambda D| / |lambda D|_+ = |G00| |I + B/lambda|`,
755///
756/// with `B = D^(-1/2) (G11 - G10 G00^(-1) G01) D^(-1/2)`. Consequently every
757/// determinant mode is an analytic logistic function of `log(lambda)`. The
758/// representation is built once, rather than re-running a basin-selecting
759/// lattice of lambda-dependent factorizations.
760///
761/// The same elimination puts the PROFILED RESIDUAL in the same form wherever
762/// the eigenbasis survives the construction — see [`CascadeResidualForm`].
763struct CascadeRemlProfile<'a> {
764 core: &'a Core,
765 null_logdet: f64,
766 modes: Vec<CascadeSpectralMode>,
767 residual: CascadeResidualForm,
768}
769
770/// The lambda-independent spectral form of the PROFILED RESIDUAL
771/// `R(lambda) = y'Wy - b'A(lambda)^(-1) b`.
772///
773/// The same null-space elimination and penalty whitening that turns the
774/// determinant into a mode sum does the same thing to the residual. In the
775/// Schur eigenbasis `B = V Theta V'`, `A(lambda)` acts as `Theta + lambda I`,
776/// so with
777///
778/// `p = V' D^(-1/2) (b1 - G10 G00^(-1) b0)` and
779/// `S_k(lambda) = sum_i p_i^2 / (theta_i + lambda)^k`,
780///
781/// `R = anchor_energy - S1`, and the three quadratic forms the score jet needs
782/// are the next three moments of that same sum:
783///
784/// `c'Dc = S2`, `(Dc)'A^(-1)(Dc) = S3`, `u'Du = S4` for `u = A^(-1) D c`.
785///
786/// `anchor_energy = y'Wy - b0' G00^(-1) b0` is the part of the residual no
787/// lambda can move.
788struct CascadeResidualSpectrum {
789 /// `theta_i`, the Schur eigenvalue of mode `i` — the SAME numbers the
790 /// determinant modes carry.
791 eigenvalue: Vec<f64>,
792 /// Every mode's penalty scale, which is exactly `1` because the Schur
793 /// complement was whitened by `D^(-1/2)` before it was decomposed. It is
794 /// materialized because [`AffineRemlProfile`] takes the pencil
795 /// `h_i = g_i + lambda s_i` as two parallel slices.
796 penalty: Vec<f64>,
797 /// `p_i^2`, the squared projection of the null-eliminated, penalty-whitened
798 /// right-hand side onto mode `i`.
799 projected_square: Vec<f64>,
800 /// `y'Wy - b0' G00^(-1) b0`, as the single-response slice
801 /// [`AffineRemlProfile`] expects.
802 anchor_energy: [f64; 1],
803}
804
805impl CascadeResidualSpectrum {
806 /// `(R, S2, S3, S4)` at `lambda`. Every `theta_i` is nonnegative by
807 /// construction and `lambda` is strictly positive, so every denominator is
808 /// strictly positive; the caller still rejects a nonpositive `R`, which is a
809 /// statement about the DATA rather than about this arithmetic.
810 fn moments(&self, lambda: f64) -> (f64, f64, f64, f64) {
811 let mut s1 = 0.0;
812 let mut s2 = 0.0;
813 let mut s3 = 0.0;
814 let mut s4 = 0.0;
815 for (&theta, &projected_square) in self.eigenvalue.iter().zip(&self.projected_square) {
816 let h = theta + lambda;
817 let first = projected_square / h;
818 let second = first / h;
819 let third = second / h;
820 s1 += first;
821 s2 += second;
822 s3 += third;
823 s4 += third / h;
824 }
825 (self.anchor_energy[0] - s1, s2, s3, s4)
826 }
827}
828
829/// Where the profiled residual and its three log-lambda derivatives come from.
830///
831/// Both arms describe the SAME function of lambda; they differ only in what the
832/// design's Schur decomposition left behind. Under the dense sizing cap the
833/// determinant spectrum comes from a full eigendecomposition, so the eigen-BASIS
834/// exists and the residual is a closed-form sum over exactly the modes the
835/// determinant already uses — no linear solve at any lambda, and the whole score
836/// is O(rank) per trial after the one decomposition.
837///
838/// Past the cap only a fixed-probe QUADRATURE of that spectrum exists (Ritz
839/// values and weights, with no basis to project the right-hand side onto), so
840/// there the residual is still obtained by solving, with both right-hand sides
841/// sharing one factorization or one preconditioner at that lambda.
842enum CascadeResidualForm {
843 Spectral(CascadeResidualSpectrum),
844 Solved,
845}
846
847struct CascadeScoreEvaluation {
848 jet: ScoreJet,
849 /// `log|G + lambda D| - rank(D) log(lambda) - log|D|_+`.
850 normalized_logdet: f64,
851}
852
853/// The determinant half of the score at one `log lambda`.
854struct DeterminantParts {
855 /// `log|G + lambda D| - rank(D) log(lambda) - log|D|_+`.
856 normalized_logdet: f64,
857 /// `d/d log lambda`: `-sum_i w_i t_i` with `t_i = theta_i/(theta_i+lambda)`.
858 /// Nonpositive, and INCREASING in `log lambda` because every `t_i` falls.
859 first: f64,
860 /// `d^2/d log lambda^2`: `sum_i w_i t_i (1-t_i)`. Nonnegative.
861 second: f64,
862}
863
864impl CascadeRemlProfile<'_> {
865 /// Machine-resolved bounded domain containing every determinant transition
866 /// `lambda ≈ theta`. Outside it, every positive mode is within
867 /// `sqrt(epsilon)` of its analytic small- or large-lambda limit. The bounds
868 /// scale with the actual design spectrum rather than a fixed log-lambda
869 /// window.
870 fn log_lambda_domain(&self) -> Result<(f64, f64), String> {
871 let mut smallest = f64::INFINITY;
872 let mut largest = 0.0_f64;
873 for mode in &self.modes {
874 if mode.weight > 0.0 && mode.eigenvalue > 0.0 {
875 smallest = smallest.min(mode.eigenvalue);
876 largest = largest.max(mode.eigenvalue);
877 }
878 }
879 if !(smallest.is_finite() && smallest > 0.0 && largest.is_finite() && largest > 0.0) {
880 return Err(
881 "residual cascade: the data identify no positive penalized Schur mode; log lambda is not estimable"
882 .into(),
883 );
884 }
885 let log_relative_resolution = f64::EPSILON.sqrt().ln();
886 let lo = (smallest.ln() + log_relative_resolution).max(f64::MIN_POSITIVE.ln());
887 let hi = (largest.ln() - log_relative_resolution).min(f64::MAX.ln());
888 if !(lo.is_finite() && hi.is_finite() && lo < hi) {
889 return Err(format!(
890 "residual cascade: invalid spectrum-derived log-lambda domain [{lo}, {hi}]"
891 ));
892 }
893 Ok((lo, hi))
894 }
895
896 /// This profile as the affine spectral REML score it is, when the residual
897 /// is spectral.
898 ///
899 /// With `h_i(lambda) = theta_i + lambda` the cascade's dense-route score is
900 /// term for term an [`AffineRemlProfile`]: `sum log h_i - rank log lambda`
901 /// is the normalized log-determinant, `R = anchor - sum p_i^2/h_i` is the
902 /// profiled residual, and there is one response. The point of saying so is
903 /// the ENCLOSURE. `AffineRemlProfile::enclose` evaluates the mode kernels on
904 /// an interval lambda, so it is a genuine interval extension whose width
905 /// collapses with the cell; [`CascadeRemlProfile::enclose`] can only pad the
906 /// endpoint jets with global Lipschitz constants, and that pad does not
907 /// collapse — see its own note on why the search could not terminate.
908 fn affine_view(&self) -> Result<Option<AffineRemlProfile<'_>>, String> {
909 let CascadeResidualForm::Spectral(spectrum) = &self.residual else {
910 return Ok(None);
911 };
912 let core = self.core;
913 AffineRemlProfile::new(
914 &spectrum.eigenvalue,
915 &spectrum.penalty,
916 &spectrum.projected_square,
917 &spectrum.anchor_energy,
918 (core.y.len() - core.nullity()) as f64,
919 // Every whitened mode carries penalty scale 1, so the penalized
920 // determinant rank is the full Schur rank.
921 spectrum.penalty.len(),
922 self.null_logdet,
923 )
924 .map(Some)
925 .map_err(|error| format!("residual cascade: affine spectral profile rejected: {error}"))
926 }
927
928 /// The normalized log-determinant and its first two `log lambda`
929 /// derivatives.
930 ///
931 /// `O(modes)` and free of linear algebra on every route, which is what lets
932 /// [`Self::enclose`] have the determinant half of the jet at both cell
933 /// endpoints without an evaluation of its own.
934 fn determinant_parts(&self, log_lambda: f64, lambda: f64) -> DeterminantParts {
935 let mut parts = DeterminantParts {
936 normalized_logdet: self.null_logdet,
937 first: 0.0,
938 second: 0.0,
939 };
940 for mode in &self.modes {
941 let theta = mode.eigenvalue;
942 let weight = mode.weight;
943 if theta == 0.0 || weight == 0.0 {
944 continue;
945 }
946 // Stable forms for log(1 + theta/lambda) and
947 // t=theta/(lambda+theta), including widely separated scales.
948 let log_theta = theta.ln();
949 parts.normalized_logdet += weight
950 * if log_theta > log_lambda {
951 (log_theta - log_lambda) + (log_lambda - log_theta).exp().ln_1p()
952 } else {
953 (log_theta - log_lambda).exp().ln_1p()
954 };
955 let t = if theta > lambda {
956 1.0 / (1.0 + lambda / theta)
957 } else {
958 theta / (lambda + theta)
959 };
960 parts.first -= weight * t;
961 parts.second += weight * t * (1.0 - t);
962 }
963 parts
964 }
965
966 fn evaluate(&self, log_lambda: f64) -> Result<CascadeScoreEvaluation, String> {
967 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
968 .map_err(|error| format!("residual cascade: {error}"))?;
969
970 let core = self.core;
971 // R = y'Wy - b'A^-1b. With A' = lambda D,
972 // R' = lambda c'Dc and
973 // R'' = lambda c'Dc - 2 lambda^2 (Dc)'A^-1(Dc).
974 // The third derivative is retained to justify the analytic enclosure
975 // used below; it needs no third solve because the last quadratic is
976 // u'Du for u=A^-1Dc.
977 let (rss, penalty_energy, inverse_penalty_energy, third_energy) = match &self.residual {
978 // The decomposition that produced the determinant modes produced
979 // these three quadratic forms too. Reading them off it costs
980 // O(rank); re-deriving them cost a fresh O(m^3) factorization of
981 // `A = X'WX + λD` at EVERY λ the certified search visits.
982 CascadeResidualForm::Spectral(spectrum) => spectrum.moments(lambda),
983 CascadeResidualForm::Solved => {
984 // ONE factorization of `A` for BOTH right-hand sides below; the
985 // matrix is the same at this λ and only the right-hand side
986 // differs.
987 let solver = core.coeff_solver(lambda)?;
988 let coeff = solver.solve(core, lambda, &core.rhs)?;
989 let dc: Vec<f64> = coeff
990 .iter()
991 .zip(core.pen_diag.iter())
992 .map(|(&c, &d)| d * c)
993 .collect();
994 let penalty_energy = coeff
995 .iter()
996 .zip(dc.iter())
997 .map(|(&c, &v)| c * v)
998 .sum::<f64>();
999 let u = solver.solve(core, lambda, &dc)?;
1000 let inverse_penalty_energy =
1001 dc.iter().zip(u.iter()).map(|(&a, &b)| a * b).sum::<f64>();
1002 let third_energy = u
1003 .iter()
1004 .zip(core.pen_diag.iter())
1005 .map(|(&v, &d)| d * v * v)
1006 .sum::<f64>();
1007 (
1008 core.rss_pen(&coeff),
1009 penalty_energy,
1010 inverse_penalty_energy,
1011 third_energy,
1012 )
1013 }
1014 };
1015 if !(rss.is_finite() && rss > 0.0) {
1016 return Err(format!(
1017 "residual cascade: degenerate penalized residual {rss}"
1018 ));
1019 }
1020 let rss_d1 = lambda * penalty_energy;
1021 let lambda2 = lambda * lambda;
1022 let rss_d2 = rss_d1 - 2.0 * lambda2 * inverse_penalty_energy;
1023 let rss_d3 =
1024 rss_d1 - 6.0 * lambda2 * inverse_penalty_energy + 6.0 * lambda2 * lambda * third_energy;
1025
1026 let DeterminantParts {
1027 normalized_logdet,
1028 first: determinant_d1,
1029 second: determinant_d2,
1030 } = self.determinant_parts(log_lambda, lambda);
1031
1032 let dof = (core.y.len() - core.nullity()) as f64;
1033 let rss_log_d1 = rss_d1 / rss;
1034 let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
1035 let rss_log_d3 = rss_d3 / rss - 3.0 * rss_d1 * rss_d2 / (rss * rss)
1036 + 2.0 * rss_log_d1 * rss_log_d1 * rss_log_d1;
1037 if !(rss_log_d3.is_finite()) {
1038 return Err(format!(
1039 "residual cascade: non-finite analytic residual derivative at log lambda {log_lambda}"
1040 ));
1041 }
1042 let jet = ScoreJet {
1043 value: -0.5 * (normalized_logdet + dof * (rss / dof).ln()),
1044 derivative: -0.5 * (determinant_d1 + dof * rss_log_d1),
1045 curvature: -0.5 * (determinant_d2 + dof * rss_log_d2),
1046 // This profile's enclosure pads with the CLOSED-FORM `third_abs_bound`
1047 // Lipschitz constant rather than the endpoint third derivative, so it
1048 // never reads this field; the exact `rss_log_d3` above is retained
1049 // only as the analyticity check that justifies that bound.
1050 third: 0.0,
1051 };
1052 if !(jet.value.is_finite() && jet.derivative.is_finite() && jet.curvature.is_finite()) {
1053 return Err(format!(
1054 "residual cascade: non-finite REML jet at log lambda {log_lambda}: value {}, derivative {}, curvature {}",
1055 jet.value, jet.derivative, jet.curvature
1056 ));
1057 }
1058 Ok(CascadeScoreEvaluation {
1059 jet,
1060 normalized_logdet,
1061 })
1062 }
1063
1064 /// Outer derivative ranges for the route with no eigenbasis: the INTERSECTION
1065 /// of an additive Lipschitz pad and a multiplicative spectral bracket.
1066 ///
1067 /// Both are outer enclosures of the same two derivatives, so intersecting
1068 /// them is again one — and they fail in opposite places, which is the whole
1069 /// reason both are here.
1070 ///
1071 /// THE PAD. Each determinant mode has `|f''| <= 1/4` and `|f'''| <= 1/4`.
1072 /// After the null-space elimination the profiled residual is a positive
1073 /// mixture of `lambda/(theta+lambda)` kernels plus a lambda-independent
1074 /// residual, so its log has `|g''| <= 2`, `|g'''| <= 6` (the loose moment
1075 /// bounds for variables in `[0,1]`). Endpoint jets plus these bounds enclose
1076 /// the interval without sampling it, and the jets arrive as the search's own
1077 /// `left`/`right` SAMPLES, so this function evaluates the profile zero
1078 /// times. The pad is tight where the score has real curvature — around the
1079 /// optimum, where certifying a unique root actually happens.
1080 ///
1081 /// WHERE THE PAD FAILS. Its radius is `C·width` with `C` of order the
1082 /// residual degrees of freedom, and it shrinks only as fast as the cell.
1083 /// [`Self::log_lambda_domain`] deliberately runs `ln(1/sqrt(eps))` past the
1084 /// extreme Schur eigenvalues, and out there `f'` has decayed to order
1085 /// `rank·sqrt(eps)` — while the search's resolution floor is also
1086 /// `sqrt(eps)`, so `C·width` AT THE FLOOR is larger than the derivative it
1087 /// is meant to bracket. The tail is then neither dismissible (the pad
1088 /// straddles zero) nor refinable (the floor is reached), and the search
1089 /// grinds toward a `ScoreSearchError::Unresolved` it cannot avoid. That is
1090 /// a search that does not terminate on its own domain, not a slow one.
1091 ///
1092 /// THE BRACKET, which has no floor because it is RELATIVE. Write
1093 /// `f' = -(D1 + dof·rho)/2` with `D1 = -sum_i w_i t_i`,
1094 /// `t_i = theta_i/(theta_i+lambda)` and `rho = R'/R > 0`.
1095 ///
1096 /// * every `t_i` falls with `log lambda`, so `D1` RISES: `D1` on the cell is
1097 /// bracketed by its two endpoint values exactly, with no bound at all;
1098 /// * `d log t_i/dx = -(1-t_i)` and `d log[t_i(1-t_i)]/dx = 2t_i - 1`, both
1099 /// in `[-1, 1]`, and a positive mixture's log-derivative is a convex
1100 /// combination of its parts', so `D2` and `R'` each satisfy
1101 /// `|d log(.)/dx| <= 1`;
1102 /// * `R` rises, so `d log rho/dx = d log R'/dx - rho <= 1`, giving
1103 /// `rho(x) <= rho(a)e^w` and `rho(x) >= rho(b)e^{-w}`;
1104 /// * `|R''| <= R'` mode by mode, so `sigma = R''/R - rho^2` lies in
1105 /// `[-rho(1+rho), rho]`.
1106 ///
1107 /// Every one of those bounds is proportional to the quantity it bounds, so
1108 /// in a tail where `f'` merely has a constant sign the bracket excludes zero
1109 /// at a width that does not depend on how far the tail runs.
1110 fn enclose(&self, left: ScoreSample, right: ScoreSample) -> Result<DerivativeEnclosure, String> {
1111 let (lo, hi) = (left.x, right.x);
1112 if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
1113 return Err(format!(
1114 "residual cascade: invalid score-enclosure interval [{lo}, {hi}]"
1115 ));
1116 }
1117 let width = hi - lo;
1118 let dof = (self.core.y.len() - self.core.nullity()) as f64;
1119 let pad = self.lipschitz_pad(left, right, width);
1120 let Some(bracket) = self.multiplicative_bracket(left, right, width, dof)? else {
1121 return Ok(pad);
1122 };
1123 // Two OUTER enclosures of the same real number must overlap — both
1124 // contain the endpoint derivatives, if nothing else. A disjoint pair
1125 // means one of them is not an outer bound, and an enclosure that is not
1126 // an outer bound does not fail loudly downstream: it lets the search
1127 // discard a cell that held a stationary point and return a certified
1128 // wrong answer. Refuse here instead of narrowing to whichever one is
1129 // left, which would be choosing a winner between two derivations with
1130 // no evidence about which is sound.
1131 let derivative = intersect(pad.derivative, bracket.derivative)
1132 .ok_or_else(|| disjoint("derivative", lo, hi, pad.derivative, bracket.derivative))?;
1133 let curvature = intersect(pad.curvature, bracket.curvature)
1134 .ok_or_else(|| disjoint("curvature", lo, hi, pad.curvature, bracket.curvature))?;
1135 Ok(DerivativeEnclosure {
1136 derivative,
1137 curvature,
1138 })
1139 }
1140
1141 /// The additive half of [`Self::enclose`], on its own — the enclosure this
1142 /// module used to return, kept separable so its tail stall can be asserted
1143 /// against the bracket that repairs it rather than described.
1144 fn lipschitz_pad(
1145 &self,
1146 left: ScoreSample,
1147 right: ScoreSample,
1148 width: f64,
1149 ) -> DerivativeEnclosure {
1150 let rank = (self.core.m - self.core.nullity()) as f64;
1151 let dof = (self.core.y.len() - self.core.nullity()) as f64;
1152 let curvature_abs_bound = 0.5 * (0.25 * rank + 2.0 * dof);
1153 let third_abs_bound = 0.5 * (0.25 * rank + 6.0 * dof);
1154 let derivative_radius = curvature_abs_bound * width;
1155 let curvature_radius = third_abs_bound * width;
1156 DerivativeEnclosure {
1157 derivative: ClosedInterval::outward(
1158 (left.derivative - derivative_radius).min(right.derivative - derivative_radius),
1159 (left.derivative + derivative_radius).max(right.derivative + derivative_radius),
1160 ),
1161 curvature: ClosedInterval::outward(
1162 (left.curvature - curvature_radius).min(right.curvature - curvature_radius),
1163 (left.curvature + curvature_radius).max(right.curvature + curvature_radius),
1164 ),
1165 }
1166 }
1167
1168 /// The relative bracket described on [`Self::enclose`], or `None` when the
1169 /// endpoint residual log-slopes cannot be recovered to a definite sign (see
1170 /// below), in which case the pad stands alone and nothing is claimed.
1171 fn multiplicative_bracket(
1172 &self,
1173 left: ScoreSample,
1174 right: ScoreSample,
1175 width: f64,
1176 dof: f64,
1177 ) -> Result<Option<DerivativeEnclosure>, String> {
1178 // Per endpoint: (D1, D2, rho lower estimate, rho upper estimate).
1179 let mut parts = [(0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); 2];
1180 for (slot, sample) in parts.iter_mut().zip([left, right]) {
1181 let lambda = gam_problem::checked_exp_log_strength(sample.x)
1182 .map_err(|error| format!("residual cascade: {error}"))?;
1183 let determinant = self.determinant_parts(sample.x, lambda);
1184 // `evaluate` formed `derivative = -(D1 + dof*rho)/2` from THIS
1185 // determinant value — recomputed here bitwise, same inputs, same
1186 // code — so `-2*derivative` returns that sum exactly (halving and
1187 // doubling are exact) and one subtraction recovers `dof*rho`. The
1188 // recovery's error is the roundoff of the sum it undoes plus its
1189 // own and the division: at most four roundings of magnitude
1190 // `|D1| + |2*derivative|`. That is a rounding COUNT, not a slack to
1191 // tune, and it is carried in both directions.
1192 let total = -2.0 * sample.derivative;
1193 let recovered = (total - determinant.first) / dof;
1194 let slack = 4.0 * f64::EPSILON * (determinant.first.abs() + total.abs()) / dof;
1195 *slot = (
1196 determinant.first,
1197 determinant.second,
1198 recovered - slack,
1199 recovered + slack,
1200 );
1201 }
1202 let (first_lo, second_lo, _, rho_left_upper) = parts[0];
1203 let (first_hi, second_hi, rho_right_lower, rho_right_upper) = parts[1];
1204
1205 // `rho > 0` is a theorem (`R' = lambda c'Dc > 0`), but a recovery that
1206 // cannot place it strictly above zero has lost it to cancellation and
1207 // can no longer carry a RELATIVE bound. Decline rather than guess.
1208 let growth = width.exp();
1209 if !(rho_left_upper > 0.0 && rho_right_upper > 0.0 && growth.is_finite()) {
1210 return Ok(None);
1211 }
1212 // `rho(x) <= rho(a)e^w` integrating the `<= 1` side forward from the
1213 // left endpoint; `rho(x) >= rho(b)e^-w` integrating it back from the
1214 // right. Only those two directions are free of `rho` itself.
1215 let rho_hi = rho_left_upper.max(rho_right_upper) * growth;
1216 let rho_lo = (rho_right_lower / growth).max(0.0);
1217
1218 // D1 rises across the cell, so its endpoint values bracket it exactly.
1219 let derivative = ClosedInterval::outward(
1220 -0.5 * (first_hi + dof * rho_hi),
1221 -0.5 * (first_lo + dof * rho_lo),
1222 );
1223
1224 // D2 > 0 at both ends forces D2 > 0 throughout (a zero inside would make
1225 // `|d log D2/dx| <= 1` impossible on a finite cell), which is what lets
1226 // the relative bound apply; otherwise only `D2 >= 0` is available.
1227 let (d2_lo, d2_hi) = if second_lo > 0.0 && second_hi > 0.0 {
1228 (second_lo.max(second_hi) / growth, second_lo.min(second_hi) * growth)
1229 } else {
1230 (0.0, 0.25 * self.modes.iter().map(|mode| mode.weight).sum::<f64>())
1231 };
1232 let curvature = ClosedInterval::outward(
1233 -0.5 * (d2_hi + dof * rho_hi),
1234 -0.5 * (d2_lo - dof * rho_hi * (1.0 + rho_hi)),
1235 );
1236 Ok(Some(DerivativeEnclosure {
1237 derivative,
1238 curvature,
1239 }))
1240 }
1241}
1242
1243/// The tighter of two outer enclosures of the same quantity, or `None` if they
1244/// are disjoint — which is a contradiction, not a tight answer.
1245fn intersect(a: ClosedInterval, b: ClosedInterval) -> Option<ClosedInterval> {
1246 let merged = ClosedInterval::new(a.lo.max(b.lo), a.hi.min(b.hi));
1247 (merged.lo <= merged.hi).then_some(merged)
1248}
1249
1250fn disjoint(
1251 what: &str,
1252 lo: f64,
1253 hi: f64,
1254 pad: ClosedInterval,
1255 bracket: ClosedInterval,
1256) -> String {
1257 format!(
1258 "residual cascade: the Lipschitz pad and the multiplicative bracket give DISJOINT \
1259 {what} enclosures on [{lo}, {hi}] ({pad:?} versus {bracket:?}); two outer bounds of \
1260 one quantity cannot be disjoint, so one of them is not an outer bound"
1261 )
1262}
1263
1264/// A coefficient solver pinned to one λ (see [`Core::coeff_solver`]). The dense
1265/// arms hold the Cholesky factor so repeated right-hand sides at that λ cost
1266/// only triangular solves; the iterative arm holds the coarse-space
1267/// preconditioner, which is likewise a function of λ alone and whose coarse
1268/// block is an `O(n·q_C²) + O(q_C³)` assembly and factorization — paid once per
1269/// λ, not once per right-hand side.
1270enum CoeffSolver<'a> {
1271 Cached(&'a [f64]),
1272 Factored(Vec<f64>),
1273 Iterative(Preconditioner),
1274}
1275
1276impl CoeffSolver<'_> {
1277 fn solve(&self, core: &Core, lambda: f64, b: &[f64]) -> Result<Vec<f64>, String> {
1278 match self {
1279 Self::Cached(l) => Ok(chol_solve(l, core.m, b)),
1280 Self::Factored(l) => Ok(chol_solve(l, core.m, b)),
1281 Self::Iterative(preconditioner) => core
1282 .pcg_with(lambda, preconditioner, b, None)
1283 .map(|(coeff, _, _)| coeff),
1284 }
1285 }
1286}
1287
1288impl Core {
1289 #[inline]
1290 fn dense_gram_entry(&self, row: usize, col: usize) -> Option<f64> {
1291 let gram = self.dense_gram.as_ref()?;
1292 let (i, j) = if row <= col { (row, col) } else { (col, row) };
1293 Some(gram[i * self.m + j])
1294 }
1295
1296 /// Factor the unpenalized polynomial Gram block. It is tiny (`dim+1 <= 4`)
1297 /// on every route and is the exact anchor for the Schur complement.
1298 fn null_gram_factor(&self) -> Result<(Vec<f64>, f64), String> {
1299 let q = self.nullity();
1300 let mut gram = vec![0.0; q * q];
1301 if self.dense_gram.is_some() {
1302 for i in 0..q {
1303 for j in i..q {
1304 let value = self.dense_gram_entry(i, j).expect("dense Gram exists");
1305 gram[i * q + j] = value;
1306 gram[j * q + i] = value;
1307 }
1308 }
1309 } else {
1310 for row in 0..self.w.len() {
1311 let lo = self.row_ptr[row];
1312 let hi = self.row_ptr[row + 1];
1313 for ea in lo..hi {
1314 let ca = self.col_idx[ea] as usize;
1315 if ca >= q {
1316 break;
1317 }
1318 let weighted = self.w[row] * self.vals[ea];
1319 for eb in ea..hi {
1320 let cb = self.col_idx[eb] as usize;
1321 if cb >= q {
1322 break;
1323 }
1324 gram[ca * q + cb] += weighted * self.vals[eb];
1325 }
1326 }
1327 }
1328 for i in 0..q {
1329 for j in i + 1..q {
1330 gram[j * q + i] = gram[i * q + j];
1331 }
1332 }
1333 }
1334 let logdet = cholesky_logdet(&mut gram, q).map_err(|error| {
1335 format!("residual cascade: polynomial null-space factorization failed: {error}")
1336 })?;
1337 Ok((gram, logdet))
1338 }
1339
1340 /// Apply the penalty-whitened Schur complement `B` without materializing
1341 /// the data Gram. Scratch buffers are supplied by the Lanczos caller so
1342 /// each iteration remains allocation-free apart from the tiny null solve.
1343 fn schur_whitened_matvec(
1344 &self,
1345 null_chol: &[f64],
1346 input: &[f64],
1347 output: &mut [f64],
1348 full: &mut [f64],
1349 gram_full: &mut [f64],
1350 projected_null: &mut [f64],
1351 ) {
1352 let q = self.nullity();
1353 full.fill(0.0);
1354 for (i, &value) in input.iter().enumerate() {
1355 full[q + i] = value / self.pen_diag[q + i].sqrt();
1356 }
1357 self.matvec(0.0, full, gram_full);
1358 let null_coeff = chol_solve(null_chol, q, &gram_full[..q]);
1359 full.fill(0.0);
1360 full[..q].copy_from_slice(&null_coeff);
1361 self.matvec(0.0, full, projected_null);
1362 for i in 0..output.len() {
1363 output[i] = (gram_full[q + i] - projected_null[q + i]) / self.pen_diag[q + i].sqrt();
1364 }
1365 }
1366
1367 /// Exact Schur spectrum under the dense sizing cap, WITH the eigenbasis it
1368 /// is computed from.
1369 ///
1370 /// `eigh` returns the eigenvectors whether or not the caller keeps them.
1371 /// Dropping them here used to leave the residual half of the same score with
1372 /// no way to reach the decomposition, so `evaluate` re-derived it as a fresh
1373 /// Cholesky of `A = X'WX + λD` at every λ the certified search visited —
1374 /// an O(m^3) factorization per trial, standing in for a projection this
1375 /// factorization had already made available.
1376 fn dense_cascade_spectrum(
1377 &self,
1378 null_chol: &[f64],
1379 ) -> Result<(Vec<CascadeSpectralMode>, CascadeResidualSpectrum), String> {
1380 let q = self.nullity();
1381 let rank = self.m - q;
1382 let mut schur = Array2::<f64>::zeros((rank, rank));
1383 let mut cross = vec![0.0; q];
1384 for j in 0..rank {
1385 for (k, value) in cross.iter_mut().enumerate() {
1386 *value = self.dense_gram_entry(k, q + j).expect("dense Gram exists");
1387 }
1388 let projected = chol_solve(null_chol, q, &cross);
1389 for i in 0..=j {
1390 let mut value = self
1391 .dense_gram_entry(q + i, q + j)
1392 .expect("dense Gram exists");
1393 for (k, &coefficient) in projected.iter().enumerate() {
1394 value -=
1395 self.dense_gram_entry(q + i, k).expect("dense Gram exists") * coefficient;
1396 }
1397 value /= (self.pen_diag[q + i] * self.pen_diag[q + j]).sqrt();
1398 schur[(i, j)] = value;
1399 schur[(j, i)] = value;
1400 }
1401 }
1402 let (eigenvalues, eigenvectors) = schur.eigh(Side::Lower).map_err(|error| {
1403 format!("residual cascade: Schur-complement eigendecomposition failed: {error}")
1404 })?;
1405 let scale = eigenvalues
1406 .iter()
1407 .copied()
1408 .map(f64::abs)
1409 .fold(0.0, f64::max);
1410 let roundoff = f64::EPSILON * rank.max(1) as f64 * scale.max(f64::MIN_POSITIVE);
1411 // A mode inside the decomposition's OWN roundoff floor is a null
1412 // direction of the whitened design, not a small positive one. The floor
1413 // is the same quantity the semidefiniteness check below is stated in;
1414 // reading it in one direction only ("this is not really negative") and
1415 // not the other ("so it is not really positive either") is what lets a
1416 // noise-level eigenvalue set the small-lambda end of the search domain
1417 // and divide into the residual there.
1418 let certified = |eigenvalue: f64| {
1419 if eigenvalue > roundoff {
1420 eigenvalue
1421 } else {
1422 0.0
1423 }
1424 };
1425 let modes = eigenvalues
1426 .iter()
1427 .copied()
1428 .enumerate()
1429 .map(|(index, eigenvalue)| {
1430 if !eigenvalue.is_finite() || eigenvalue < -roundoff {
1431 Err(format!(
1432 "residual cascade: penalty-whitened Schur mode {index} is not positive semidefinite ({eigenvalue})"
1433 ))
1434 } else {
1435 Ok(CascadeSpectralMode {
1436 eigenvalue: certified(eigenvalue),
1437 weight: 1.0,
1438 })
1439 }
1440 })
1441 .collect::<Result<Vec<_>, String>>()?;
1442
1443 // The same null elimination and penalty whitening, applied to the
1444 // right-hand side instead of to the Gram: `beta = D^(-1/2)(b1 - G10
1445 // G00^(-1) b0)`, then projected onto the eigenbasis above.
1446 let null_solved = chol_solve(null_chol, q, &self.rhs[..q]);
1447 let mut whitened = vec![0.0_f64; rank];
1448 for (i, value) in whitened.iter_mut().enumerate() {
1449 let mut entry = self.rhs[q + i];
1450 for (k, &coefficient) in null_solved.iter().enumerate() {
1451 entry -= self.dense_gram_entry(q + i, k).expect("dense Gram exists") * coefficient;
1452 }
1453 *value = entry / self.pen_diag[q + i].sqrt();
1454 }
1455 // A null mode carries NO response energy, exactly. The Schur complement
1456 // and the whitened right-hand side are built from the same design `Z`:
1457 // `B = Z'WZ` and `beta = Z'Wy`, so `Bv = 0` gives `Zv = 0` and hence
1458 // `v'beta = (Zv)'Wy = 0`. What the arithmetic returns for such a mode is
1459 // roundoff — and the residual sum divides it by `theta + lambda`, which
1460 // at the bottom of the search domain is SMALLER than that roundoff. On a
1461 // 558-column cascade the three null modes carried `p^2 ~ 3e-16` against
1462 // `lambda ~ 4e-19` and drove the profiled residual to -764 where the
1463 // mathematics bounds it below by the unpenalized residual sum of
1464 // squares. Restoring the exact identity is not a tolerance.
1465 let mut projected_square = vec![0.0_f64; rank];
1466 for (j, square) in projected_square.iter_mut().enumerate() {
1467 if certified(eigenvalues[j]) == 0.0 {
1468 continue;
1469 }
1470 let mut projection = 0.0;
1471 for (i, &value) in whitened.iter().enumerate() {
1472 projection += eigenvectors[(i, j)] * value;
1473 }
1474 *square = projection * projection;
1475 }
1476 let anchor_energy = self.ytwy
1477 - self.rhs[..q]
1478 .iter()
1479 .zip(null_solved.iter())
1480 .map(|(&b, &c)| b * c)
1481 .sum::<f64>();
1482 if !(anchor_energy.is_finite() && projected_square.iter().all(|v| v.is_finite())) {
1483 return Err(format!(
1484 "residual cascade: non-finite spectral residual representation (anchor {anchor_energy})"
1485 ));
1486 }
1487 Ok((
1488 modes,
1489 CascadeResidualSpectrum {
1490 eigenvalue: eigenvalues.iter().copied().map(certified).collect(),
1491 penalty: vec![1.0; rank],
1492 projected_square,
1493 anchor_energy: [anchor_energy],
1494 },
1495 ))
1496 }
1497
1498 /// Fixed-probe Lanczos quadrature of the lambda-independent Schur
1499 /// spectrum. Unlike the previous lambda-dependent SLQ call, its nodes and
1500 /// weights define one smooth analytic score across the entire search
1501 /// domain, so differentiating the scalar kernels is exact for the score
1502 /// being optimized.
1503 fn iterative_cascade_spectrum(
1504 &self,
1505 null_chol: &[f64],
1506 ) -> Result<Vec<CascadeSpectralMode>, String> {
1507 let q0 = self.nullity();
1508 let rank = self.m - q0;
1509 let steps = SLQ_LANCZOS_STEPS.min(rank);
1510 let mut modes = Vec::with_capacity(SLQ_PROBES * steps);
1511 let mut full = vec![0.0; self.m];
1512 let mut gram_full = vec![0.0; self.m];
1513 let mut projected_null = vec![0.0; self.m];
1514 let mut matvec = vec![0.0; rank];
1515 let mut basis: Vec<Vec<f64>> = Vec::with_capacity(steps);
1516
1517 for probe in 0..SLQ_PROBES {
1518 let mut rng =
1519 SplitMix64::new(RNG_SEED ^ (probe as u64).wrapping_mul(0xD134_2543_DE82_EF95));
1520 let inv_norm = 1.0 / (rank as f64).sqrt();
1521 let mut q = (0..rank)
1522 .map(|_| rng.next_sign() * inv_norm)
1523 .collect::<Vec<_>>();
1524 let mut q_previous: Option<Vec<f64>> = None;
1525 let mut alpha = Vec::with_capacity(steps);
1526 let mut beta = Vec::with_capacity(steps.saturating_sub(1));
1527 basis.clear();
1528
1529 for _ in 0..steps {
1530 self.schur_whitened_matvec(
1531 null_chol,
1532 &q,
1533 &mut matvec,
1534 &mut full,
1535 &mut gram_full,
1536 &mut projected_null,
1537 );
1538 let diagonal = matvec
1539 .iter()
1540 .zip(q.iter())
1541 .map(|(&a, &b)| a * b)
1542 .sum::<f64>();
1543 alpha.push(diagonal);
1544 let mut residual = matvec.clone();
1545 for i in 0..rank {
1546 residual[i] -= diagonal * q[i];
1547 }
1548 if let Some(previous) = &q_previous {
1549 let previous_beta = beta.last().copied().unwrap_or(0.0);
1550 for i in 0..rank {
1551 residual[i] -= previous_beta * previous[i];
1552 }
1553 }
1554 basis.push(q.clone());
1555 for direction in &basis {
1556 let projection = residual
1557 .iter()
1558 .zip(direction.iter())
1559 .map(|(&a, &b)| a * b)
1560 .sum::<f64>();
1561 for i in 0..rank {
1562 residual[i] -= projection * direction[i];
1563 }
1564 }
1565 let norm = residual
1566 .iter()
1567 .map(|value| value * value)
1568 .sum::<f64>()
1569 .sqrt();
1570 if !norm.is_finite() {
1571 return Err(
1572 "residual cascade: Schur-spectrum Lanczos produced a non-finite norm"
1573 .into(),
1574 );
1575 }
1576 let rounding_floor =
1577 f64::EPSILON * rank.max(1) as f64 * diagonal.abs().max(f64::MIN_POSITIVE);
1578 if norm <= rounding_floor {
1579 break;
1580 }
1581 beta.push(norm);
1582 q_previous = Some(std::mem::replace(&mut q, residual));
1583 for value in &mut q {
1584 *value /= norm;
1585 }
1586 }
1587
1588 beta.truncate(alpha.len().saturating_sub(1));
1589 let (eigenvalues, first_components) = symmetric_tridiagonal_eigen(&alpha, &beta)?;
1590 let scale = eigenvalues
1591 .iter()
1592 .copied()
1593 .map(f64::abs)
1594 .fold(0.0, f64::max);
1595 let roundoff = f64::EPSILON * alpha.len().max(1) as f64 * scale.max(f64::MIN_POSITIVE);
1596 for (index, (&eigenvalue, &first)) in
1597 eigenvalues.iter().zip(first_components.iter()).enumerate()
1598 {
1599 if !eigenvalue.is_finite() || eigenvalue < -roundoff {
1600 return Err(format!(
1601 "residual cascade: Schur-spectrum Ritz value {index} is not positive semidefinite ({eigenvalue})"
1602 ));
1603 }
1604 let weight = rank as f64 * first * first / SLQ_PROBES as f64;
1605 if !(weight.is_finite() && weight >= 0.0) {
1606 return Err(format!(
1607 "residual cascade: invalid Schur-spectrum quadrature weight {weight}"
1608 ));
1609 }
1610 modes.push(CascadeSpectralMode {
1611 // Same reading of the same floor as the dense route: a Ritz
1612 // value inside the quadrature's own roundoff is a null
1613 // direction, not a small positive mode. Admitting it as
1614 // positive lets it set the small-lambda end of
1615 // `log_lambda_domain`, which is how the search comes to
1616 // demand a solve of `X'WX + λD` at a λ that leaves the
1617 // matrix numerically singular.
1618 eigenvalue: if eigenvalue > roundoff {
1619 eigenvalue
1620 } else {
1621 0.0
1622 },
1623 weight,
1624 });
1625 }
1626 }
1627 Ok(modes)
1628 }
1629
1630 fn reml_profile(&self) -> Result<CascadeRemlProfile<'_>, String> {
1631 let (null_chol, null_logdet) = self.null_gram_factor()?;
1632 let (modes, residual) = if self.dense_gram.is_some() {
1633 let (modes, spectrum) = self.dense_cascade_spectrum(&null_chol)?;
1634 (modes, CascadeResidualForm::Spectral(spectrum))
1635 } else {
1636 (
1637 self.iterative_cascade_spectrum(&null_chol)?,
1638 CascadeResidualForm::Solved,
1639 )
1640 };
1641 Ok(CascadeRemlProfile {
1642 core: self,
1643 null_logdet,
1644 modes,
1645 residual,
1646 })
1647 }
1648
1649 /// Scale a raw point into shifted metric coordinates.
1650 fn scale_point(&self, x: &[f64]) -> [f64; 3] {
1651 let mut z = [0.0_f64; 3];
1652 for a in 0..self.dim {
1653 z[a] = self.metric[a] * x[a] - self.z_lo[a];
1654 }
1655 z
1656 }
1657
1658 /// Sparse basis row at a scaled point: polynomial layer then every bump
1659 /// whose support covers it, as (column, value) pairs sorted by column.
1660 fn basis_row_scaled(&self, z: &[f64; 3]) -> Vec<(usize, f64)> {
1661 let mut row = Vec::with_capacity(self.dim + 1 + self.levels.len() * 8);
1662 row.push((0, 1.0));
1663 for a in 0..self.dim {
1664 row.push((a + 1, 2.0 * z[a] / self.z_range[a] - 1.0));
1665 }
1666 for level in &self.levels {
1667 let start = row.len();
1668 level.grid.for_neighbors(z, |j| {
1669 let c = &level.centers[j as usize];
1670 let r = dist2(z, c, self.dim).sqrt() / level.delta;
1671 let v = wendland(r);
1672 if v > 0.0 {
1673 row.push((level.col_offset + j as usize, v));
1674 }
1675 });
1676 row[start..].sort_unstable_by_key(|&(col, _)| col);
1677 }
1678 row
1679 }
1680
1681 /// `out = (X'WX + λD)·v` through the CSR rows: O(nnz).
1682 fn matvec(&self, lambda: f64, v: &[f64], out: &mut [f64]) {
1683 for (o, (&d, &x)) in out.iter_mut().zip(self.pen_diag.iter().zip(v.iter())) {
1684 *o = lambda * d * x;
1685 }
1686 for i in 0..self.w.len() {
1687 let lo = self.row_ptr[i];
1688 let hi = self.row_ptr[i + 1];
1689 let mut t = 0.0;
1690 for e in lo..hi {
1691 t += self.vals[e] * v[self.col_idx[e] as usize];
1692 }
1693 t *= self.w[i];
1694 for e in lo..hi {
1695 out[self.col_idx[e] as usize] += self.vals[e] * t;
1696 }
1697 }
1698 }
1699
1700 /// Jacobi / level-diagonal preconditioner: `diag(X'WX) + λ·diag(λD)`.
1701 /// Levels share a constant prior weight, so this IS the level-block
1702 /// (BPX-flavored) diagonal in the multilevel frame.
1703 /// Coarse column count of the additive-Schwarz coarse space at `λ`: the
1704 /// polynomial layer plus the longest prefix of data-dominated levels
1705 /// (`λ d_l < COARSE_DOMINANCE · median diag(X'WX) over the level`), with the
1706 /// two coarsest levels always deflated and the total capped at
1707 /// [`COARSE_SPACE_MAX`]. Because `d_l` rises while the per-level data weight
1708 /// falls, the data-dominated set is a contiguous prefix, so one scan from the
1709 /// coarsest level finds the cut. (See [`COARSE_DOMINANCE`].)
1710 fn coarse_space_cols(&self, lambda: f64) -> usize {
1711 let mut ncoarse = self.nullity();
1712 let mut buf: Vec<f64> = Vec::new();
1713 for (li, level) in self.levels.iter().enumerate() {
1714 let a = level.col_offset;
1715 let b = a + level.centers.len();
1716 if b <= a {
1717 continue;
1718 }
1719 if b > COARSE_SPACE_MAX {
1720 break;
1721 }
1722 let dominated = if li < MIN_COARSE_LEVELS {
1723 true
1724 } else {
1725 buf.clear();
1726 buf.extend_from_slice(&self.gram_diag[a..b]);
1727 buf.sort_unstable_by(|x, y| x.partial_cmp(y).unwrap());
1728 let gram_median = buf[buf.len() / 2];
1729 lambda * level.weight < COARSE_DOMINANCE * gram_median
1730 };
1731 if dominated {
1732 ncoarse = b;
1733 } else {
1734 break;
1735 }
1736 }
1737 // Keep at least one fine column so the split is well-defined; if every
1738 // level is coarse the iterative route is degenerate anyway and the dense
1739 // route would have been taken, but guard regardless.
1740 let ncoarse = ncoarse.min(self.m);
1741 // Debug-only coarse-space layout trace (#1032). Gated on the log level so
1742 // the per-call string build stays out of this preconditioner hot path,
1743 // and routed through `log` (an `eprintln!` here trips the src banned-macro
1744 // gate and broke the build).
1745 if log::log_enabled!(log::Level::Debug) {
1746 let mut s = String::new();
1747 for (li, level) in self.levels.iter().enumerate() {
1748 let a = level.col_offset;
1749 let b = a + level.centers.len();
1750 let mut buf: Vec<f64> = self.gram_diag[a..b].to_vec();
1751 buf.sort_unstable_by(|x, y| x.partial_cmp(y).unwrap());
1752 let med = if buf.is_empty() {
1753 0.0
1754 } else {
1755 buf[buf.len() / 2]
1756 };
1757 let coarse = b <= ncoarse;
1758 s.push_str(&format!(
1759 " L{li}[{}c off{a} w={:.2e} λw={:.2e} med={:.2e} {}]",
1760 level.centers.len(),
1761 level.weight,
1762 lambda * level.weight,
1763 med,
1764 if coarse { "C" } else { "F" }
1765 ));
1766 }
1767 log::debug!(
1768 "[1032-COARSE] λ={lambda:.3e} m={} ncoarse={ncoarse} cap={COARSE_SPACE_MAX}{s}",
1769 self.m
1770 );
1771 }
1772 ncoarse
1773 }
1774
1775 /// Build the coarse-space additive-Schwarz preconditioner at `λ`: assemble
1776 /// and factor the coarse block `A_CC` from the CSR (coarse columns are the
1777 /// prefix `[0, ncoarse)`, and each CSR row is column-sorted, so a row's
1778 /// coarse entries are its leading run), then the Jacobi diagonal on the fine
1779 /// tail. `O(n · q_C²) + O(ncoarse³)` — paid once per `λ`, not per CG step.
1780 fn build_preconditioner(&self, lambda: f64) -> Result<Preconditioner, String> {
1781 let m = self.m;
1782 let nc = self.coarse_space_cols(lambda);
1783 let mut acc = vec![0.0_f64; nc * nc];
1784 for i in 0..self.w.len() {
1785 let lo = self.row_ptr[i];
1786 let hi = self.row_ptr[i + 1];
1787 // Leading run of coarse columns (CSR rows are column-sorted).
1788 let mut end = lo;
1789 while end < hi && (self.col_idx[end] as usize) < nc {
1790 end += 1;
1791 }
1792 for ea in lo..end {
1793 let ca = self.col_idx[ea] as usize;
1794 let va = self.w[i] * self.vals[ea];
1795 for eb in ea..end {
1796 let cb = self.col_idx[eb] as usize;
1797 acc[ca * nc + cb] += va * self.vals[eb];
1798 }
1799 }
1800 }
1801 for i in 0..nc {
1802 for j in i + 1..nc {
1803 acc[j * nc + i] = acc[i * nc + j];
1804 }
1805 }
1806 for i in 0..nc {
1807 acc[i * nc + i] += lambda * self.pen_diag[i];
1808 }
1809 let coarse_logdet = cholesky_logdet(&mut acc, nc)?;
1810 let mut inv_fine = Vec::with_capacity(m - nc);
1811 let mut inv_sqrt_fine = Vec::with_capacity(m - nc);
1812 let mut fine_logdet = 0.0;
1813 for j in nc..m {
1814 let p = self.gram_diag[j] + lambda * self.pen_diag[j];
1815 if !(p.is_finite() && p > EIG_FLOOR) {
1816 return Err(format!(
1817 "residual cascade: non-positive preconditioner diagonal {p} at column {j}"
1818 ));
1819 }
1820 inv_fine.push(1.0 / p);
1821 inv_sqrt_fine.push(1.0 / p.sqrt());
1822 fine_logdet += p.ln();
1823 }
1824 Ok(Preconditioner {
1825 ncoarse: nc,
1826 coarse_chol: acc,
1827 coarse_logdet,
1828 inv_fine,
1829 inv_sqrt_fine,
1830 fine_logdet,
1831 })
1832 }
1833
1834 /// Preconditioned CG on `(X'WX + λD)c = b` to relative residual CG_RTOL.
1835 /// Returns the solution with its backward-error certificate.
1836 fn pcg(
1837 &self,
1838 lambda: f64,
1839 b: &[f64],
1840 warm: Option<&[f64]>,
1841 ) -> Result<(Vec<f64>, f64, usize), String> {
1842 let prec = self.build_preconditioner(lambda)?;
1843 self.pcg_with(lambda, &prec, b, warm)
1844 }
1845
1846 /// [`Core::pcg`] against a preconditioner the caller already built at this
1847 /// λ. The preconditioner depends on λ and nothing else, so a caller with
1848 /// several right-hand sides at one λ builds it once.
1849 fn pcg_with(
1850 &self,
1851 lambda: f64,
1852 prec: &Preconditioner,
1853 b: &[f64],
1854 warm: Option<&[f64]>,
1855 ) -> Result<(Vec<f64>, f64, usize), String> {
1856 let m = self.m;
1857 let b_norm = b.iter().map(|v| v * v).sum::<f64>().sqrt();
1858 if b_norm == 0.0 {
1859 return Ok((vec![0.0; m], 0.0, 0));
1860 }
1861 let mut zv = vec![0.0; m];
1862 let mut x = match warm {
1863 Some(x0) => {
1864 if x0.len() != m {
1865 return Err(format!(
1866 "residual cascade: warm-start length {} != system size {m}",
1867 x0.len()
1868 ));
1869 }
1870 x0.to_vec()
1871 }
1872 None => {
1873 prec.solve(b, &mut zv);
1874 zv.clone()
1875 }
1876 };
1877 let mut r = vec![0.0; m];
1878 self.matvec(lambda, &x, &mut r);
1879 for (ri, &bi) in r.iter_mut().zip(b.iter()) {
1880 *ri = bi - *ri;
1881 }
1882 prec.solve(&r, &mut zv);
1883 let mut p_dir = zv.clone();
1884 let mut rz: f64 = r.iter().zip(zv.iter()).map(|(&a, &c)| a * c).sum();
1885 let mut ap = vec![0.0; m];
1886 let max_iters = CG_MAX_ITERS;
1887 for iter in 0..max_iters {
1888 let r_norm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
1889 if r_norm <= CG_RTOL * b_norm {
1890 return Ok((x, r_norm / b_norm, iter));
1891 }
1892 self.matvec(lambda, &p_dir, &mut ap);
1893 let pap: f64 = p_dir.iter().zip(ap.iter()).map(|(&a, &c)| a * c).sum();
1894 if !(pap.is_finite() && pap > 0.0) {
1895 return Err(format!(
1896 "residual cascade: CG curvature breakdown (p'Ap = {pap}) at iteration {iter}"
1897 ));
1898 }
1899 let alpha = rz / pap;
1900 for j in 0..m {
1901 x[j] += alpha * p_dir[j];
1902 r[j] -= alpha * ap[j];
1903 }
1904 prec.solve(&r, &mut zv);
1905 let rz_new: f64 = r.iter().zip(zv.iter()).map(|(&a, &c)| a * c).sum();
1906 let beta = rz_new / rz;
1907 rz = rz_new;
1908 for j in 0..m {
1909 p_dir[j] = zv[j] + beta * p_dir[j];
1910 }
1911 }
1912 Err(format!(
1913 "residual cascade: CG failed to reach relative residual {CG_RTOL} within \
1914 {CG_MAX_ITERS} iterations (the coarse-space additive-Schwarz preconditioner should \
1915 make this n-independent; this indicates a degenerate design)"
1916 ))
1917 }
1918
1919 /// Expand the cached dense upper Gram + λD into a full symmetric matrix.
1920 fn dense_system(&self, lambda: f64) -> Option<Vec<f64>> {
1921 let gram = self.dense_gram.as_ref()?;
1922 let m = self.m;
1923 let mut a = vec![0.0; m * m];
1924 for i in 0..m {
1925 for j in i..m {
1926 let mut v = gram[i * m + j];
1927 if i == j {
1928 v += lambda * self.pen_diag[i];
1929 }
1930 a[i * m + j] = v;
1931 a[j * m + i] = v;
1932 }
1933 }
1934 Some(a)
1935 }
1936
1937 /// Exact log-determinant of `X'WX + λD` by dense Cholesky. Errors when
1938 /// the design is past the dense sizing cap.
1939 fn logdet_dense(&self, lambda: f64) -> Result<f64, String> {
1940 let mut a = self.dense_system(lambda).ok_or_else(|| {
1941 format!(
1942 "residual cascade: dense logdet requested past the sizing cap \
1943 (m = {} > {DENSE_GRAM_MAX})",
1944 self.m
1945 )
1946 })?;
1947 cholesky_logdet(&mut a, self.m)
1948 }
1949
1950 /// SLQ log-determinant: exact control variate `log|P|` (the coarse-space
1951 /// additive-Schwarz preconditioner's own log-determinant — `log|A_CC|` plus
1952 /// the fine Jacobi `Σ_F log A_jj`) plus stochastic Lanczos quadrature for
1953 /// `tr log(R⁻¹ A R⁻ᵀ)`, `P = R Rᵀ`, on fixed deterministic Rademacher probes
1954 /// shared across every λ (common random numbers ⇒ the REML criterion is a
1955 /// smooth deterministic function of λ). The same coarse deflation that makes
1956 /// the PCG iteration count n-independent makes `R⁻¹ A R⁻ᵀ` uniformly
1957 /// conditioned, so the Lanczos quadrature converges in a depth-independent
1958 /// number of steps too.
1959 fn logdet_slq(&self, lambda: f64) -> Result<f64, String> {
1960 let m = self.m;
1961 let prec = self.build_preconditioner(lambda)?;
1962 let logdet = prec.logdet();
1963 // M·v = R⁻¹ A R⁻ᵀ v (eigenvalues of P^{−1/2} A P^{−1/2}) without forming M.
1964 let mut scratch_in = vec![0.0; m];
1965 let mut scratch_out = vec![0.0; m];
1966 let mut vbuf = vec![0.0; m];
1967 let mut trace_est = 0.0;
1968 let steps = SLQ_LANCZOS_STEPS.min(m);
1969 let mut basis: Vec<Vec<f64>> = Vec::with_capacity(steps);
1970 for probe in 0..SLQ_PROBES {
1971 let mut rng =
1972 SplitMix64::new(RNG_SEED ^ (probe as u64).wrapping_mul(0xD134_2543_DE82_EF95));
1973 let mut q = vec![0.0; m];
1974 for qj in q.iter_mut() {
1975 *qj = rng.next_sign();
1976 }
1977 let z_norm2 = m as f64;
1978 let inv_norm = 1.0 / (m as f64).sqrt();
1979 for qj in q.iter_mut() {
1980 *qj *= inv_norm;
1981 }
1982 // Lanczos with full reorthogonalization.
1983 basis.clear();
1984 let mut alpha = Vec::with_capacity(steps);
1985 let mut beta: Vec<f64> = Vec::with_capacity(steps);
1986 let mut q_prev: Option<Vec<f64>> = None;
1987 for _step in 0..steps {
1988 // v = R⁻¹ A R⁻ᵀ q.
1989 prec.apply_r_inv_t(&q, &mut scratch_in);
1990 self.matvec(lambda, &scratch_in, &mut scratch_out);
1991 prec.apply_r_inv(&scratch_out, &mut vbuf);
1992 let mut v: Vec<f64> = vbuf.clone();
1993 let a: f64 = v.iter().zip(q.iter()).map(|(&x, &y)| x * y).sum();
1994 alpha.push(a);
1995 for j in 0..m {
1996 v[j] -= a * q[j];
1997 }
1998 if let Some(prev) = &q_prev {
1999 let b_prev = beta.last().copied().unwrap_or(0.0);
2000 for j in 0..m {
2001 v[j] -= b_prev * prev[j];
2002 }
2003 }
2004 // Full reorthogonalization against the stored basis.
2005 basis.push(q.clone());
2006 for qb in &basis {
2007 let proj: f64 = v.iter().zip(qb.iter()).map(|(&x, &y)| x * y).sum();
2008 for j in 0..m {
2009 v[j] -= proj * qb[j];
2010 }
2011 }
2012 let b: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
2013 if !(b.is_finite()) {
2014 return Err("residual cascade: Lanczos breakdown (non-finite norm)".into());
2015 }
2016 if b < 1e-13 {
2017 break;
2018 }
2019 beta.push(b);
2020 q_prev = Some(std::mem::replace(&mut q, v));
2021 for qj in q.iter_mut() {
2022 *qj /= b;
2023 }
2024 }
2025 beta.truncate(alpha.len().saturating_sub(1));
2026 let (theta, tau) = symmetric_tridiagonal_eigen(&alpha, &beta)?;
2027 let mut quad = 0.0;
2028 for (&t, &w0) in theta.iter().zip(tau.iter()) {
2029 if !(t.is_finite() && t > EIG_FLOOR) {
2030 return Err(format!(
2031 "residual cascade: non-positive Ritz value {t} in SLQ (system not PD)"
2032 ));
2033 }
2034 quad += w0 * w0 * t.ln();
2035 }
2036 trace_est += z_norm2 * quad;
2037 }
2038 Ok(logdet + trace_est / SLQ_PROBES as f64)
2039 }
2040
2041 /// Log-determinant through the route the sizing contract picks.
2042 fn logdet(&self, lambda: f64) -> Result<(f64, LogdetMethod), String> {
2043 if self.dense_gram.is_some() {
2044 Ok((self.logdet_dense(lambda)?, LogdetMethod::DenseExact))
2045 } else {
2046 Ok((self.logdet_slq(lambda)?, LogdetMethod::Slq))
2047 }
2048 }
2049
2050 /// The coefficient solver at a FIXED λ, obtained once so that several
2051 /// right-hand sides can share one factorization or one preconditioner.
2052 ///
2053 /// Neither `A = X'WX + λD` nor its preconditioner depends on the right-hand
2054 /// side, so a caller that needs two solves at the same λ must not pay two
2055 /// O(m³) Cholesky factorizations — or two coarse-block assemblies — for it.
2056 /// [`CascadeResidualForm::Solved`] needs exactly that pair (`A⁻¹b` and then
2057 /// `A⁻¹Dc`), and going through `solve_coeff`/`pcg` twice rebuilt the
2058 /// identical operator both times.
2059 fn coeff_solver(&self, lambda: f64) -> Result<CoeffSolver<'_>, String> {
2060 if let Some(l) = &self.predict_chol {
2061 return Ok(CoeffSolver::Cached(l));
2062 }
2063 if let Some(mut a) = self.dense_system(lambda) {
2064 cholesky_logdet(&mut a, self.m)?;
2065 return Ok(CoeffSolver::Factored(a));
2066 }
2067 Ok(CoeffSolver::Iterative(self.build_preconditioner(lambda)?))
2068 }
2069
2070 /// Coefficient solve at λ: dense Cholesky when cached, else certified PCG.
2071 fn solve_coeff(
2072 &self,
2073 lambda: f64,
2074 b: &[f64],
2075 warm: Option<&[f64]>,
2076 ) -> Result<(Vec<f64>, f64, usize), String> {
2077 // A core rebuilt from a persisted state carries no training design, only
2078 // the factored precision `L` of `A = X'WX + λD` at the fit's λ. Replay
2079 // the solve through it (exact — predict always solves at that same λ).
2080 if let Some(l) = &self.predict_chol {
2081 return Ok((chol_solve(l, self.m, b), 0.0, 0));
2082 }
2083 if let Some(mut a) = self.dense_system(lambda) {
2084 cholesky_logdet(&mut a, self.m)?;
2085 return Ok((chol_solve(&a, self.m, b), 0.0, 0));
2086 }
2087 self.pcg(lambda, b, warm)
2088 }
2089
2090 /// Assemble the lower Cholesky factor `L` of `A = X'WX + λD` as a dense
2091 /// `m × m` row-major matrix — the factored precision a persisted predict
2092 /// replays its posterior-variance solve through. Uses the cached dense Gram
2093 /// when present; otherwise scatters the CSR row outer products into the
2094 /// upper triangle (one O(nnz·q) pass), the same assembly `build` uses under
2095 /// the sizing cap, just without the cap. Factoring is O(m³) — paid once at
2096 /// snapshot time, not per predict.
2097 fn assemble_predict_factor(&self, lambda: f64) -> Result<Vec<f64>, String> {
2098 let m = self.m;
2099 let mut a = vec![0.0_f64; m * m];
2100 if let Some(gram) = &self.dense_gram {
2101 for i in 0..m {
2102 for j in i..m {
2103 let v = gram[i * m + j];
2104 a[i * m + j] = v;
2105 a[j * m + i] = v;
2106 }
2107 }
2108 } else {
2109 for i in 0..self.w.len() {
2110 let lo = self.row_ptr[i];
2111 let hi = self.row_ptr[i + 1];
2112 for ea in lo..hi {
2113 let ca = self.col_idx[ea] as usize;
2114 let va = self.w[i] * self.vals[ea];
2115 for eb in ea..hi {
2116 let cb = self.col_idx[eb] as usize;
2117 a[ca * m + cb] += va * self.vals[eb];
2118 }
2119 }
2120 }
2121 // Mirror the upper triangle into the lower.
2122 for i in 0..m {
2123 for j in i + 1..m {
2124 a[j * m + i] = a[i * m + j];
2125 }
2126 }
2127 }
2128 for (i, d) in self.pen_diag.iter().enumerate() {
2129 a[i * m + i] += lambda * d;
2130 }
2131 cholesky_logdet(&mut a, m)?;
2132 Ok(a)
2133 }
2134
2135 /// Penalized residual quadratic at a solution: `y'Wy − c'X'Wy`.
2136 fn rss_pen(&self, coeff: &[f64]) -> f64 {
2137 let mut quad = 0.0;
2138 for (c, r) in coeff.iter().zip(self.rhs.iter()) {
2139 quad += c * r;
2140 }
2141 self.ytwy - quad
2142 }
2143
2144 /// Number of unpenalized (polynomial) columns.
2145 fn nullity(&self) -> usize {
2146 self.dim + 1
2147 }
2148
2149 /// Working residual `r_i = y_i − (Xc)_i`.
2150 fn residuals(&self, coeff: &[f64]) -> Vec<f64> {
2151 let n = self.y.len();
2152 let mut r = Vec::with_capacity(n);
2153 for i in 0..n {
2154 let mut fit = 0.0;
2155 for e in self.row_ptr[i]..self.row_ptr[i + 1] {
2156 fit += self.vals[e] * coeff[self.col_idx[e] as usize];
2157 }
2158 r.push(self.y[i] - fit);
2159 }
2160 r
2161 }
2162}
2163
2164// ──────────────────── symmetric tridiagonal eigensolver ─────────────────────
2165
2166/// Eigenvalues and FIRST eigenvector components of a symmetric tridiagonal
2167/// matrix (diag `d`, off-diagonal `e`), by implicit-shift QL with the
2168/// first-row vector carried through the rotations — exactly what Lanczos
2169/// quadrature needs.
2170fn symmetric_tridiagonal_eigen(d: &[f64], e: &[f64]) -> Result<(Vec<f64>, Vec<f64>), String> {
2171 let n = d.len();
2172 if n == 0 {
2173 return Ok((Vec::new(), Vec::new()));
2174 }
2175 let mut diag = d.to_vec();
2176 let mut off = vec![0.0; n];
2177 off[..n - 1].copy_from_slice(&e[..n - 1]);
2178 let mut first = vec![0.0; n];
2179 first[0] = 1.0;
2180 for l in 0..n {
2181 let mut iter = 0;
2182 loop {
2183 // Find a negligible off-diagonal to split at.
2184 let mut msplit = n - 1;
2185 for mm in l..n - 1 {
2186 let dd = diag[mm].abs() + diag[mm + 1].abs();
2187 if off[mm].abs() <= f64::EPSILON * dd {
2188 msplit = mm;
2189 break;
2190 }
2191 }
2192 if msplit == l {
2193 break;
2194 }
2195 iter += 1;
2196 if iter > 60 {
2197 return Err("residual cascade: tridiagonal QL failed to converge".into());
2198 }
2199 let mut g = (diag[l + 1] - diag[l]) / (2.0 * off[l]);
2200 let mut r = g.hypot(1.0);
2201 g = diag[msplit] - diag[l] + off[l] / (g + r.copysign(g));
2202 let (mut s, mut c) = (1.0, 1.0);
2203 let mut p = 0.0;
2204 let mut broke_early = false;
2205 for i in (l..msplit).rev() {
2206 let mut f = s * off[i];
2207 let b = c * off[i];
2208 r = f.hypot(g);
2209 off[i + 1] = r;
2210 if r == 0.0 {
2211 diag[i + 1] -= p;
2212 off[msplit] = 0.0;
2213 broke_early = true;
2214 break;
2215 }
2216 s = f / r;
2217 c = g / r;
2218 g = diag[i + 1] - p;
2219 r = (diag[i] - g) * s + 2.0 * c * b;
2220 p = s * r;
2221 diag[i + 1] = g + p;
2222 g = c * r - b;
2223 // Carry the first-row eigenvector components.
2224 f = first[i + 1];
2225 first[i + 1] = s * first[i] + c * f;
2226 first[i] = c * first[i] - s * f;
2227 }
2228 if broke_early {
2229 continue;
2230 }
2231 diag[l] -= p;
2232 off[l] = g;
2233 off[msplit] = 0.0;
2234 }
2235 }
2236 Ok((diag, first))
2237}
2238
2239// ───────────────────────────── net construction ─────────────────────────────
2240
2241/// Extend a nested net to covering radius `h` over the DOMAIN: first every data
2242/// point further than `h` from the (seeded) net becomes a new center, then every
2243/// cell of the `h`-grid over the bounding box `[0, box_hi]` whose centre is not
2244/// yet within `h` of the net is filled with a synthetic center. O((n + box
2245/// cells)·3^d). Returns the new centers.
2246///
2247/// Covering the box, not merely the data cloud, is what the multilevel Wendland
2248/// norm-equivalence (Narcowich–Ward inverse estimates + Le Gia–Wendland
2249/// multilevel stability) actually requires: the nested centres must be
2250/// quasi-uniform over the domain Ω. In data-dense regions every cell is already
2251/// covered by a data center, so the fill is a no-op there; in a data void it
2252/// plants the fine centres whose coefficients carry no data and revert to the
2253/// prior — the mechanism by which the posterior mean bridges a gap (coarse
2254/// data-pinned bumps) while the posterior variance GROWS into it (fine void
2255/// bumps the data cannot pin). The synthetic centres carry (almost) no data
2256/// rows, so their Gram diagonal is ~0 and they land in the penalty-dominated
2257/// fine block where the Jacobi preconditioner is exact — they neither perturb
2258/// the coarse factorization nor the n-independent iteration count.
2259fn extend_net(
2260 net: &mut Vec<[f64; 3]>,
2261 points: &[[f64; 3]],
2262 dim: usize,
2263 h: f64,
2264 box_hi: &[f64; 3],
2265) -> Vec<[f64; 3]> {
2266 let mut grid = HashGrid::new(h, dim);
2267 for (idx, c) in net.iter().enumerate() {
2268 grid.insert(idx as u32, c);
2269 }
2270 let h2 = h * h;
2271 let mut new_centers = Vec::new();
2272 let try_add = |net: &mut Vec<[f64; 3]>,
2273 grid: &mut HashGrid,
2274 new_centers: &mut Vec<[f64; 3]>,
2275 p: &[f64; 3]| {
2276 let mut covered = false;
2277 grid.for_neighbors(p, |j| {
2278 if !covered && dist2(p, &net[j as usize], dim) <= h2 {
2279 covered = true;
2280 }
2281 });
2282 if !covered {
2283 let idx = net.len() as u32;
2284 net.push(*p);
2285 grid.insert(idx, p);
2286 new_centers.push(*p);
2287 }
2288 };
2289 for p in points {
2290 try_add(net, &mut grid, &mut new_centers, p);
2291 if net.len() > MAX_CENTERS {
2292 return new_centers;
2293 }
2294 }
2295 // Fill the bounding box so the net covers the domain, not just the data.
2296 //
2297 // The box has ~`(box_hi/h)^dim` cells, so the fill cost grows like
2298 // `(2^l)^dim` as the covering radius `h = h₀·2^{-l}` shrinks with the
2299 // level `l`. At fine levels below the data spacing that is an explosion
2300 // (every sub-data-spacing cell of the whole domain becomes a synthetic
2301 // center), which is unbounded work the caller never needs: once the net
2302 // crosses `MAX_CENTERS` the build path errors and the auto-route's typed
2303 // next-level assessment reports center-capacity underresolution. So
2304 // cap the fill IN the loop — stop planting synthetic centers the moment
2305 // the net exceeds the cap rather than materializing the entire fine-level
2306 // box first. Coarse levels (few cells, never near the cap) keep the full
2307 // quasi-uniform domain fill and the polynomial-bridge gap behavior intact.
2308 let mut cells = [1_i64; 3];
2309 for a in 0..dim {
2310 cells[a] = (box_hi[a] / h).ceil() as i64 + 1;
2311 }
2312 let mut c = [0.0_f64; 3];
2313 'fill: for i0 in 0..cells[0] {
2314 c[0] = (i0 as f64 + 0.5) * h;
2315 for i1 in 0..cells[1] {
2316 if dim > 1 {
2317 c[1] = (i1 as f64 + 0.5) * h;
2318 }
2319 for i2 in 0..cells[2] {
2320 if dim > 2 {
2321 c[2] = (i2 as f64 + 0.5) * h;
2322 }
2323 try_add(net, &mut grid, &mut new_centers, &c);
2324 if net.len() > MAX_CENTERS {
2325 break 'fill;
2326 }
2327 }
2328 }
2329 }
2330 new_centers
2331}
2332
2333impl ResidualCascadeDesign {
2334 /// Build the cascade design: validate, scale by the metric, grow `levels`
2335 /// nested nets, and assemble the sparse design plus its sufficient
2336 /// statistics in O(n·(levels + 3^d)).
2337 ///
2338 /// `xs` holds one slice per axis (2 or 3 of them), `metric` the positive
2339 /// per-axis scaling of the learned metric, `sobolev_s` the Sobolev order
2340 /// of the equivalent (semi)norm — must satisfy `d/2 < s ≤ (d+3)/2` (the
2341 /// Wendland-(3,1) native smoothness).
2342 pub fn build(
2343 xs: &[&[f64]],
2344 y: &[f64],
2345 w: &[f64],
2346 metric: &[f64],
2347 sobolev_s: f64,
2348 levels: usize,
2349 ) -> Result<Self, String> {
2350 let dim = xs.len();
2351 if !(dim == 2 || dim == 3) {
2352 return Err(format!(
2353 "residual cascade: built for scattered 2-3D smooths, got {dim} axes"
2354 ));
2355 }
2356 let n = y.len();
2357 if w.len() != n || xs.iter().any(|x| x.len() != n) {
2358 return Err(format!(
2359 "residual cascade: length mismatch (y={n}, w={}, axes={:?})",
2360 w.len(),
2361 xs.iter().map(|x| x.len()).collect::<Vec<_>>()
2362 ));
2363 }
2364 if n <= dim + 1 {
2365 return Err(format!(
2366 "residual cascade: needs more than {} rows for the profiled REML degrees of \
2367 freedom, got {n}",
2368 dim + 1
2369 ));
2370 }
2371 if metric.len() != dim || metric.iter().any(|&s| !(s.is_finite() && s > 0.0)) {
2372 return Err(format!(
2373 "residual cascade: metric must be {dim} finite positive scales, got {metric:?}"
2374 ));
2375 }
2376 if !(sobolev_s > dim as f64 / 2.0 && sobolev_s <= (dim as f64 + 3.0) / 2.0) {
2377 return Err(format!(
2378 "residual cascade: sobolev_s must lie in (d/2, (d+3)/2] = ({}, {}] for the \
2379 Wendland-(3,1) bump, got {sobolev_s}",
2380 dim as f64 / 2.0,
2381 (dim as f64 + 3.0) / 2.0
2382 ));
2383 }
2384 if levels == 0 || levels > MAX_LEVELS {
2385 return Err(format!(
2386 "residual cascade: levels must be in 1..={MAX_LEVELS}, got {levels}"
2387 ));
2388 }
2389 for i in 0..n {
2390 if !(y[i].is_finite() && w[i].is_finite() && w[i] > 0.0)
2391 || xs.iter().any(|x| !x[i].is_finite())
2392 {
2393 return Err(format!(
2394 "residual cascade: non-finite or non-positive input at row {i}"
2395 ));
2396 }
2397 }
2398 // Scaled, corner-shifted coordinates.
2399 let mut z_lo = [f64::INFINITY; 3];
2400 let mut z_hi = [f64::NEG_INFINITY; 3];
2401 for a in 0..dim {
2402 for &v in xs[a] {
2403 let s = metric[a] * v;
2404 z_lo[a] = z_lo[a].min(s);
2405 z_hi[a] = z_hi[a].max(s);
2406 }
2407 }
2408 let mut z_range = [1.0_f64; 3];
2409 let mut max_range = 0.0_f64;
2410 for a in 0..dim {
2411 if !(z_hi[a] > z_lo[a]) {
2412 return Err(format!(
2413 "residual cascade: degenerate axis {a} bounding box [{}, {}]",
2414 z_lo[a], z_hi[a]
2415 ));
2416 }
2417 z_range[a] = z_hi[a] - z_lo[a];
2418 max_range = max_range.max(z_range[a]);
2419 }
2420 for a in dim..3 {
2421 z_lo[a] = 0.0;
2422 }
2423 let z: Vec<[f64; 3]> = (0..n)
2424 .map(|i| {
2425 let mut p = [0.0_f64; 3];
2426 for a in 0..dim {
2427 p[a] = metric[a] * xs[a][i] - z_lo[a];
2428 }
2429 p
2430 })
2431 .collect();
2432 let mut metric3 = [1.0_f64; 3];
2433 metric3[..dim].copy_from_slice(metric);
2434
2435 let h0 = H0_FRACTION * max_range;
2436 let mut net: Vec<[f64; 3]> = Vec::new();
2437 let mut level_specs = Vec::with_capacity(levels);
2438 let mut col = dim + 1;
2439 let mut pen_logdet_const = 0.0;
2440 for l in 0..levels {
2441 let h = h0 * 0.5_f64.powi(l as i32);
2442 let new_centers = extend_net(&mut net, &z, dim, h, &z_range);
2443 if net.len() > MAX_CENTERS {
2444 return Err(format!(
2445 "residual cascade: center cap {MAX_CENTERS} exceeded at level {l}"
2446 ));
2447 }
2448 let weight = level_weight(l, sobolev_s, dim);
2449 pen_logdet_const += new_centers.len() as f64 * weight.ln();
2450 let delta = OVERLAP * h;
2451 let mut grid = HashGrid::new(delta, dim);
2452 for (j, c) in new_centers.iter().enumerate() {
2453 grid.insert(j as u32, c);
2454 }
2455 let col_offset = col;
2456 col += new_centers.len();
2457 level_specs.push(Level {
2458 h,
2459 delta,
2460 weight,
2461 centers: new_centers,
2462 col_offset,
2463 grid,
2464 });
2465 }
2466 let m = col;
2467
2468 // CSR assembly + sufficient statistics in one pass.
2469 let mut row_ptr = Vec::with_capacity(n + 1);
2470 row_ptr.push(0_usize);
2471 let mut col_idx: Vec<u32> = Vec::new();
2472 let mut vals: Vec<f64> = Vec::new();
2473 let mut rhs = vec![0.0_f64; m];
2474 let mut gram_diag = vec![0.0_f64; m];
2475 let mut ytwy = 0.0_f64;
2476 let probe_core = CoreScaffold {
2477 dim,
2478 z_range,
2479 levels: &level_specs,
2480 };
2481 for i in 0..n {
2482 let row = probe_core.basis_row(&z[i]);
2483 for &(c, v) in &row {
2484 col_idx.push(c as u32);
2485 vals.push(v);
2486 rhs[c] += w[i] * y[i] * v;
2487 gram_diag[c] += w[i] * v * v;
2488 }
2489 ytwy += w[i] * y[i] * y[i];
2490 row_ptr.push(col_idx.len());
2491 }
2492 let mut pen_diag = vec![0.0_f64; m];
2493 for level in &level_specs {
2494 for j in 0..level.centers.len() {
2495 pen_diag[level.col_offset + j] = level.weight;
2496 }
2497 }
2498
2499 // Dense Gram cache under the sizing cap: O(n·q²) scatter of row outer
2500 // products into the upper triangle.
2501 let dense_gram = if m <= DENSE_GRAM_MAX {
2502 let mut gram = vec![0.0_f64; m * m];
2503 for i in 0..n {
2504 let lo = row_ptr[i];
2505 let hi = row_ptr[i + 1];
2506 for ea in lo..hi {
2507 let ca = col_idx[ea] as usize;
2508 let va = w[i] * vals[ea];
2509 for eb in ea..hi {
2510 gram[ca * m + col_idx[eb] as usize] += va * vals[eb];
2511 }
2512 }
2513 }
2514 Some(gram)
2515 } else {
2516 None
2517 };
2518
2519 Ok(ResidualCascadeDesign {
2520 core: Arc::new(Core {
2521 dim,
2522 metric: metric3,
2523 z_lo,
2524 z_range,
2525 sobolev_s,
2526 levels: level_specs,
2527 net,
2528 m,
2529 row_ptr,
2530 col_idx,
2531 vals,
2532 w: w.to_vec(),
2533 y: y.to_vec(),
2534 z,
2535 rhs,
2536 ytwy,
2537 gram_diag,
2538 pen_diag,
2539 pen_logdet_const,
2540 dense_gram,
2541 predict_chol: None,
2542 }),
2543 })
2544 }
2545
2546 /// Number of resolution levels.
2547 pub fn num_levels(&self) -> usize {
2548 self.core.levels.len()
2549 }
2550
2551 /// Aspect ratio of the metric-scaled point cloud: the ratio of the largest
2552 /// to smallest per-axis standard deviation of the scaled coordinates `z`.
2553 /// This is the metric-condition measure the quasi-uniformity guard (issue
2554 /// #1032, caveat 2) keys on — see [`QUASI_UNIFORMITY_MAX_ASPECT`]. A value
2555 /// near 1 is an isotropic (benign) cloud; a large value means the metric
2556 /// has collapsed the data onto a lower-dimensional sheet in `z`, breaking
2557 /// the BPX n-independent iteration bound.
2558 pub fn metric_scaled_aspect_ratio(&self) -> f64 {
2559 let dim = self.core.dim;
2560 let n = self.core.z.len();
2561 if dim == 0 || n == 0 {
2562 return 1.0;
2563 }
2564 let mut mean = [0.0_f64; 3];
2565 for p in &self.core.z {
2566 for a in 0..dim {
2567 mean[a] += p[a];
2568 }
2569 }
2570 for m in mean.iter_mut().take(dim) {
2571 *m /= n as f64;
2572 }
2573 let mut var = [0.0_f64; 3];
2574 for p in &self.core.z {
2575 for a in 0..dim {
2576 let d = p[a] - mean[a];
2577 var[a] += d * d;
2578 }
2579 }
2580 let mut sd_lo = f64::INFINITY;
2581 let mut sd_hi = 0.0_f64;
2582 for v in var.iter().take(dim) {
2583 let sd = (v / n as f64).sqrt();
2584 sd_lo = sd_lo.min(sd);
2585 sd_hi = sd_hi.max(sd);
2586 }
2587 if !(sd_lo > 0.0 && sd_lo.is_finite()) {
2588 // A collapsed axis (zero scaled spread) is maximally degenerate.
2589 return f64::INFINITY;
2590 }
2591 sd_hi / sd_lo
2592 }
2593
2594 /// Quasi-uniformity certificate (issue #1032, caveat 2): `true` iff the
2595 /// metric-scaled cloud is isotropic enough that the BPX n-independent CG
2596 /// iteration bound is trustworthy. When this returns `false` the auto-route
2597 /// MUST fall back to the dense kernel path rather than pay an iterative
2598 /// solve whose iteration count is no longer n-independent — the CG residual
2599 /// certificate would still *catch* a mis-solve at [`CG_MAX_ITERS`], but the
2600 /// guard prevents the silent O(n·iters) blow-up up front.
2601 pub fn quasi_uniformity_certified(&self) -> bool {
2602 self.metric_scaled_aspect_ratio() <= QUASI_UNIFORMITY_MAX_ASPECT
2603 }
2604
2605 /// Number of columns `ncoarse` in the additive-Schwarz coarse space at `log
2606 /// λ` (the polynomial layer plus the data-dominated coarsest levels). The
2607 /// iterative-route preconditioner solves the principal `[0, ncoarse)` block
2608 /// of `A = X'WX + λD` exactly and Jacobi-preconditions the fine tail; exposed
2609 /// so the conditioning oracle can reconstruct that block-arrow preconditioner
2610 /// from the public dense system and certify it is uniformly conditioned in
2611 /// depth. See [`COARSE_DOMINANCE`].
2612 pub fn coarse_space_cols(&self, log_lambda: f64) -> Result<usize, String> {
2613 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
2614 .map_err(|error| format!("residual cascade: {error}"))?;
2615 Ok(self.core.coarse_space_cols(lambda))
2616 }
2617
2618 /// Total coefficient count (`dim + 1` polynomial + all centers).
2619 pub fn num_coeffs(&self) -> usize {
2620 self.core.m
2621 }
2622
2623 /// Structural nonzero count of the sparse design `X` (its CSR size). Each
2624 /// iterative-route PCG iteration applies the operator `A = XᵀWX + λD` as two
2625 /// CSR products against `X`, so its per-iteration cost is `Θ(nnz(X))`; the
2626 /// certified sparse-solve work is therefore `solve_iters · num_nonzeros()`,
2627 /// the figure the residual-cascade complexity certificate compares against
2628 /// the dense `m³/3` factorization cost. Zero on a predict-only core rebuilt
2629 /// from a persisted snapshot (the training CSR is intentionally dropped).
2630 pub fn num_nonzeros(&self) -> usize {
2631 self.core.col_idx.len()
2632 }
2633
2634 /// Total centers across all levels.
2635 pub fn num_centers(&self) -> usize {
2636 self.core.m - self.core.nullity()
2637 }
2638
2639 /// NEW centers of one level in ORIGINAL (unscaled) coordinates.
2640 pub fn centers(&self, level: usize) -> Vec<Vec<f64>> {
2641 let lv = &self.core.levels[level];
2642 lv.centers
2643 .iter()
2644 .map(|c| {
2645 (0..self.core.dim)
2646 .map(|a| (c[a] + self.core.z_lo[a]) / self.core.metric[a])
2647 .collect()
2648 })
2649 .collect()
2650 }
2651
2652 /// Sparse basis row at a raw point, as (column, value) pairs sorted by
2653 /// column within each block — the exact row the fit used for training
2654 /// rows, exposed so oracles can assemble the dense system independently.
2655 pub fn basis_row(&self, x: &[f64]) -> Result<Vec<(usize, f64)>, String> {
2656 self.check_point(x)?;
2657 Ok(self.core.basis_row_scaled(&self.core.scale_point(x)))
2658 }
2659
2660 fn check_point(&self, x: &[f64]) -> Result<(), String> {
2661 if x.len() != self.core.dim || x.iter().any(|v| !v.is_finite()) {
2662 return Err(format!(
2663 "residual cascade: point must be {} finite coordinates, got {x:?}",
2664 self.core.dim
2665 ));
2666 }
2667 Ok(())
2668 }
2669
2670 /// Exact penalty quadratic `c'Dc` (unit-λ multilevel prior energy).
2671 pub fn penalty_value(&self, coeff: &[f64]) -> Result<f64, String> {
2672 if coeff.len() != self.core.m {
2673 return Err(format!(
2674 "residual cascade: coefficient length {} != {}",
2675 coeff.len(),
2676 self.core.m
2677 ));
2678 }
2679 Ok(coeff
2680 .iter()
2681 .zip(self.core.pen_diag.iter())
2682 .map(|(&c, &d)| d * c * c)
2683 .sum())
2684 }
2685
2686 /// Exact dense log-determinant of `X'WX + λD` (errors past the sizing
2687 /// cap) — exposed for the in-test SLQ-vs-exact oracle.
2688 pub fn logdet_exact(&self, log_lambda: f64) -> Result<f64, String> {
2689 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
2690 .map_err(|error| format!("residual cascade: {error}"))?;
2691 self.core.logdet_dense(lambda)
2692 }
2693
2694 /// SLQ log-determinant estimate on the fixed deterministic probes —
2695 /// exposed for the in-test SLQ-vs-exact oracle.
2696 pub fn logdet_slq(&self, log_lambda: f64) -> Result<f64, String> {
2697 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
2698 .map_err(|error| format!("residual cascade: {error}"))?;
2699 self.core.logdet_slq(lambda)
2700 }
2701
2702 /// Profiled-σ² REML criterion at `log λ` (differences across λ are exact
2703 /// REML differences on the dense route; one fixed spectral quadrature is
2704 /// used past the cap).
2705 pub fn criterion(&self, log_lambda: f64) -> Result<f64, String> {
2706 Ok(self.core.reml_profile()?.evaluate(log_lambda)?.jet.value)
2707 }
2708
2709 /// Fit at a FIXED `log λ`, with σ² either supplied or profiled.
2710 pub fn fit_at(
2711 &self,
2712 log_lambda: f64,
2713 sigma2: Option<f64>,
2714 ) -> Result<ResidualCascadeFit, String> {
2715 self.fit_at_with_warm(log_lambda, sigma2, None, None)
2716 }
2717
2718 fn fit_at_with_warm(
2719 &self,
2720 log_lambda: f64,
2721 sigma2: Option<f64>,
2722 warm: Option<&[f64]>,
2723 profile_normalized_logdet: Option<f64>,
2724 ) -> Result<ResidualCascadeFit, String> {
2725 let core = &self.core;
2726 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
2727 .map_err(|error| format!("residual cascade: {error}"))?;
2728 let (coeff, rel_res, iters) = core.solve_coeff(lambda, &core.rhs, warm)?;
2729 let rss_pen = core.rss_pen(&coeff);
2730 let dof = (core.y.len() - core.nullity()) as f64;
2731 let sigma2 = match sigma2 {
2732 Some(s) => {
2733 if !(s.is_finite() && s > 0.0) {
2734 return Err(format!("residual cascade: invalid sigma2 {s}"));
2735 }
2736 s
2737 }
2738 None => {
2739 if !(rss_pen > 0.0) {
2740 return Err(format!(
2741 "residual cascade: degenerate penalized residual {rss_pen}"
2742 ));
2743 }
2744 rss_pen / dof
2745 }
2746 };
2747 let r = (core.m - core.nullity()) as f64;
2748 let (logdet, logdet_method) = match profile_normalized_logdet {
2749 Some(normalized) => (
2750 normalized + r * log_lambda + core.pen_logdet_const,
2751 if core.dense_gram.is_some() {
2752 LogdetMethod::DenseExact
2753 } else {
2754 LogdetMethod::Slq
2755 },
2756 ),
2757 None => core.logdet(lambda)?,
2758 };
2759 // Full restricted log-likelihood at this (λ, σ²) up to λ- and σ-free
2760 // constants; at the profiled σ̂² the quadratic collapses to `dof`.
2761 let restricted_loglik = -0.5
2762 * (logdet - r * log_lambda - core.pen_logdet_const
2763 + dof * sigma2.ln()
2764 + rss_pen / sigma2);
2765 let predict_chol = if core.dense_gram.is_some() {
2766 Some(core.assemble_predict_factor(lambda)?)
2767 } else {
2768 None
2769 };
2770 Ok(ResidualCascadeFit {
2771 core: Arc::clone(&self.core),
2772 predict_chol,
2773 coeff,
2774 log_lambda,
2775 sigma2,
2776 restricted_loglik,
2777 rss_pen,
2778 certificate: CascadeCertificate {
2779 solve_rel_residual: rel_res,
2780 solve_iters: iters,
2781 logdet_method,
2782 },
2783 refinement: None,
2784 })
2785 }
2786
2787 /// Fit with `log λ` selected by the profiled REML criterion. Every
2788 /// stationary interval in the bounded domain is isolated from analytic
2789 /// derivative enclosures, refined by safeguarded Newton/bisection, and
2790 /// compared with both exact boundary candidates. The large route uses one
2791 /// lambda-independent fixed-probe spectral profile, so it is the same
2792 /// smooth deterministic score at every trial.
2793 pub fn fit_reml(&self) -> Result<ResidualCascadeFit, String> {
2794 let profile = self.core.reml_profile()?;
2795 let (log_lambda_lo, log_lambda_hi) = profile.log_lambda_domain()?;
2796 let resolution = f64::EPSILON.sqrt();
2797 let failed = |error: &dyn std::fmt::Display| {
2798 format!("residual cascade: REML stationary isolation failed: {error}")
2799 };
2800 // Both arms run the SAME certified isolation on the SAME domain; they
2801 // differ only in the enclosure oracle the design can honestly supply.
2802 let selected_log_lambda = match profile.affine_view()? {
2803 Some(affine) => {
2804 affine
2805 .maximize(log_lambda_lo, log_lambda_hi, resolution)
2806 .map_err(|error| failed(&error))?
2807 .optimum
2808 .x
2809 }
2810 None => {
2811 maximize_score_1d(
2812 log_lambda_lo,
2813 log_lambda_hi,
2814 resolution,
2815 |log_lambda| {
2816 profile
2817 .evaluate(log_lambda)
2818 .map(|evaluation| evaluation.jet)
2819 },
2820 |left, right| profile.enclose(left, right),
2821 )
2822 .map_err(|error| failed(&error))?
2823 .optimum
2824 .x
2825 }
2826 };
2827 let selected = profile.evaluate(selected_log_lambda)?;
2828 self.fit_at_with_warm(
2829 selected_log_lambda,
2830 None,
2831 None,
2832 Some(selected.normalized_logdet),
2833 )
2834 }
2835
2836 /// Assess the candidate level L+1 at this fit's λ. A complete candidate
2837 /// reports the exact upper bound `‖X₂'W r̂‖² / (λ·d_{L+1})` on its
2838 /// penalized-objective decrease (see the module header for the Schur-
2839 /// complement argument). Empty-net exhaustion and representation capacity
2840 /// are different typed outcomes because only an empty net certifies zero
2841 /// remaining gain.
2842 pub fn assess_next_level(
2843 &self,
2844 fit: &ResidualCascadeFit,
2845 ) -> Result<NextLevelAssessment, String> {
2846 let core = &self.core;
2847 if !Arc::ptr_eq(core, &fit.core) {
2848 return Err("residual cascade: fit does not belong to this design".into());
2849 }
2850 let next_l = core.levels.len();
2851 let h = core.levels[next_l - 1].h * 0.5;
2852 let mut net = core.net.clone();
2853 let candidates = extend_net(&mut net, &core.z, core.dim, h, &core.z_range);
2854 if candidates.is_empty() {
2855 return Ok(NextLevelAssessment::EmptyNet);
2856 }
2857 if net.len() > MAX_CENTERS {
2858 return Ok(NextLevelAssessment::CapacityExceeded {
2859 obstruction: RefinementObstruction::CenterCapacity {
2860 centers: net.len(),
2861 maximum_centers: MAX_CENTERS,
2862 },
2863 // The cap stopped candidate construction before every column
2864 // could contribute to ‖X₂'Wr̂‖². Infinity is the honest
2865 // conservative upper bound; a finite partial sum would not
2866 // certify the omitted columns.
2867 gain_bound: f64::INFINITY,
2868 });
2869 }
2870 let delta = OVERLAP * h;
2871 let mut grid = HashGrid::new(delta, core.dim);
2872 for (j, c) in candidates.iter().enumerate() {
2873 grid.insert(j as u32, c);
2874 }
2875 let r = core.residuals(&fit.coeff);
2876 let mut g = vec![0.0_f64; candidates.len()];
2877 for (i, zi) in core.z.iter().enumerate() {
2878 let wr = core.w[i] * r[i];
2879 grid.for_neighbors(zi, |j| {
2880 let rad = dist2(zi, &candidates[j as usize], core.dim).sqrt() / delta;
2881 g[j as usize] += wr * wendland(rad);
2882 });
2883 }
2884 let g2: f64 = g.iter().map(|v| v * v).sum();
2885 let d_next = level_weight(next_l, core.sobolev_s, core.dim);
2886 let lambda = gam_problem::checked_exp_log_strength(fit.log_lambda)
2887 .map_err(|error| format!("residual cascade refinement: {error}"))?;
2888 let gain_bound = g2 / (lambda * d_next);
2889 if next_l >= MAX_LEVELS {
2890 Ok(NextLevelAssessment::CapacityExceeded {
2891 obstruction: RefinementObstruction::LevelCapacity {
2892 levels: next_l,
2893 maximum_levels: MAX_LEVELS,
2894 },
2895 gain_bound,
2896 })
2897 } else {
2898 Ok(NextLevelAssessment::GainBound(gain_bound))
2899 }
2900 }
2901}
2902
2903/// Prior precision weight of level `l`: `4^{l(s−d/2)}`.
2904fn level_weight(l: usize, sobolev_s: f64, dim: usize) -> f64 {
2905 (4.0_f64).powf(l as f64 * (sobolev_s - dim as f64 / 2.0))
2906}
2907
2908/// Lightweight view used during assembly, before the Core exists: shares the
2909/// exact basis-row logic with [`Core::basis_row_scaled`] so the assembled CSR
2910/// and later prediction rows cannot drift apart.
2911struct CoreScaffold<'a> {
2912 dim: usize,
2913 z_range: [f64; 3],
2914 levels: &'a [Level],
2915}
2916
2917impl CoreScaffold<'_> {
2918 fn basis_row(&self, z: &[f64; 3]) -> Vec<(usize, f64)> {
2919 let mut row = Vec::with_capacity(self.dim + 1 + self.levels.len() * 8);
2920 row.push((0, 1.0));
2921 for a in 0..self.dim {
2922 row.push((a + 1, 2.0 * z[a] / self.z_range[a] - 1.0));
2923 }
2924 for level in self.levels {
2925 let start = row.len();
2926 level.grid.for_neighbors(z, |j| {
2927 let c = &level.centers[j as usize];
2928 let r = dist2(z, c, self.dim).sqrt() / level.delta;
2929 let v = wendland(r);
2930 if v > 0.0 {
2931 row.push((level.col_offset + j as usize, v));
2932 }
2933 });
2934 row[start..].sort_unstable_by_key(|&(col, _)| col);
2935 }
2936 row
2937 }
2938}
2939
2940impl ResidualCascadeFit {
2941 pub fn log_lambda(&self) -> f64 {
2942 self.log_lambda
2943 }
2944
2945 pub fn lambda(&self) -> f64 {
2946 gam_problem::checked_exp_log_strength(self.log_lambda)
2947 .expect("ResidualCascadeFit construction validates its private log strength")
2948 }
2949
2950 /// Posterior `(mean, variance)` at a raw point: the sparse basis row
2951 /// dotted with the coefficients, and `σ̂²·x'(X'WX+λD)^{−1}x` through one
2952 /// certified solve.
2953 pub fn predict(&self, x: &[f64]) -> Result<(f64, f64), String> {
2954 let core = &self.core;
2955 if x.len() != core.dim || x.iter().any(|v| !v.is_finite()) {
2956 return Err(format!(
2957 "residual cascade: prediction point must be {} finite coordinates, got {x:?}",
2958 core.dim
2959 ));
2960 }
2961 let row = core.basis_row_scaled(&core.scale_point(x));
2962 let mut mean = 0.0;
2963 let mut dense_row = vec![0.0_f64; core.m];
2964 for &(c, v) in &row {
2965 mean += v * self.coeff[c];
2966 dense_row[c] += v;
2967 }
2968 let lambda = gam_problem::checked_exp_log_strength(self.log_lambda)
2969 .map_err(|error| format!("residual cascade fit: {error}"))?;
2970 let zsol = if let Some(l) = &self.predict_chol {
2971 chol_solve(l, core.m, &dense_row)
2972 } else {
2973 core.solve_coeff(lambda, &dense_row, None)?.0
2974 };
2975 let mut quad = 0.0;
2976 for (a, b) in dense_row.iter().zip(zsol.iter()) {
2977 quad += a * b;
2978 }
2979 Ok((mean, self.sigma2 * quad))
2980 }
2981
2982 /// EXACT posterior coefficient samples by perturb-and-solve:
2983 /// `c_s = A^{−1}(X'Wy + σ(X'W^{1/2}z₁ + √λ D^{1/2}z₂))` has mean ĉ and
2984 /// covariance exactly `σ̂²A^{−1}`. Deterministically seeded; one certified
2985 /// solve per sample (warm-started at the mode).
2986 pub fn sample_coefficients(&self, n_samples: usize) -> Result<Vec<Vec<f64>>, String> {
2987 let core = &self.core;
2988 let lambda = gam_problem::checked_exp_log_strength(self.log_lambda)
2989 .map_err(|error| format!("residual cascade fit: {error}"))?;
2990 let sigma = self.sigma2.sqrt();
2991 let sqrt_lambda = lambda.sqrt();
2992 let n = core.y.len();
2993 let mut rng = SplitMix64::new(RNG_SEED ^ 0xA11C_E5A_u64);
2994 let mut samples = Vec::with_capacity(n_samples);
2995 for _ in 0..n_samples {
2996 let mut b = core.rhs.clone();
2997 // X'W^{1/2} z₁: one CSR pass with per-row factor √w_i·z₁_i.
2998 for i in 0..n {
2999 let f = sigma * core.w[i].sqrt() * rng.next_normal();
3000 for e in core.row_ptr[i]..core.row_ptr[i + 1] {
3001 b[core.col_idx[e] as usize] += f * core.vals[e];
3002 }
3003 }
3004 // √λ D^{1/2} z₂ on the penalized columns.
3005 for (bj, &dj) in b.iter_mut().zip(core.pen_diag.iter()) {
3006 if dj > 0.0 {
3007 *bj += sigma * sqrt_lambda * dj.sqrt() * rng.next_normal();
3008 }
3009 }
3010 let (c, _, _) = core.solve_coeff(lambda, &b, Some(&self.coeff))?;
3011 samples.push(c);
3012 }
3013 Ok(samples)
3014 }
3015
3016 /// Number of resolution levels in the fitted cascade.
3017 pub fn num_levels(&self) -> usize {
3018 self.core.levels.len()
3019 }
3020
3021 /// Total coefficient count.
3022 pub fn num_coeffs(&self) -> usize {
3023 self.core.m
3024 }
3025
3026 /// Total centers across all fitted resolution levels.
3027 pub fn num_centers(&self) -> usize {
3028 self.core.m - self.core.nullity()
3029 }
3030
3031 /// Snapshot the fit for persistence (#1032). Assembles the factored
3032 /// precision `L` of `A = X'WX + λD` at the fit's λ (O(m³) once) and copies
3033 /// the nested geometry + coefficients, dropping all training rows. The
3034 /// resulting [`ResidualCascadeState`] is predict-complete: `from_state`
3035 /// replays the posterior mean+variance bit-for-bit.
3036 pub fn to_state(&self) -> Result<ResidualCascadeState, String> {
3037 let core = &self.core;
3038 let lambda = gam_problem::checked_exp_log_strength(self.log_lambda)
3039 .map_err(|error| format!("residual cascade fit: {error}"))?;
3040 let predict_chol = if let Some(l) = &self.predict_chol {
3041 l.clone()
3042 } else if let Some(l) = &core.predict_chol {
3043 l.clone()
3044 } else {
3045 core.assemble_predict_factor(lambda)?
3046 };
3047 let dim = core.dim;
3048 let levels = core
3049 .levels
3050 .iter()
3051 .map(|level| {
3052 let mut centers = Vec::with_capacity(level.centers.len() * dim);
3053 for c in &level.centers {
3054 centers.extend_from_slice(&c[..dim]);
3055 }
3056 LevelState {
3057 h: level.h,
3058 delta: level.delta,
3059 weight: level.weight,
3060 col_offset: level.col_offset as u64,
3061 centers,
3062 }
3063 })
3064 .collect();
3065 Ok(ResidualCascadeState {
3066 dim: dim as u64,
3067 metric: core.metric,
3068 z_lo: core.z_lo,
3069 z_range: core.z_range,
3070 sobolev_s: core.sobolev_s,
3071 levels,
3072 m: core.m as u64,
3073 pen_logdet_const: core.pen_logdet_const,
3074 coeff: self.coeff.clone(),
3075 log_lambda: self.log_lambda,
3076 sigma2: self.sigma2,
3077 restricted_loglik: self.restricted_loglik,
3078 rss_pen: self.rss_pen,
3079 predict_chol,
3080 })
3081 }
3082
3083 /// Rebuild a predict-capable fit from a snapshot (#1032). Validates shape,
3084 /// finiteness, the Sobolev/Wendland window, strictly-positive level weights
3085 /// and box ranges, the column accounting (`m = dim+1 + Σ centers`, matching
3086 /// `col_offset`s), positive σ², and that `predict_chol` is a valid `m × m`
3087 /// lower factor (positive pivots) — so a corrupt payload fails here, not in
3088 /// a later `predict`. The restored `Core` has empty training CSR and
3089 /// `predict_chol = Some(L)`; its `predict` reads only geometry (mean) and
3090 /// the factor (variance), replaying both exactly.
3091 pub fn from_state(state: &ResidualCascadeState) -> Result<Self, String> {
3092 let dim = state.dim as usize;
3093 if !(dim == 2 || dim == 3) {
3094 return Err(format!(
3095 "residual cascade state: dim must be 2 or 3, got {dim}"
3096 ));
3097 }
3098 if !(state.sobolev_s > dim as f64 / 2.0 && state.sobolev_s <= (dim as f64 + 3.0) / 2.0) {
3099 return Err(format!(
3100 "residual cascade state: sobolev_s {} outside the Wendland window ({}, {}]",
3101 state.sobolev_s,
3102 dim as f64 / 2.0,
3103 (dim as f64 + 3.0) / 2.0
3104 ));
3105 }
3106 for a in 0..dim {
3107 if !(state.metric[a].is_finite() && state.metric[a] > 0.0) {
3108 return Err(format!(
3109 "residual cascade state: metric axis {a} must be finite positive, got {}",
3110 state.metric[a]
3111 ));
3112 }
3113 if !(state.z_range[a].is_finite()
3114 && state.z_range[a] > 0.0
3115 && state.z_lo[a].is_finite())
3116 {
3117 return Err(format!(
3118 "residual cascade state: degenerate box on axis {a} (lo={}, range={})",
3119 state.z_lo[a], state.z_range[a]
3120 ));
3121 }
3122 }
3123 let m = state.m as usize;
3124 let mut metric3 = [1.0_f64; 3];
3125 metric3[..dim].copy_from_slice(&state.metric[..dim]);
3126 let mut z_lo = [0.0_f64; 3];
3127 let mut z_range = [1.0_f64; 3];
3128 z_lo[..dim].copy_from_slice(&state.z_lo[..dim]);
3129 z_range[..dim].copy_from_slice(&state.z_range[..dim]);
3130
3131 // Rebuild the levels and their lookup grids from the flattened centers,
3132 // checking the column accounting matches the polynomial layer + blocks.
3133 let mut levels = Vec::with_capacity(state.levels.len());
3134 let mut net: Vec<[f64; 3]> = Vec::new();
3135 let mut pen_diag = vec![0.0_f64; m];
3136 let mut expected_offset = dim + 1;
3137 for (li, ls) in state.levels.iter().enumerate() {
3138 if !(ls.h.is_finite() && ls.h > 0.0 && ls.delta.is_finite() && ls.delta > 0.0) {
3139 return Err(format!(
3140 "residual cascade state: level {li} has non-positive h/delta ({}, {})",
3141 ls.h, ls.delta
3142 ));
3143 }
3144 if !(ls.weight.is_finite() && ls.weight > 0.0) {
3145 return Err(format!(
3146 "residual cascade state: level {li} has non-positive prior weight {}",
3147 ls.weight
3148 ));
3149 }
3150 if ls.centers.len() % dim != 0 {
3151 return Err(format!(
3152 "residual cascade state: level {li} centers length {} not a multiple of dim {dim}",
3153 ls.centers.len()
3154 ));
3155 }
3156 let n_centers = ls.centers.len() / dim;
3157 let col_offset = ls.col_offset as usize;
3158 if col_offset != expected_offset {
3159 return Err(format!(
3160 "residual cascade state: level {li} col_offset {col_offset} ≠ expected {expected_offset}"
3161 ));
3162 }
3163 let mut grid = HashGrid::new(ls.delta, dim);
3164 let mut centers = Vec::with_capacity(n_centers);
3165 for j in 0..n_centers {
3166 let mut c = [0.0_f64; 3];
3167 for a in 0..dim {
3168 let v = ls.centers[j * dim + a];
3169 if !v.is_finite() {
3170 return Err(format!(
3171 "residual cascade state: non-finite center coordinate at level {li}, center {j}"
3172 ));
3173 }
3174 c[a] = v;
3175 }
3176 grid.insert(j as u32, &c);
3177 centers.push(c);
3178 net.push(c);
3179 let col = col_offset + j;
3180 if col >= m {
3181 return Err(format!(
3182 "residual cascade state: level {li} column {col} exceeds m {m}"
3183 ));
3184 }
3185 pen_diag[col] = ls.weight;
3186 }
3187 expected_offset = col_offset + n_centers;
3188 levels.push(Level {
3189 h: ls.h,
3190 delta: ls.delta,
3191 weight: ls.weight,
3192 centers,
3193 col_offset,
3194 grid,
3195 });
3196 }
3197 if expected_offset != m {
3198 return Err(format!(
3199 "residual cascade state: column accounting mismatch (dim+1+Σcenters = {expected_offset} ≠ m {m})"
3200 ));
3201 }
3202 if state.coeff.len() != m {
3203 return Err(format!(
3204 "residual cascade state: coeff length {} ≠ m {m}",
3205 state.coeff.len()
3206 ));
3207 }
3208 if state.predict_chol.len() != m * m {
3209 return Err(format!(
3210 "residual cascade state: predict_chol must be m×m = {m}² = {}, got {}",
3211 m * m,
3212 state.predict_chol.len()
3213 ));
3214 }
3215 for (i, v) in state
3216 .coeff
3217 .iter()
3218 .chain(state.predict_chol.iter())
3219 .enumerate()
3220 {
3221 if !v.is_finite() {
3222 return Err(format!("residual cascade state: non-finite entry at {i}"));
3223 }
3224 }
3225 for g in 0..m {
3226 let piv = state.predict_chol[g * m + g];
3227 if !(piv.is_finite() && piv > 0.0) {
3228 return Err(format!(
3229 "residual cascade state: non-positive Cholesky pivot {piv} at index {g}"
3230 ));
3231 }
3232 }
3233 gam_problem::validate_log_strength(state.log_lambda)
3234 .map_err(|error| format!("residual cascade state: {error}"))?;
3235 if !(state.sigma2.is_finite()
3236 && state.sigma2 > 0.0
3237 && state.restricted_loglik.is_finite()
3238 && state.rss_pen.is_finite())
3239 {
3240 return Err(format!(
3241 "residual cascade state: invalid scalars (log_lambda={}, sigma2={}, restricted_loglik={}, rss_pen={})",
3242 state.log_lambda, state.sigma2, state.restricted_loglik, state.rss_pen
3243 ));
3244 }
3245 let core = Core {
3246 dim,
3247 metric: metric3,
3248 z_lo,
3249 z_range,
3250 sobolev_s: state.sobolev_s,
3251 levels,
3252 net,
3253 m,
3254 row_ptr: Vec::new(),
3255 col_idx: Vec::new(),
3256 vals: Vec::new(),
3257 w: Vec::new(),
3258 y: Vec::new(),
3259 z: Vec::new(),
3260 rhs: Vec::new(),
3261 ytwy: 0.0,
3262 gram_diag: Vec::new(),
3263 pen_diag,
3264 pen_logdet_const: state.pen_logdet_const,
3265 dense_gram: None,
3266 predict_chol: Some(state.predict_chol.clone()),
3267 };
3268 Ok(ResidualCascadeFit {
3269 core: Arc::new(core),
3270 predict_chol: None,
3271 coeff: state.coeff.clone(),
3272 log_lambda: state.log_lambda,
3273 sigma2: state.sigma2,
3274 restricted_loglik: state.restricted_loglik,
3275 rss_pen: state.rss_pen,
3276 certificate: CascadeCertificate {
3277 solve_rel_residual: 0.0,
3278 solve_iters: 0,
3279 logdet_method: LogdetMethod::DenseExact,
3280 },
3281 refinement: None,
3282 })
3283 }
3284}
3285
3286#[derive(Clone, Copy, Debug, PartialEq)]
3287enum RefinementDecision {
3288 Converged {
3289 gain_bound: f64,
3290 },
3291 Refine,
3292 Underresolved {
3293 gain_bound: f64,
3294 obstruction: RefinementObstruction,
3295 },
3296}
3297
3298/// Turn the typed next-level assessment into the only three legal refinement
3299/// transitions. In particular, a capacity limit can yield a fit only when its
3300/// already-computed gain bound independently passes the requested tolerance.
3301fn decide_refinement(
3302 assessment: NextLevelAssessment,
3303 requested_tolerance: f64,
3304) -> RefinementDecision {
3305 match assessment {
3306 NextLevelAssessment::EmptyNet => RefinementDecision::Converged { gain_bound: 0.0 },
3307 NextLevelAssessment::GainBound(gain_bound) if gain_bound <= requested_tolerance => {
3308 RefinementDecision::Converged { gain_bound }
3309 }
3310 NextLevelAssessment::GainBound(_) => RefinementDecision::Refine,
3311 NextLevelAssessment::CapacityExceeded {
3312 gain_bound,
3313 obstruction: _,
3314 } if gain_bound <= requested_tolerance => RefinementDecision::Converged { gain_bound },
3315 NextLevelAssessment::CapacityExceeded {
3316 gain_bound,
3317 obstruction,
3318 } => RefinementDecision::Underresolved {
3319 gain_bound,
3320 obstruction,
3321 },
3322 }
3323}
3324
3325/// Fit the full magic-default cascade: start at [`INITIAL_LEVELS`], REML-fit,
3326/// and refine (add a level, refit, re-select λ) until the exact next-level
3327/// gain bound certifies that one more level cannot move the penalized
3328/// objective by more than [`REFINE_TOL`] of the penalized residual. A genuinely
3329/// empty next-level net certifies zero remaining gain; a level/center capacity
3330/// reached before the tolerance passes is a typed
3331/// [`ResidualCascadeError::Underresolved`] carrying the retained work and its
3332/// evidence, never a fit.
3333pub fn fit_residual_cascade(
3334 xs: &[&[f64]],
3335 y: &[f64],
3336 w: &[f64],
3337 metric: &[f64],
3338 sobolev_s: f64,
3339) -> Result<ResidualCascadeFit, ResidualCascadeError> {
3340 let mut levels = INITIAL_LEVELS;
3341 loop {
3342 let design = ResidualCascadeDesign::build(xs, y, w, metric, sobolev_s, levels)?;
3343 // Quasi-uniformity guard (issue #1032, caveat 2): if the metric has
3344 // collapsed the cloud onto a near-degenerate sheet in scaled
3345 // coordinates, the BPX iteration bound no longer holds. Refuse the
3346 // iterative solve up front with a typed signal so the auto-route falls
3347 // back to the dense kernel BEFORE paying an unbounded CG, rather than
3348 // grinding to CG_MAX_ITERS. (The guard is checked at the root level
3349 // only — refinement adds finer nets to the SAME scaled cloud, so the
3350 // aspect ratio is invariant under added levels.)
3351 if levels == INITIAL_LEVELS && !design.quasi_uniformity_certified() {
3352 return Err(format!(
3353 "residual cascade: metric-scaled aspect ratio {:.3e} exceeds the \
3354 quasi-uniformity ceiling {QUASI_UNIFORMITY_MAX_ASPECT:.0e}; the BPX \
3355 iteration bound is not trustworthy on this (near-degenerate) metric — \
3356 fall back to the dense kernel path",
3357 design.metric_scaled_aspect_ratio()
3358 )
3359 .into());
3360 }
3361 let mut fit = design.fit_reml()?;
3362 // The realized CG iteration count at this cascade depth is the runtime
3363 // tell of the BPX n-independence bound (issue #1032 caveat: a count
3364 // creeping toward CG_MAX_ITERS means the quasi-uniformity guard's static
3365 // aspect-ratio check was too lenient for this cloud). It is exposed
3366 // STRUCTURALLY rather than over stderr: the per-depth count and backward
3367 // error ride on `fit.certificate` (`solve_iters` — 0 on the dense route,
3368 // the PCG count on the iterative route — and `solve_rel_residual`), so a
3369 // caller that wants to watch the bound reads them off the returned fit
3370 // instead of scraping log lines. (A library solve never writes to
3371 // stderr.)
3372 let assessment = design.assess_next_level(&fit)?;
3373 let requested_tolerance = REFINE_TOL * fit.rss_pen;
3374 match decide_refinement(assessment, requested_tolerance) {
3375 RefinementDecision::Converged { gain_bound } => {
3376 fit.refinement = Some(RefinementCertificate {
3377 next_level_gain_bound: gain_bound,
3378 tolerance: requested_tolerance,
3379 });
3380 return Ok(fit);
3381 }
3382 RefinementDecision::Refine => {
3383 levels += 1;
3384 }
3385 RefinementDecision::Underresolved {
3386 gain_bound,
3387 obstruction,
3388 } => {
3389 return Err(ResidualCascadeError::Underresolved {
3390 checkpoint: ResidualCascadeCheckpoint::new(fit),
3391 gain_bound,
3392 requested_tolerance,
3393 obstruction,
3394 });
3395 }
3396 }
3397 }
3398}
3399
3400#[cfg(test)]
3401mod refinement_decision_tests {
3402 use super::*;
3403
3404 const TOLERANCE: f64 = 0.25;
3405
3406 #[test]
3407 fn only_empty_or_passing_bound_converges() {
3408 assert_eq!(
3409 decide_refinement(NextLevelAssessment::EmptyNet, TOLERANCE),
3410 RefinementDecision::Converged { gain_bound: 0.0 }
3411 );
3412 assert_eq!(
3413 decide_refinement(NextLevelAssessment::GainBound(0.2), TOLERANCE),
3414 RefinementDecision::Converged { gain_bound: 0.2 }
3415 );
3416 assert_eq!(
3417 decide_refinement(NextLevelAssessment::GainBound(0.3), TOLERANCE),
3418 RefinementDecision::Refine
3419 );
3420 }
3421
3422 #[test]
3423 fn capacity_above_tolerance_is_underresolved() {
3424 let obstruction = RefinementObstruction::LevelCapacity {
3425 levels: MAX_LEVELS,
3426 maximum_levels: MAX_LEVELS,
3427 };
3428 assert_eq!(
3429 decide_refinement(
3430 NextLevelAssessment::CapacityExceeded {
3431 obstruction,
3432 gain_bound: 0.3,
3433 },
3434 TOLERANCE,
3435 ),
3436 RefinementDecision::Underresolved {
3437 gain_bound: 0.3,
3438 obstruction,
3439 }
3440 );
3441
3442 let center_obstruction = RefinementObstruction::CenterCapacity {
3443 centers: MAX_CENTERS + 1,
3444 maximum_centers: MAX_CENTERS,
3445 };
3446 assert_eq!(
3447 decide_refinement(
3448 NextLevelAssessment::CapacityExceeded {
3449 obstruction: center_obstruction,
3450 gain_bound: f64::INFINITY,
3451 },
3452 TOLERANCE,
3453 ),
3454 RefinementDecision::Underresolved {
3455 gain_bound: f64::INFINITY,
3456 obstruction: center_obstruction,
3457 }
3458 );
3459 }
3460
3461 #[test]
3462 fn capacity_does_not_block_an_independently_passing_bound() {
3463 assert_eq!(
3464 decide_refinement(
3465 NextLevelAssessment::CapacityExceeded {
3466 obstruction: RefinementObstruction::LevelCapacity {
3467 levels: MAX_LEVELS,
3468 maximum_levels: MAX_LEVELS,
3469 },
3470 gain_bound: 0.2,
3471 },
3472 TOLERANCE,
3473 ),
3474 RefinementDecision::Converged { gain_bound: 0.2 }
3475 );
3476 }
3477
3478 /// A 2-D fixture small enough to stay under the dense sizing cap, with a
3479 /// response that is smooth plus a deterministic wobble so the profiled
3480 /// residual is not degenerate at any λ.
3481 fn dense_fixture(side: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
3482 let mut x1 = Vec::with_capacity(side * side);
3483 let mut x2 = Vec::with_capacity(side * side);
3484 let mut y = Vec::with_capacity(side * side);
3485 for i in 0..side {
3486 for j in 0..side {
3487 let a = i as f64 / (side - 1) as f64;
3488 let b = j as f64 / (side - 1) as f64;
3489 x1.push(a);
3490 x2.push(b);
3491 y.push((2.3 * a).sin() + (1.7 * b).cos() + 0.07 * ((3 * i + 5 * j) % 7) as f64);
3492 }
3493 }
3494 (x1, x2, y)
3495 }
3496
3497 /// Tight clusters with empty space between them, deterministic.
3498 ///
3499 /// `extend_net` fills the whole bounding BOX, not just the data, so a cloud
3500 /// with genuine voids puts cascade columns where no row supports them. Those
3501 /// columns are annihilated by the design exactly, which is what produces the
3502 /// numerically null Schur modes. A regular grid never does — every bump has
3503 /// data under it — which is why the other fixture cannot cover that edge.
3504 fn clustered_fixture() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
3505 let centers = [(0.12_f64, 0.13_f64), (0.86, 0.20), (0.45, 0.88)];
3506 let mut x1 = Vec::new();
3507 let mut x2 = Vec::new();
3508 let mut y = Vec::new();
3509 let mut state = 0x2455_u64;
3510 let mut next = || {
3511 state = state
3512 .wrapping_mul(6364136223846793005)
3513 .wrapping_add(1442695040888963407);
3514 ((state >> 11) as f64) / ((1u64 << 53) as f64)
3515 };
3516 for (cx, cy) in centers {
3517 for _ in 0..60 {
3518 let a = cx + 0.06 * (next() - 0.5);
3519 let b = cy + 0.06 * (next() - 0.5);
3520 x1.push(a);
3521 x2.push(b);
3522 y.push((3.0 * a).sin() + (2.0 * b).cos() + 0.05 * (next() - 0.5));
3523 }
3524 }
3525 (x1, x2, y)
3526 }
3527
3528 /// The two [`CascadeResidualForm`] arms are the same function of λ.
3529 ///
3530 /// The spectral arm reads the profiled residual and its three quadratic
3531 /// forms off the Schur decomposition; the solved arm re-derives them from a
3532 /// factorization of `A = X'WX + λD`. If they ever disagree the criterion is
3533 /// route-dependent, which is the defect the spectral arm exists to remove —
3534 /// so the agreement is asserted directly rather than inferred from the
3535 /// scores that consume it.
3536 ///
3537 /// The bound is the textbook forward-error of the comparator, not a tuned
3538 /// number: the solved arm's Cholesky solve carries `O(m)·eps·cond(A)`, and
3539 /// `cond(A) = (θ_max + λ)/(θ_min + λ)` is available exactly from the same
3540 /// spectrum. Nothing here is free to be widened without changing that claim.
3541 #[test]
3542 fn spectral_and_solved_residual_forms_agree() {
3543 let (x1, x2, y) = dense_fixture(6);
3544 let weights = vec![1.0; y.len()];
3545 let axes: [&[f64]; 2] = [&x1, &x2];
3546 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
3547 .expect("cascade design");
3548 let core = &design.core;
3549 assert!(core.dense_gram.is_some(), "fixture must take the dense route");
3550 let profile = core.reml_profile().expect("spectral profile");
3551 let CascadeResidualForm::Spectral(spectrum) = &profile.residual else {
3552 panic!("the dense route must carry the spectral residual form");
3553 };
3554
3555 let smallest = spectrum
3556 .eigenvalue
3557 .iter()
3558 .copied()
3559 .fold(f64::INFINITY, f64::min);
3560 let largest = spectrum
3561 .eigenvalue
3562 .iter()
3563 .copied()
3564 .fold(0.0_f64, f64::max);
3565
3566 for log_lambda in [-6.0_f64, -2.0, 0.0, 2.0, 6.0] {
3567 let lambda = log_lambda.exp();
3568 let (rss, penalty_energy, inverse_penalty_energy, third_energy) =
3569 spectrum.moments(lambda);
3570
3571 let solver = core.coeff_solver(lambda).expect("factored solver");
3572 let coeff = solver.solve(core, lambda, &core.rhs).expect("first solve");
3573 let dc: Vec<f64> = coeff
3574 .iter()
3575 .zip(core.pen_diag.iter())
3576 .map(|(&c, &d)| d * c)
3577 .collect();
3578 let u = solver.solve(core, lambda, &dc).expect("second solve");
3579 let solved = [
3580 core.rss_pen(&coeff),
3581 coeff.iter().zip(dc.iter()).map(|(&c, &v)| c * v).sum(),
3582 dc.iter().zip(u.iter()).map(|(&a, &b)| a * b).sum(),
3583 u.iter()
3584 .zip(core.pen_diag.iter())
3585 .map(|(&v, &d)| d * v * v)
3586 .sum(),
3587 ];
3588 let spectral = [rss, penalty_energy, inverse_penalty_energy, third_energy];
3589 let names = ["R", "c'Dc", "(Dc)'A^-1(Dc)", "u'Du"];
3590
3591 let condition = (largest + lambda) / (smallest + lambda);
3592 // The three quadratic forms are sums of positive terms, so their
3593 // relative error is the solve's own `O(m)·eps·cond(A)`. `R` is not:
3594 // BOTH routes form it by subtracting a fitted energy from an anchor
3595 // energy, so its relative error carries that cancellation's own
3596 // condition number, `anchor/|R|`. Charging the sum of the two is the
3597 // honest comparator bound.
3598 let cancellation = spectrum.anchor_energy[0] / rss.abs().max(f64::MIN_POSITIVE);
3599 let bounds = [
3600 core.m as f64 * f64::EPSILON * (condition + cancellation),
3601 core.m as f64 * f64::EPSILON * condition,
3602 core.m as f64 * f64::EPSILON * condition,
3603 core.m as f64 * f64::EPSILON * condition,
3604 ];
3605 for (((&a, &b), name), bound) in
3606 spectral.iter().zip(solved.iter()).zip(names).zip(bounds)
3607 {
3608 let gap = (a - b).abs() / b.abs().max(f64::MIN_POSITIVE);
3609 assert!(
3610 gap <= bound,
3611 "{name} disagrees at log lambda {log_lambda}: spectral {a}, solved {b} \
3612 (relative {gap:e} exceeds the comparator's own forward error {bound:e} \
3613 at cond(A) = {condition:e}, cancellation = {cancellation:e})"
3614 );
3615 }
3616 }
3617 }
3618
3619 /// The cascade's own closed form and the [`AffineRemlProfile`] the search
3620 /// actually runs on are one score.
3621 ///
3622 /// `fit_reml` isolates the optimum with the affine profile (for its interval
3623 /// extension) while `criterion` and the selected `normalized_logdet` come
3624 /// from [`CascadeRemlProfile::evaluate`]. Two implementations of one
3625 /// quantity is exactly the arrangement that lets a criterion drift, so the
3626 /// two are held to agreement in value, slope and curvature here. The bound
3627 /// is the summation roundoff of the mode sums both perform — `rank·eps`
3628 /// relative — and nothing about the fixture is free to widen it.
3629 #[test]
3630 fn affine_view_is_the_same_score_as_the_cascade_jet() {
3631 let (x1, x2, y) = dense_fixture(6);
3632 let weights = vec![1.0; y.len()];
3633 let axes: [&[f64]; 2] = [&x1, &x2];
3634 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
3635 .expect("cascade design");
3636 let profile = design.core.reml_profile().expect("spectral profile");
3637 let affine = profile
3638 .affine_view()
3639 .expect("affine view")
3640 .expect("the dense route must expose an affine view");
3641 let (lo, hi) = profile.log_lambda_domain().expect("domain");
3642 let rank = (design.core.m - design.core.nullity()) as f64;
3643 let bound = rank * f64::EPSILON;
3644
3645 for step in 0..=8 {
3646 let log_lambda = lo + (hi - lo) * step as f64 / 8.0;
3647 let cascade = profile.evaluate(log_lambda).expect("cascade jet").jet;
3648 let spectral = affine.evaluate(log_lambda).expect("affine jet");
3649 for (name, a, b) in [
3650 ("value", cascade.value, spectral.value),
3651 ("derivative", cascade.derivative, spectral.derivative),
3652 ("curvature", cascade.curvature, spectral.curvature),
3653 ] {
3654 let gap = (a - b).abs() / (1.0 + b.abs());
3655 assert!(
3656 gap <= bound,
3657 "{name} disagrees at log lambda {log_lambda}: cascade {a}, affine {b} \
3658 (relative {gap:e} exceeds the shared mode-sum roundoff {bound:e})"
3659 );
3660 }
3661 }
3662 }
3663
3664 /// Both replacement enclosures dismiss a tail cell the Lipschitz pad cannot.
3665 ///
3666 /// At the top of `log_lambda_domain` the score's derivative has decayed to
3667 /// order `rank·sqrt(eps)`, while the pad's radius at the search's own
3668 /// resolution floor is `C·sqrt(eps)` with `C` of order the residual degrees
3669 /// of freedom. The pad therefore straddles zero at a width the search cannot
3670 /// go below — a search that cannot terminate, not a slow one. Both cures
3671 /// have widths that collapse with the cell instead: the affine interval
3672 /// extension on the dense route, and the multiplicative bracket that
3673 /// [`CascadeRemlProfile::enclose`] intersects into the pad everywhere else.
3674 /// This asserts the difference at the resolution floor rather than
3675 /// describing it.
3676 #[test]
3677 fn tail_cell_the_lipschitz_pad_cannot_dismiss_is_dismissed_by_both_cures() {
3678 let (x1, x2, y) = dense_fixture(6);
3679 let weights = vec![1.0; y.len()];
3680 let axes: [&[f64]; 2] = [&x1, &x2];
3681 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
3682 .expect("cascade design");
3683 let profile = design.core.reml_profile().expect("spectral profile");
3684 let affine = profile
3685 .affine_view()
3686 .expect("affine view")
3687 .expect("the dense route must expose an affine view");
3688 let (_, hi) = profile.log_lambda_domain().expect("domain");
3689
3690 let lo = hi - f64::EPSILON.sqrt();
3691 let left = sample_at(&profile, lo);
3692 let right = sample_at(&profile, hi);
3693
3694 let pad = profile.lipschitz_pad(left, right, hi - lo);
3695 assert!(
3696 pad.derivative.contains_zero(),
3697 "the fixture no longer reproduces the pad's tail stall: {pad:?}"
3698 );
3699 assert!(
3700 pad.derivative.hi - pad.derivative.lo > 10.0 * left.derivative.abs(),
3701 "the stall is that the pad is WIDER than the derivative it brackets; \
3702 derivative {}, pad {:?}",
3703 left.derivative,
3704 pad.derivative
3705 );
3706
3707 for (name, enclosure) in [
3708 (
3709 "affine interval extension",
3710 affine.enclose(lo, hi).expect("interval extension").derivative,
3711 ),
3712 (
3713 "multiplicative bracket (intersected)",
3714 profile.enclose(left, right).expect("enclosure").derivative,
3715 ),
3716 ] {
3717 assert!(
3718 !enclosure.contains_zero(),
3719 "{name} must exclude zero on a floor-width tail cell where the \
3720 derivative is {} and the pad reports {:?}; got {enclosure:?}",
3721 left.derivative,
3722 pad.derivative
3723 );
3724 }
3725 }
3726
3727 /// The multiplicative bracket is an OUTER bound, checked against the score
3728 /// it claims to bracket, on a design that HAS numerically null Schur modes.
3729 ///
3730 /// A too-tight enclosure does not fail loudly — it makes the search discard
3731 /// a cell that contained a stationary point and return a certified wrong
3732 /// answer. So the bracket is charged against densely sampled truth on cells
3733 /// spanning the whole domain and every width from the resolution floor up.
3734 ///
3735 /// The deep fixture is deliberate: the null modes are the edge where the
3736 /// bracket's premises are thinnest (`R'` is a positive mixture only over the
3737 /// modes that survive the roundoff floor), so the containment claim is made
3738 /// where it is hardest, not where it is easiest. The test asserts the
3739 /// fixture still reaches that regime, so it cannot quietly stop covering it.
3740 #[test]
3741 fn enclosure_contains_the_derivatives_it_brackets_with_null_modes() {
3742 let (x1, x2, y) = clustered_fixture();
3743 let weights = vec![1.0; y.len()];
3744 let axes: [&[f64]; 2] = [&x1, &x2];
3745 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 3)
3746 .expect("cascade design");
3747 let profile = design.core.reml_profile().expect("spectral profile");
3748 let null_modes = profile
3749 .modes
3750 .iter()
3751 .filter(|mode| mode.eigenvalue == 0.0)
3752 .count();
3753 assert!(
3754 null_modes > 0,
3755 "fixture no longer reaches the null-mode regime this test exists to cover \
3756 ({} modes, all positive)",
3757 profile.modes.len()
3758 );
3759 check_containment_over(&profile);
3760 }
3761
3762 /// The same containment claim on the small, well-conditioned fixture the
3763 /// other tests use — the two together bracket the range of designs the
3764 /// enclosure has to serve.
3765 #[test]
3766 fn enclosure_contains_the_derivatives_it_brackets() {
3767 let (x1, x2, y) = dense_fixture(6);
3768 let weights = vec![1.0; y.len()];
3769 let axes: [&[f64]; 2] = [&x1, &x2];
3770 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
3771 .expect("cascade design");
3772 let profile = design.core.reml_profile().expect("spectral profile");
3773 check_containment_over(&profile);
3774 }
3775
3776 fn check_containment_over(profile: &CascadeRemlProfile<'_>) {
3777 let (domain_lo, domain_hi) = profile.log_lambda_domain().expect("domain");
3778 let span = domain_hi - domain_lo;
3779
3780 for cell in 0..24 {
3781 let width = span * 0.5_f64.powi(cell as i32 % 12);
3782 let start = domain_lo + (span - width) * (cell / 12) as f64;
3783 let (lo, hi) = (start, (start + width).min(domain_hi));
3784 if !(hi > lo) {
3785 continue;
3786 }
3787 let left = sample_at(profile, lo);
3788 let right = sample_at(profile, hi);
3789 let enclosure = profile.enclose(left, right).expect("enclosure");
3790 for step in 0..=32 {
3791 let x = lo + (hi - lo) * step as f64 / 32.0;
3792 let jet = profile.evaluate(x).expect("jet").jet;
3793 assert!(
3794 enclosure.derivative.contains(jet.derivative),
3795 "derivative {} at {x} escapes {:?} on cell [{lo}, {hi}]",
3796 jet.derivative,
3797 enclosure.derivative
3798 );
3799 assert!(
3800 enclosure.curvature.contains(jet.curvature),
3801 "curvature {} at {x} escapes {:?} on cell [{lo}, {hi}]",
3802 jet.curvature,
3803 enclosure.curvature
3804 );
3805 }
3806 }
3807 }
3808
3809 fn sample_at(profile: &CascadeRemlProfile<'_>, x: f64) -> ScoreSample {
3810 let jet = profile.evaluate(x).expect("cascade jet").jet;
3811 ScoreSample {
3812 x,
3813 value: jet.value,
3814 derivative: jet.derivative,
3815 curvature: jet.curvature,
3816 third: jet.third,
3817 }
3818 }
3819
3820 #[test]
3821 fn dense_spectral_profile_matches_factorization_and_analytic_slope() {
3822 let (x1, x2, y) = dense_fixture(6);
3823 let weights = vec![1.0; y.len()];
3824 let axes: [&[f64]; 2] = [&x1, &x2];
3825 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
3826 .expect("cascade design");
3827 assert!(design.core.dense_gram.is_some());
3828 let profile = design.core.reml_profile().expect("spectral profile");
3829 let rank = (design.core.m - design.core.nullity()) as f64;
3830 let dof = (design.core.y.len() - design.core.nullity()) as f64;
3831
3832 for log_lambda in [-4.0, 0.0, 3.0] {
3833 let evaluation = profile.evaluate(log_lambda).expect("analytic score");
3834 let lambda = log_lambda.exp();
3835 let logdet = design.core.logdet_dense(lambda).expect("dense logdet");
3836 let coefficients = design
3837 .core
3838 .solve_coeff(lambda, &design.core.rhs, None)
3839 .expect("dense solve")
3840 .0;
3841 let rss = design.core.rss_pen(&coefficients);
3842 let direct = -0.5
3843 * (logdet - rank * log_lambda - design.core.pen_logdet_const
3844 + dof * (rss / dof).ln());
3845 assert!(
3846 (evaluation.jet.value - direct).abs() <= f64::EPSILON.sqrt() * (1.0 + direct.abs()),
3847 "spectral/direct score mismatch at {log_lambda}: {} versus {direct}",
3848 evaluation.jet.value,
3849 );
3850
3851 // Finite differences are confined to this oracle test. The
3852 // production optimizer consumes the hand-derived score jet above.
3853 //
3854 // The comparator has to be built for a NOISY evaluator, and this
3855 // one is: `profile.evaluate` runs a spectral solve, so its value
3856 // carries roughly 1e-12 of evaluation noise rather than being exact
3857 // to the last bit.
3858 //
3859 // That moves the optimal step. `h = eps^(1/3)` is optimal only when
3860 // the sole error is representation roundoff; against noise `v` the
3861 // central-difference error is `v/h + (h²/6)·S3`, minimized at
3862 // `h ~ (3v/S3)^(1/3) ~ 1e-4` — three orders ABOVE `eps^(1/3)`.
3863 // Measured at `eps^(1/3) = 6.06e-6`: D(h) = 5.025511346842242,
3864 // D(h/2) = 5.025511611442345. The two stencils disagree by 2.6e-7
3865 // and the FINER one is FARTHER from the analytic slope. Truncation
3866 // shrinks with h and cannot do that; noise amplified by `1/h` does
3867 // exactly that. The step was too small, not too crude.
3868 //
3869 // So: `h = 1e-4`, and Richardson there. The `h²` term cancels
3870 // exactly (leaving O(h⁴) ~ 1e-16, negligible whatever `S3` is) and
3871 // the noise floor is `~3v/h ~ 3e-8`, inside the unchanged
3872 // `sqrt(eps)·(1+|S'|) ~ 9e-8` bound. The bound is not relaxed; the
3873 // comparator is made accurate enough to be charged against it.
3874 let central = |step: f64| -> f64 {
3875 let right = profile
3876 .evaluate(log_lambda + step)
3877 .expect("right score")
3878 .jet
3879 .value;
3880 let left = profile
3881 .evaluate(log_lambda - step)
3882 .expect("left score")
3883 .jet
3884 .value;
3885 (right - left) / (2.0 * step)
3886 };
3887 let step = 1.0e-4;
3888 let coarse = central(step);
3889 let fine = central(0.5 * step);
3890 let numerical_slope = (4.0 * fine - coarse) / 3.0;
3891 assert!(
3892 (evaluation.jet.derivative - numerical_slope).abs()
3893 <= f64::EPSILON.sqrt() * (1.0 + numerical_slope.abs()),
3894 "analytic slope mismatch at {log_lambda}: {} versus {numerical_slope} \
3895 (Richardson of h={step:e} → {coarse}, h/2 → {fine})",
3896 evaluation.jet.derivative,
3897 );
3898 }
3899 }
3900}