Skip to main content

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}