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. The dense route has a rigorous
61//! interval extension that isolates every stationary interval before
62//! safeguarded root refinement.
63//!
64//! Which designs get that proof is set by `CERTIFIED_SPECTRUM_MAX`, NOT by the
65//! dense Gram cache. The exact spectrum is a symmetric reduction of the
66//! `rank × rank` Schur complement, so its bound is that reduction's transient
67//! memory; the Gram cache's bound (`DENSE_GRAM_MAX`) is a much tighter LIFETIME
68//! budget for a per-design array. Past the cache the Schur complement is
69//! accumulated straight from the CSR rows into ONE packed upper triangle for the
70//! duration of the reduction and dropped, so certification continues well past
71//! it — which is what lets a cascade whose refinement crosses 1536 columns
72//! finish at all (#2546). What the criterion consumes is `Θ` and `Vᵀβ`, never
73//! the eigenbasis, so the triangle is reduced in place and `β` rides along
74//! through the reflectors and rotations (`gam_linalg::packed_symmetric_spectrum`,
75//! #2758); the transient is one `m²/2` array rather than the seven-plus `m²`
76//! blocks a general eigendecomposition holds, and the derived width moves with
77//! it. Past the SPECTRUM budget the route deliberately refuses automatic
78//! λ selection: a fixed-probe SLQ log-determinant has no exact-real outer
79//! enclosure, and neither does an exact factorization, which returns a number at
80//! one λ and no enclosure over a λ cell. Closing the separate β-seeded
81//! residual Krylov space can make the residual exact, but cannot enclose that
82//! determinant; a merely converged residual tail is numerical point evidence
83//! only. Fixed-λ fits remain available on the iterative route.
84//!
85//! The same elimination puts the profiled RESIDUAL in the same form, making the
86//! diagnostic criterion solve-free at every λ whenever its residual rule is
87//! admitted. With
88//! `β = D^{−1/2}(b₁ − G₁₀G₀₀^{−1}b₀)` and `S_k(λ) = β'(B+λI)^{−k}β`, the residual
89//! is `R = anchor − S₁` and its three `log λ` derivatives are built from
90//! `S₂, S₃, S₄`; `anchor = y'Wy − b₀'G₀₀^{−1}b₀` is the part no λ can move. On
91//! the dense route the eigenbasis projects β directly. Past the cap, `S_k(λ)` is
92//! `∫(θ+λ)^{−k} dμ(θ)` for the measure β induces on `spec(B)`, so ONE Lanczos run
93//! seeded with β (rather than with a Rademacher probe) returns the Jacobi matrix
94//! of its Golub–Meurant Gauss rule — the same `(node, weight)` shape the dense
95//! route stores, so both routes then evaluate one expression. That rule is
96//! admitted only when it has earned point-evaluation trust: either the Krylov
97//! space has consumed `rank(B) ≤ n − nullity` and is therefore invariant (the
98//! rule is then exact for every kernel), or the gaps over the nested
99//! `m/4, m/2, m` rules contract enough that their geometric tail estimate is at
100//! most `√ε` over the whole domain. A failed admission is an error; the
101//! ill-conditioned two-PCG-solves-per-λ fallback is not revived (#2503).
102//! Neither residual admission encloses the independent fixed-probe SLQ
103//! determinant, so neither authorizes automatic iterative-route REML (#2513).
104//!
105//! Refinement certificate. After fitting L levels, the candidate level L+1 is
106//! constructed (O(n)) and what it would buy is compared against what it would
107//! cost — both exactly, and both in the currency λ was already selected in.
108//! For the penalized objective `F(c) = ‖√W(y−Xc)‖² + λc'Dc`, appending columns
109//! `X₂` with penalty `λd_{L+1}I` decreases the minimum by `gain = g'S⁻¹g`,
110//! `g = X₂'W r̂`, `S = X₂'W(I−H)X₂ + λd_{L+1}I` the Schur complement, and
111//! multiplies the restricted likelihood's Occam factor by `exp(−occam/2)` with
112//! `occam = log det(S/(λd_{L+1}))` — the log-determinant of the SAME operator
113//! the gain is a quadratic form in. At the profiled σ̂² the restricted
114//! log-likelihood therefore moves by
115//!
116//! ```text
117//! 2·Δ = dof·log(rss_pen/(rss_pen − gain)) − occam
118//! ```
119//!
120//! so one more level is warranted exactly when `gain > rss_pen·(1 −
121//! e^{−occam/dof})`. That break-even gain is the tolerance: the objective
122//! decrease the level's own DIMENSION already pays for. Nothing in it is
123//! chosen — a fixed fraction of `rss_pen` charges nothing for the width of the
124//! set it is buying, which is why it demanded a level of 32790 candidate
125//! columns against 5997 identifiable directions (#2759).
126//!
127//! The cascade refines (adds the level, refits, re-selects λ) until the
128//! evidence stops improving, the net stops producing new centers (every point
129//! is a center), or the next level reaches a structural boundary: data
130//! identifiability, certified-spectrum memory, level count, or center count. A
131//! boundary reached while the evidence still improves is `Underresolved` with
132//! the retained checkpoint and that evidence; it is never sent downstream to a
133//! rank-flat score search and never converted into a fit.
134//!
135//! Both numbers come from ONE evaluation of the design with the complete
136//! candidate level appended, at the incumbent's λ — available past every
137//! capacity budget, because a single fixed-λ evaluation needs no certified
138//! spectrum and no identifiable rank. The matrix-free two-sided bracket on
139//! `gain` (below) is kept as the SCREEN that skips that evaluation: Hadamard on
140//! `S ⪯ diag(X₂'WX₂) + λd` bounds `occam` from above for free, so a gain
141//! bracket whose LOWER end already clears the break-even gain of that bound
142//! proves the level warranted without building anything.
143//!
144//! A capacity boundary is a boundary on WIDTH, and the level it stops is not
145//! all-or-nothing. When the complete candidate level would carry more penalized
146//! modes than the sample identifies or the certified spectrum can enclose, the
147//! cascade adds as many of its candidates as the budget allows — the largest
148//! `|g_j|` first, the same terms the bound is a sum of — and leaves the rest for
149//! the bound to certify. Refusing the whole level because the PROPOSAL was wide,
150//! while the gain it carried was concentrated far inside the budget, is #2700.
151//! The tolerance is still compared against the bound over the COMPLETE candidate
152//! set, and a truncated level's leftovers are re-assessed at their own radius
153//! before any fit is minted, so a partial level buys width, never a weaker
154//! certificate.
155//!
156//! Posterior. Coefficient covariance is `σ²(X'WX+λD)^{−1}`; pointwise
157//! prediction variance routes the basis row through one (certified) solve.
158//! Exact posterior samples come from perturb-and-solve: `c_s = A^{−1}(X'Wy +
159//! σ(X'W^{1/2}z₁ + √λ D^{1/2}z₂))` with iid standard-normal `z₁, z₂` has
160//! mean `ĉ` and covariance exactly `σ²A^{−1}` (deterministically seeded; one
161//! certified solve per sample).
162//!
163//! Payoff. Build O(n·(L + 3^d)), fit O(nnz · iters) per λ trial with
164//! n-independent iters — O(n log n) end to end, against the dense n×k kernel
165//! Gram + O(k³) per trial that duchon/matern pay today. Gap behavior is
166//! mechanical: levels wider than a gap keep support across it (polynomial +
167//! coarse bumps bridge), finer levels have no data and revert to their prior
168//! variance, so the posterior mean bridges instead of sagging while the
169//! variance grows into the gap.
170
171use std::collections::HashMap;
172use std::sync::Arc;
173
174use faer::sparse::{SparseColMat, SymbolicSparseColMat};
175use gam_linalg::packed_symmetric_spectrum::{
176 packed_symmetric_spectrum_with_probe, packed_upper_len, packed_upper_row_offset,
177};
178use gam_math::score_opt::{
179 AffineRemlProfile, ScoreJet, certified_ln_positive,
180};
181use gam_linalg::sparse_exact::{
182 SparseExactFactor, factorize_sparse_spd_strict, logdet_from_factor, solve_sparse_spd,
183 sparse_spd_factor_nnz,
184};
185use gam_terms::grid_spline_2d::{chol_solve, cholesky_logdet};
186use ndarray::Array1;
187
188/// Bump support radius as a multiple of the level's covering radius:
189/// `δ_l = OVERLAP·h_l`. Separation ≥ h_l caps the bumps covering a point at
190/// a packing constant per level (O(q) row nonzeros per level).
191const OVERLAP: f64 = 2.0;
192/// Root covering radius as a fraction of the largest scaled axis range.
193const H0_FRACTION: f64 = 0.5;
194/// Levels in the initial cascade before refinement certificates run.
195const INITIAL_LEVELS: usize = 3;
196/// Hard cap on cascade depth (h shrinks 2^16-fold below the root).
197const MAX_LEVELS: usize = 16;
198/// Hard cap on total centers across all levels.
199const MAX_CENTERS: usize = 200_000;
200
201/// Column count up to which the normal equations go through dense Cholesky
202/// (exact logdet, no iteration); above it, PCG + SLQ. 1536² doubles ≈ 18 MB.
203///
204/// This sizes a PERSISTENT per-design cache: `Core::dense_gram` is held for the
205/// whole life of the design and reused by every solve and every λ. It does NOT
206/// bound the certified REML proof — see [`CERTIFIED_SPECTRUM_MAX`], which is a
207/// transient budget and reaches further.
208const DENSE_GRAM_MAX: usize = 1536;
209
210/// Column count up to which the CERTIFIED automatic-REML proof is available.
211///
212/// The proof is not a log-determinant, and that is the whole reason it needs its
213/// own bound. It is the λ-INDEPENDENT Schur spectrum built by
214/// [`Core::dense_cascade_spectrum`]: with `B = D^{−1/2}(G₁₁ − G₁₀G₀₀^{−1}G₀₁)
215/// D^{−1/2} = VΘV'`, every determinant mode and every residual moment is an
216/// analytic kernel of `θ_i + λ`, which is what lets
217/// [`AffineRemlProfile::enclose`] produce genuine INTERVAL extensions of the
218/// score value and its first two derivatives — the objects the KKT root and the
219/// global candidate ordering are certified in.
220///
221/// An exact factorization of `X'WX + λD` does not substitute for that, however
222/// exact it is. A factorization — dense or sparse-direct — is a POINTWISE
223/// object: it returns a number at one λ and supports no enclosure over a λ cell,
224/// so it can certify neither a score sign on an interval nor a stationary point.
225/// (The former endpoint-jet/global-Lipschitz enclosure that tried to bridge that
226/// gap did not collapse in saturated tails and was removed; see
227/// [`CascadeRemlProfile::affine_view`].) The requirement is therefore ALL
228/// eigenvalues of the `rank × rank` whitened Schur complement, together with the
229/// whitened response's coordinates in its eigenbasis — and NOT the eigenbasis
230/// itself, which is where this budget's history went wrong (#2758).
231///
232/// So the bound is that decomposition's LIVE MEMORY, and the width is DERIVED
233/// from it: `sqrt(CERTIFIED_SPECTRUM_BYTES / bytes-per-m²)`, over the
234/// [`CERTIFIED_SPECTRUM_BYTES_PER_COLUMN_SQUARED`] the route was measured to
235/// hold, all freed as soon as the modes are extracted. Being a transient rather
236/// than a lifetime cache is why this budget reaches so much further in columns
237/// than [`DENSE_GRAM_MAX`]: the Schur complement is assembled from the CSR
238/// design for the duration of the decomposition and dropped, instead of being
239/// kept for the fit.
240///
241/// Time is not the binding resource here and is not what the number is derived
242/// from: the decomposition is `O(rank³)` and is paid ONCE per cascade depth
243/// (`fit_reml` builds the profile once and the certified search then evaluates
244/// mode sums, `O(modes)` per trial, with no linear algebra at all).
245///
246/// That claim is now stated with a measurement behind it, because a wider cap
247/// admits wider designs and "not binding" should not be taken on faith. On four
248/// cores a `rank = 6795` profile builds in **46.2 s** end to end — assembly,
249/// reduction and sweep — which is `4·rank³/3 = 4.2e11` flops at 9.1 GFLOP/s
250/// through the packed symmetric matrix-vector product and rank-2 update, both
251/// Rayon-parallel over rows. `rank = 1922` takes 1.16 s. Extrapolating the cubic
252/// to the cap gives ~2.7 minutes at `rank = 10362`, so the widest admissible
253/// design costs single-digit minutes of one-off certification, not hours.
254const CERTIFIED_SPECTRUM_MAX: usize =
255 (CERTIFIED_SPECTRUM_BYTES / CERTIFIED_SPECTRUM_BYTES_PER_COLUMN_SQUARED).isqrt();
256
257/// Live memory the certified spectral proof may hold at its peak. The largest
258/// transient this crate asks of a workstation; [`CERTIFIED_SPECTRUM_MAX`] is
259/// derived from it rather than chosen, so moving the budget moves the width and
260/// the two cannot drift apart.
261const CERTIFIED_SPECTRUM_BYTES: usize = 512 * 1024 * 1024;
262
263/// Bytes the certified route holds at its peak, per `m²` of design width.
264///
265/// This used to be a count of whole `m × m` `f64` blocks, and it was **8** —
266/// measured at 6.41-6.84 and rounded up, because the inventory the file could
267/// see (the upper Gram, the Schur complement, the eigenvector matrix) was not
268/// what ran: `eigh` is `faer`'s self-adjoint EVD and its tridiagonalization
269/// allocates workspace this crate never named. Eight blocks is `64` bytes per
270/// `m²` and a `1/√8` factor on the admissible width — 2896 columns, against a
271/// 6000-row fixture that identifies 5997 penalized directions (#2758).
272///
273/// Every one of those blocks was carrying something the criterion does not
274/// consume. It consumes `Θ` and `Vᵀβ`; the eigenbasis is read at exactly one
275/// site, to form that projection. So the route now holds:
276///
277/// ```text
278/// packed upper Schur triangle rank(rank+1)/2 · 8 B -> 4 B per m²
279/// cross block G01 q · rank · 8 B, q ≤ 4 -> O(m)
280/// tridiagonal + working vectors O(rank) -> O(m)
281/// ```
282///
283/// and nothing else: the `m × m` Gram is not assembled at all (the two blocks
284/// the Schur complement needs are accumulated straight from the CSR rows), the
285/// triangle is reduced IN PLACE, and `Vᵀβ` rides along one vector at a time
286/// through [`gam_linalg::packed_symmetric_spectrum`]. The declared number is
287/// therefore an INVENTORY again — one packed `f64` triangle, `8/2 = 4` bytes
288/// per `m²` — with the next integer of headroom for the allocator's own
289/// rounding.
290///
291/// `zz_measure_certified_spectrum_peak_memory_2546` re-measures it and fails if
292/// it is ever exceeded, because a figure below the realized one would let
293/// [`CERTIFIED_SPECTRUM_MAX`] admit a width that overruns
294/// [`CERTIFIED_SPECTRUM_BYTES`].
295const CERTIFIED_SPECTRUM_BYTES_PER_COLUMN_SQUARED: usize = 5;
296
297/// Memory budget for the exact sparse-direct factor of `A = X'WX + λD`, stated
298/// as nonzeros of `L`.
299///
300/// Past [`DENSE_GRAM_MAX`] the design is still SPARSE — a row touches the `O(1)`
301/// bumps per level whose supports cover it, `O(qL)` nonzeros — so `A` is sparse
302/// and has an exact sparse Cholesky. Nothing about a fixed-λ log-determinant
303/// requires iteration or a stochastic estimate; what it requires is that the
304/// FILL-IN fit in memory, and `nnz(A)` does not predict `nnz(L)`. So the
305/// realized fill of the AMD ordering is measured by a symbolic pass before any
306/// numeric work is committed, and compared against this budget.
307///
308/// The number is that budget divided by the factor's own per-entry cost: the
309/// simplicial factor stores one `f64` value and one `usize` row index per
310/// nonzero, 16 bytes, and 256 MiB of factor is the largest this route will pay,
311/// so `256·2^20 / 16 = 16·2^20` nonzeros. Beyond it no exact factorization is
312/// available at all and the log-determinant falls back to the stochastic
313/// estimate — reported as [`LogdetMethod::Slq`], and never underwriting a proof,
314/// since [`ResidualCascadeDesign::fit_reml`] refuses far below this width.
315const SPARSE_FACTOR_MAX_NNZ: usize = 16 * 1024 * 1024;
316
317/// PCG convergence: relative residual ‖b − Ac‖/‖b‖ (the backward-error
318/// certificate) demanded of every solve, and the iteration cap past which
319/// the solve is an error rather than a silent approximation. The certification
320/// suite gates the iterative route at 1e-9; asking for more burns matvecs
321/// without strengthening any downstream certificate.
322const CG_RTOL: f64 = 1e-9;
323const CG_MAX_ITERS: usize = 4000;
324
325/// Coarse-space additive-Schwarz preconditioner controls (issue #1032: the
326/// "BPX/level-diagonal preconditioned CG, n-independent iters" spec).
327///
328/// The multilevel Wendland frame is redundant across scales — a coarse bump and
329/// the fine bumps inside its support are strongly correlated — so the data-fit
330/// Gram `X'WX` couples levels and a pure-diagonal (Jacobi) preconditioner leaves
331/// a conditioning that grows with the number of *data-identified* levels, hence
332/// with `n` (more rows ⇒ finer levels carry data ⇒ another collinear coarse
333/// scale the diagonal can't decouple). The cure is the textbook two-level
334/// additive Schwarz coarse space: solve the coarse block — the polynomial layer
335/// plus every level the penalty has NOT yet made diagonally dominant — EXACTLY,
336/// and precondition the remaining penalty-dominated fine levels (where
337/// `A_ll ≈ λ d_l I` is already uniformly conditioned) by their Jacobi diagonal.
338///
339/// A level is "data-dominated" while `λ d_l < COARSE_DOMINANCE · median diag
340/// (X'WX) over the level`. Because columns are laid out poly, level-0, level-1,
341/// … and `d_l` increases while the per-level data weight decreases, the
342/// data-dominated levels are exactly the coarsest prefix `[0, ncoarse)`, so the
343/// coarse space is a contiguous column prefix and the cut is a single scan. The
344/// crossover level grows only as `½ log₄(n/λ)` — `ncoarse = O(√(n/λ))` columns —
345/// so the exact coarse factorization stays small against the sparse matvecs at
346/// every n the primitive serves. [`COARSE_SPACE_MAX`] caps it as a safety valve
347/// (past the cap the finer data-dominated levels fall back to Jacobi and the
348/// iteration count rises, but the CG residual certificate still guarantees the
349/// solve); [`MIN_COARSE_LEVELS`] always deflates the two coarsest scales, which
350/// are near-collinear with the polynomial layer at every λ.
351const COARSE_DOMINANCE: f64 = 4.0;
352/// Safety ceiling on the exact-coarse column count. It must NOT bind at the n
353/// the primitive serves: the n-independent iteration count rests on the coarse
354/// block containing the WHOLE data-dominated prefix (`O(√(n/λ))` columns), so a
355/// cap that truncates that prefix is exactly what makes the iteration count
356/// climb with n (a finer data-dominated level demoted to Jacobi cannot be
357/// decoupled from the coarse scales it is collinear with). At the n-scales the
358/// iterative route engages (tens of thousands of rows → a ≈1.4k-column
359/// prefix) this is non-binding headroom; it only triggers in the genuinely
360/// degenerate case the quasi-uniformity guard is meant to catch first. The
361/// realized coarse factorization runs at the actual prefix length, not the cap,
362/// so the ceiling costs nothing until it fires.
363const COARSE_SPACE_MAX: usize = 4096;
364const MIN_COARSE_LEVELS: usize = 2;
365
366/// Quasi-uniformity guard (issue #1032, caveat 2). The BPX n-independent CG
367/// iteration bound rests on the nested ε-nets being quasi-uniform *in the
368/// metric-scaled coordinates `z = diag(metric)·x` the bumps live in*. The
369/// greedy net guarantees covering ≤ h and separation ≥ h in `z` by
370/// construction, so the only way the BPX norm-equivalence constant blows up is
371/// when the metric is so anisotropic that the metric-scaled point cloud is
372/// effectively degenerate along a direction — the data collapses onto a lower
373/// dimension in `z`, the root covering radius `h₀ = ½·max_a range_a` swamps the
374/// collapsed axis, the level-`l` bumps overlap pathologically, and the
375/// preconditioner constant (hence the iteration count) grows without an
376/// n-independent bound. The realized symptom is `solve_iters` climbing toward
377/// [`CG_MAX_ITERS`]; this guard detects the *cause* up front from the
378/// metric-scaled per-axis spread so the selected route can refuse BEFORE
379/// paying an unbounded iterative solve, rather than discovering
380/// the blow-up only after `CG_MAX_ITERS` work.
381///
382/// Condition measure: the ratio of the largest to smallest metric-scaled
383/// per-axis standard deviation (a scale-free aspect ratio of the scaled
384/// cloud). Past this threshold the net is no longer quasi-uniform in every
385/// direction and the BPX bound is not trustworthy. Derived, not a knob: a
386/// `10³` aspect ratio means the collapsed axis carries <0.1% of the dominant
387/// axis's variation, at which point its bumps span the whole cloud and the
388/// multilevel hierarchy degenerates to a single ill-conditioned level.
389const QUASI_UNIFORMITY_MAX_ASPECT: f64 = 1.0e3;
390
391/// SLQ controls: fixed Rademacher probes (shared across λ trials) and the
392/// Lanczos depth per probe (full reorthogonalization; early exit on
393/// breakdown).
394const SLQ_PROBES: usize = 24;
395const SLQ_LANCZOS_STEPS: usize = 48;
396/// Live bytes the profiled-residual quadrature's full-reorthogonalization basis
397/// may occupy past the dense cap (see [`Core::residual_quadrature_budget`]). The
398/// basis is `steps x rank` doubles, and it is the only quantity in that run that
399/// grows with both; the matvecs and the tridiagonal eigensolve are negligible
400/// beside it.
401///
402/// The number is chosen so the run can REACH its Krylov ceiling, because that is
403/// where the rule becomes exact and stopping short of it buys nothing at all: a
404/// budget of `0.9 * ceiling` pays 90% of the work and then refuses point evaluation.
405/// The binding requirement is therefore `ceiling * rank * 8` bytes, measured on the
406/// designs the #2503 integration fixtures actually build:
407///
408/// ```text
409/// n = 800, level 6 ceiling 797 rank 7387 47 MB
410/// n = 1200, level 8 ceiling 1197 rank 30879 296 MB
411/// n = 2500, level 7 ceiling 2497 rank 16565 331 MB
412/// n = 6000, level 7 ceiling 5997 rank 8432 404 MB
413/// ```
414///
415/// 512 MiB covers all of them and still bounds the run on the
416/// hundred-thousand-column designs `MAX_CENTERS` permits, where the ceiling is
417/// unreachable at any budget worth paying and the point route refuses rather
418/// than reviving the ill-conditioned solve.
419const RESIDUAL_QUADRATURE_BASIS_BYTES: usize = 512 << 20;
420
421/// Deterministic seed for the SLQ probes and posterior samples.
422const RNG_SEED: u64 = 0x1032_CA5C_ADE0_5EED;
423
424
425// ───────────────────────────── deterministic RNG ────────────────────────────
426
427/// SplitMix64: tiny, deterministic, full-period stream generator.
428struct SplitMix64(u64);
429
430impl SplitMix64 {
431 fn new(seed: u64) -> Self {
432 SplitMix64(seed)
433 }
434
435 fn next_u64(&mut self) -> u64 {
436 gam_linalg::utils::splitmix64(&mut self.0)
437 }
438
439 /// Rademacher ±1.
440 fn next_sign(&mut self) -> f64 {
441 if self.next_u64() & 1 == 0 { 1.0 } else { -1.0 }
442 }
443}
444
445// ─────────────────────────────── hash grids ─────────────────────────────────
446
447/// Integer cell of a point at a given cell width (coordinates are already
448/// metric-scaled and shifted to be ≥ 0, so indices are small and exact).
449#[inline]
450fn cell_of(z: &[f64; 3], dim: usize, width: f64) -> (i32, i32, i32) {
451 let mut c = [0_i32; 3];
452 for a in 0..dim {
453 c[a] = (z[a] / width).floor() as i32;
454 }
455 (c[0], c[1], c[2])
456}
457
458/// Hash grid over a point set: cell → indices. Lookup scans the 3^d
459/// neighborhood, which covers every point within one cell width.
460struct HashGrid {
461 width: f64,
462 dim: usize,
463 cells: HashMap<(i32, i32, i32), Vec<u32>>,
464}
465
466impl HashGrid {
467 fn new(width: f64, dim: usize) -> Self {
468 HashGrid {
469 width,
470 dim,
471 cells: HashMap::new(),
472 }
473 }
474
475 fn insert(&mut self, idx: u32, z: &[f64; 3]) {
476 let key = cell_of(z, self.dim, self.width);
477 self.cells.entry(key).or_default().push(idx);
478 }
479
480 /// Visit every stored index in the 3^d cells around `z` (deterministic
481 /// order: lexicographic cells, insertion order within a cell).
482 fn for_neighbors(&self, z: &[f64; 3], mut visit: impl FnMut(u32)) {
483 let (c0, c1, c2) = cell_of(z, self.dim, self.width);
484 let d2 = if self.dim > 2 { 1 } else { 0 };
485 let d1 = if self.dim > 1 { 1 } else { 0 };
486 for i0 in -1..=1_i32 {
487 for i1 in -d1..=d1 {
488 for i2 in -d2..=d2 {
489 if let Some(bucket) = self.cells.get(&(c0 + i0, c1 + i1, c2 + i2)) {
490 for &idx in bucket {
491 visit(idx);
492 }
493 }
494 }
495 }
496 }
497 }
498}
499
500#[inline]
501fn dist2(a: &[f64; 3], b: &[f64; 3], dim: usize) -> f64 {
502 let mut s = 0.0;
503 for k in 0..dim {
504 let d = a[k] - b[k];
505 s += d * d;
506 }
507 s
508}
509
510/// Wendland-(3,1) bump `(1−r)₊⁴(4r+1)`: positive definite on ℝ^d, d ≤ 3,
511/// C², native space H^{(d+3)/2}.
512#[inline]
513fn wendland(r: f64) -> f64 {
514 if r >= 1.0 {
515 return 0.0;
516 }
517 let v = 1.0 - r;
518 let v2 = v * v;
519 v2 * v2 * (4.0 * r + 1.0)
520}
521
522// ───────────────────────────── design assembly ──────────────────────────────
523
524/// One resolution level: its NEW centers (scaled coordinates), covering
525/// radius, support radius, prior precision weight, and a lookup grid of cell
526/// width δ_l over those centers.
527struct Level {
528 h: f64,
529 delta: f64,
530 /// Prior precision weight `d_l = 4^{l(s−d/2)}` (prior variance τ²/d_l).
531 weight: f64,
532 centers: Vec<[f64; 3]>,
533 /// First flat column index of this level's coefficients.
534 col_offset: usize,
535 grid: HashGrid,
536}
537
538/// Immutable fitted-design core shared between the design handle and fits.
539struct Core {
540 dim: usize,
541 metric: [f64; 3],
542 /// Lower corner / range of the scaled bounding box (polynomial layer
543 /// coordinates are `2(z − lo)/range − 1` for conditioning).
544 z_lo: [f64; 3],
545 z_range: [f64; 3],
546 sobolev_s: f64,
547 levels: Vec<Level>,
548 /// Full nested net Ξ_L (scaled coords), retained so the candidate level
549 /// L+1 can extend it without re-deriving coarser levels.
550 net: Vec<[f64; 3]>,
551 /// Total columns: `dim + 1` polynomial + all level centers.
552 m: usize,
553 /// CSR design rows (column-sorted within a row).
554 row_ptr: Vec<usize>,
555 col_idx: Vec<u32>,
556 vals: Vec<f64>,
557 /// Inputs retained for matvecs, residuals, and refinement.
558 w: Vec<f64>,
559 y: Vec<f64>,
560 /// Scaled data coordinates (shifted to the box corner).
561 z: Vec<[f64; 3]>,
562 /// `X'Wy`, `y'Wy`, `diag(X'WX)`.
563 rhs: Vec<f64>,
564 ytwy: f64,
565 gram_diag: Vec<f64>,
566 /// Per-column prior precision weight (0 on the polynomial layer).
567 pen_diag: Vec<f64>,
568 /// `Σ_j log d_j` over penalized columns (the λ-free part of log|λD|₊,
569 /// kept so REML criteria compare across cascade depths).
570 pen_logdet_const: f64,
571 /// Dense upper-triangular `X'WX` when `m ≤ DENSE_GRAM_MAX` (row-major
572 /// m×m, lower mirror filled at solve time); None on the iterative route.
573 dense_gram: Option<Vec<f64>>,
574 /// Predict-only factored precision: the lower Cholesky factor `L` of
575 /// `A = X'WX + λD` at the FIT's λ, populated only on a core rebuilt from a
576 /// persisted [`ResidualCascadeState`] (where the training CSR is dropped).
577 /// When present, `solve_coeff` replays the posterior-variance solve through
578 /// this factor instead of the absent training design; `None` on a
579 /// training-built core, which solves through `dense_gram`/PCG as usual.
580 predict_chol: Option<Vec<f64>>,
581}
582
583/// Solver route a fit took for its log-determinant.
584#[derive(Clone, Copy, Debug, PartialEq, Eq)]
585pub enum LogdetMethod {
586 /// Exact dense linear algebra: either the dense Cholesky of `X'WX + λD` at
587 /// this λ, or the λ-independent Schur eigendecomposition the certified REML
588 /// profile is built from. Both are exact; which one ran depends on whether a
589 /// λ was fixed or selected.
590 DenseExact,
591 /// Exact sparse-direct Cholesky of `X'WX + λD` at this λ (AMD-ordered
592 /// simplicial LLᵀ); the log-determinant is `2·Σ log L_jj`. Available past
593 /// the dense Gram cache, where the design is still sparse.
594 SparseExact,
595 /// Diagonal control variate + stochastic Lanczos quadrature on fixed
596 /// deterministic probes. NOT exact — the only route that is not, and taken
597 /// only when the sparse factor's fill-in exceeds
598 /// `SPARSE_FACTOR_MAX_NNZ`.
599 Slq,
600}
601
602/// Computable certificates attached to a fit.
603#[derive(Clone, Copy, Debug)]
604pub struct CascadeCertificate {
605 /// Backward error of the coefficient solve: ‖b − Aĉ‖/‖b‖ (0 on the dense
606 /// route).
607 pub solve_rel_residual: f64,
608 /// CG iterations of the coefficient solve (0 on the dense route); the
609 /// n-independence gate watches this.
610 pub solve_iters: usize,
611 /// Route the log-determinant took.
612 pub logdet_method: LogdetMethod,
613}
614
615/// The exact nested-model comparison one candidate set was decided on.
616///
617/// Appending the complete candidate level `X₂` (penalty `λ·d`) to the design
618/// and re-minimizing at the SAME λ decreases the penalized objective by
619/// [`Self::gain`] and multiplies the restricted likelihood's Occam factor by
620/// `exp(−occam/2)`, with
621///
622/// ```text
623/// gain = gᵀS⁻¹g, g = X₂ᵀW r̂, S = X₂ᵀW(I − H)X₂ + λd·I
624/// occam = log det(S/(λd))
625/// ```
626///
627/// the two spectral functionals of ONE operator. At the profiled σ̂² the
628/// restricted log-likelihood moves by
629///
630/// ```text
631/// 2·evidence = dof·log(rss_pen/(rss_pen − gain)) − occam
632/// ```
633///
634/// which is positive exactly when `gain > tolerance`. A returned fit carries
635/// the candidate set that came CLOSEST to warranting one more level — the
636/// largest [`Self::evidence`] over the next level and, when a capacity budget
637/// forced the finest level to be partial, the candidates that level left behind
638/// at its own radius — and every one of them is at or below its own tolerance.
639///
640/// Two routes produce one: the design carrying the candidate set is built and
641/// solved, in which case every field is EXACT; or the matrix-free gain bracket
642/// and its Hadamard Occam bound already settle the comparison, in which case
643/// `gain` is a certified lower bound and `occam` a certified upper one — both
644/// read in the direction that understates [`Self::evidence`], so a positive
645/// evidence from that route is still a proof. The second route only ever
646/// settles the POSITIVE side: a fit is never minted on it.
647#[derive(Clone, Copy, Debug, PartialEq)]
648pub struct RefinementCertificate {
649 /// Penalized-objective decrease the complete candidate set buys at this
650 /// fit's λ: `rss_pen − rss_pen_refined`, differenced from a design that was
651 /// built and solved — or, on a refusal the screen settled, a certified
652 /// lower bound on it.
653 pub gain: f64,
654 /// `log det(I + X₂ᵀW(I − H)X₂/(λd)) ⩾ 0`, the candidate set's Occam factor,
655 /// or a certified upper bound on it. It is the charge for the set's
656 /// DIMENSION, weighted by how far each of its directions is identified by
657 /// the data: a candidate column with no rows in its support contributes
658 /// exactly zero to it, and to the gain.
659 pub occam: f64,
660 /// Break-even gain `rss_pen·(1 − e^{−occam/dof})` — the objective decrease
661 /// this candidate set's own Occam factor already pays for. DERIVED from the
662 /// set, never chosen: there is no tolerance constant in the cascade.
663 pub tolerance: f64,
664 /// Restricted log-likelihood change from appending the set, at this fit's
665 /// λ. Non-positive exactly when `gain ⩽ tolerance`, and non-positive on
666 /// every certificate a returned fit carries.
667 pub evidence: f64,
668}
669
670impl RefinementCertificate {
671 /// The certificate of a candidate set that does not exist: an empty net
672 /// certifies zero remaining gain against a zero charge.
673 const EXHAUSTED: Self = Self {
674 gain: 0.0,
675 occam: 0.0,
676 tolerance: 0.0,
677 evidence: 0.0,
678 };
679
680 /// Whether one more level is warranted — the marginal likelihood improves.
681 fn warrants_refinement(&self) -> bool {
682 self.evidence > 0.0
683 }
684}
685
686impl std::fmt::Display for RefinementCertificate {
687 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
688 write!(
689 f,
690 "gain {:.6e} against break-even {:.6e} (occam {:.6e}), restricted log-likelihood \
691 {:+.6e}",
692 self.gain, self.tolerance, self.occam, self.evidence
693 )
694 }
695}
696
697/// A structural limit that prevented the cascade from adding the next
698/// resolution level with certified automatic REML. These are never convergence
699/// certificates: if the requested gain tolerance has not passed, they produce
700/// [`ResidualCascadeError::Underresolved`] instead of a fit.
701#[derive(Clone, Copy, Debug, PartialEq, Eq)]
702pub enum RefinementObstruction {
703 /// The representation reached its supported maximum number of levels.
704 LevelCapacity {
705 levels: usize,
706 maximum_levels: usize,
707 },
708 /// Extending the nested net would exceed its supported center capacity.
709 CenterCapacity {
710 centers: usize,
711 maximum_centers: usize,
712 },
713 /// The complete next net would carry more penalized directions than the
714 /// training sample can identify after the polynomial null space is removed.
715 /// Crossing this boundary makes automatic REML flat by rank deficiency; it
716 /// is therefore reported at refinement, before score search.
717 IdentifiabilityCapacity {
718 candidate_columns: usize,
719 candidate_penalized_modes: usize,
720 identifiable_directions: usize,
721 },
722 /// The next net remains data-identified but its λ-independent Schur
723 /// eigenspectrum would exceed the certified automatic-REML memory budget.
724 CertifiedSpectrumCapacity {
725 candidate_columns: usize,
726 certified_spectrum_max: usize,
727 },
728}
729
730impl std::fmt::Display for RefinementObstruction {
731 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
732 match *self {
733 Self::LevelCapacity {
734 levels,
735 maximum_levels,
736 } => write!(
737 f,
738 "level capacity reached ({levels} of {maximum_levels} levels)"
739 ),
740 Self::CenterCapacity {
741 centers,
742 maximum_centers,
743 } => write!(
744 f,
745 "center capacity exceeded ({centers} centers for capacity {maximum_centers})"
746 ),
747 Self::IdentifiabilityCapacity {
748 candidate_columns,
749 candidate_penalized_modes,
750 identifiable_directions,
751 } => write!(
752 f,
753 "next-level identifiability exhausted ({candidate_penalized_modes} penalized \
754 modes from {candidate_columns} columns against {identifiable_directions} \
755 identifiable directions)"
756 ),
757 Self::CertifiedSpectrumCapacity {
758 candidate_columns,
759 certified_spectrum_max,
760 } => write!(
761 f,
762 "next-level certified-spectrum capacity exceeded ({candidate_columns} columns \
763 for capacity {certified_spectrum_max})"
764 ),
765 }
766 }
767}
768
769/// Result of assessing the candidate level immediately finer than a fitted
770/// design. Empty-net exhaustion is distinct from representation capacity:
771/// only the former proves that the remaining gain is exactly zero.
772#[derive(Clone, Copy, Debug, PartialEq)]
773pub enum NextLevelAssessment {
774 /// The nested net produced no new centers, so the next-level gain is zero.
775 EmptyNet,
776 /// The complete candidate level was assessed and has this gain bound.
777 GainBound(f64),
778 /// A structural limit was reached. `gain_bound` is the computed bound when
779 /// the complete candidate could be assessed (identifiability, spectrum, or
780 /// level capacity), and positive infinity only when center capacity
781 /// prevented a complete assessment.
782 CapacityExceeded {
783 obstruction: RefinementObstruction,
784 gain_bound: f64,
785 },
786}
787
788/// Multiresolution residual-cascade design: nested nets, sparse design,
789/// diagonal multilevel prior — everything needed to evaluate the REML
790/// criterion and solve at any λ.
791pub struct ResidualCascadeDesign {
792 core: Arc<Core>,
793}
794
795/// Fitted cascade with factored-by-solve posterior access.
796pub struct ResidualCascadeFit {
797 core: Arc<Core>,
798 /// Number of original training rows. Restored fits retain this scalar even
799 /// though their prediction-only core intentionally drops the row arrays.
800 training_sample_size: std::num::NonZeroUsize,
801 /// Dense-route prediction factor at the fit's λ. When present, pointwise
802 /// variance uses this one Cholesky factor instead of refactoring the same
803 /// precision matrix for every prediction point.
804 predict_chol: Option<Vec<f64>>,
805 /// Exact sparse-direct factor of `A = X'WX + λD` at THIS fit's λ, held when
806 /// the design is past the dense Gram cache. The posterior variance is one
807 /// solve per prediction point; replaying it through this factor is exact and
808 /// `O(nnz(L))`, where the alternative is a fresh PCG per point whose
809 /// backward error the point then inherits.
810 predict_sparse: Option<Arc<SparseExactFactor>>,
811 /// Coefficients: `dim+1` polynomial entries, then level blocks.
812 pub coeff: Vec<f64>,
813 /// Selected (or supplied) log smoothing parameter `log λ = log σ²/τ²`.
814 log_lambda: f64,
815 /// Profiled (or supplied) observation variance σ².
816 pub sigma2: f64,
817 /// Restricted log-likelihood at the fit, up to λ- and data-independent
818 /// additive constants. Exact on every route whose log-determinant is exact
819 /// — dense Cholesky, the certified Schur spectrum, or the sparse direct
820 /// factor — and SLQ-estimated only when the sparse factor's fill-in
821 /// exceeded its budget, which the fit's `logdet_method` reports.
822 pub restricted_loglik: f64,
823 /// Penalized residual quadratic `y'Wy − c'X'Wy`.
824 pub rss_pen: f64,
825 /// Solve/logdet certificates.
826 pub certificate: CascadeCertificate,
827 /// Present when the fit came from the refinement loop.
828 pub refinement: Option<RefinementCertificate>,
829}
830
831/// Opaque work checkpoint carried by an underresolved cascade result.
832///
833/// The current finite-resolution iterate is deliberately private: callers can
834/// inspect its numerical evidence, but cannot turn an uncertified iterate into
835/// a [`ResidualCascadeFit`]. The retained design and coefficients allow a
836/// future refinement backend to resume the work without minting a partial fit.
837pub struct ResidualCascadeCheckpoint {
838 iterate: ResidualCascadeFit,
839}
840
841impl ResidualCascadeCheckpoint {
842 fn new(iterate: ResidualCascadeFit) -> Self {
843 Self { iterate }
844 }
845
846 /// Number of levels already fitted in this checkpoint.
847 pub fn num_levels(&self) -> usize {
848 self.iterate.num_levels()
849 }
850
851 /// Number of centers already fitted in this checkpoint.
852 pub fn num_centers(&self) -> usize {
853 self.iterate.num_centers()
854 }
855
856 /// REML-selected log smoothing parameter of the retained iterate.
857 pub fn log_lambda(&self) -> f64 {
858 self.iterate.log_lambda
859 }
860
861 /// Penalized residual used to scale the requested refinement tolerance.
862 pub fn rss_pen(&self) -> f64 {
863 self.iterate.rss_pen
864 }
865
866 /// Linear-solve evidence attached to the retained iterate.
867 pub fn certificate(&self) -> CascadeCertificate {
868 self.iterate.certificate
869 }
870}
871
872impl std::fmt::Debug for ResidualCascadeCheckpoint {
873 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
874 f.debug_struct("ResidualCascadeCheckpoint")
875 .field("num_levels", &self.num_levels())
876 .field("num_centers", &self.num_centers())
877 .field("log_lambda", &self.log_lambda())
878 .field("rss_pen", &self.rss_pen())
879 .field("certificate", &self.certificate())
880 .finish_non_exhaustive()
881 }
882}
883
884/// Typed failure of the magic-default cascade fit.
885#[derive(Debug)]
886pub enum ResidualCascadeError {
887 /// Invalid input or a numerical failure in design construction/optimization.
888 Computation(String),
889 /// Automatic smoothing-parameter selection needs mathematical outer
890 /// enclosures of the score value and derivatives over λ CELLS, which only
891 /// the λ-independent Schur spectrum provides. Past
892 /// `CERTIFIED_SPECTRUM_MAX` the dense eigendecomposition that spectrum is
893 /// made of exceeds its memory budget, and what remains is a fixed-probe
894 /// stochastic quadrature — a pointwise estimate, not an enclosure. Exact or
895 /// numerically converged residual evidence cannot repair that gap, and
896 /// neither can an exact factorization at a point.
897 RemlScoreProofUnavailable {
898 columns: usize,
899 certified_spectrum_max: usize,
900 },
901 /// Stationary structure could not be isolated even though the score was
902 /// certified flat at its representable value resolution.
903 RemlOptimumResolutionFlat {
904 lo: f64,
905 hi: f64,
906 max_score_gap: f64,
907 score_resolution: f64,
908 },
909 /// The certified 1-D score search could not decompose the λ domain within
910 /// the subdivision budget derived from that domain and the requested
911 /// resolution, so no λ was selected.
912 ///
913 /// Carries the identifiability of the design because that is the cause
914 /// whenever `rank > identifiable`: past the data's own rank the profiled
915 /// residual is an interpolation and the score is flat by rank deficiency
916 /// over whole stretches of λ, where there is no stationary point to isolate
917 /// and no derivative sign to exclude a cell by — so the search subdivides
918 /// every cell it reaches and its cost is exponential in the domain's
919 /// subdivision depth (#2546). `rank <= identifiable` means the budget was
920 /// hit for some other flat-criterion reason and the numbers say so.
921 RemlScoreSearchUndecomposable {
922 columns: usize,
923 rank: usize,
924 identifiable: usize,
925 subdivisions: usize,
926 budget: usize,
927 log_lambda_lo: f64,
928 log_lambda_hi: f64,
929 },
930 /// Rounded candidate ordering is wider than its certified comparison
931 /// resolution, so no unique representative may be fitted.
932 RemlValueOrderingUnresolved {
933 maximum_excess: f64,
934 comparison_resolution: f64,
935 },
936 /// A structural capacity was reached while one more level was still
937 /// warranted by the marginal likelihood. The checkpoint preserves all
938 /// completed work while remaining unusable as a public fit.
939 Underresolved {
940 checkpoint: ResidualCascadeCheckpoint,
941 /// The comparison that says the level is still warranted, computed on a
942 /// design that was built and solved. `None` when no EXACT comparison
943 /// against the candidate set exists — a structural cap stopped the set
944 /// from being formed, or the design carrying it is past the sparse
945 /// factor's fill budget and its log-determinant is a stochastic point
946 /// estimate. Either way the refusal rests on the cap alone, and an
947 /// absent comparison can never certify the discretization spent.
948 evidence: Option<RefinementCertificate>,
949 obstruction: RefinementObstruction,
950 },
951}
952
953impl From<String> for ResidualCascadeError {
954 fn from(reason: String) -> Self {
955 Self::Computation(reason)
956 }
957}
958
959impl std::fmt::Display for ResidualCascadeError {
960 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
961 match self {
962 Self::Computation(reason) => f.write_str(reason),
963 Self::RemlScoreProofUnavailable {
964 columns,
965 certified_spectrum_max,
966 } => write!(
967 f,
968 "residual cascade: automatic REML proof unavailable because the certified \
969 Schur eigendecomposition does not fit its memory budget ({columns} columns \
970 exceed {certified_spectrum_max}); without the lambda-independent spectrum the \
971 score has only a fixed-probe stochastic quadrature, which is a pointwise \
972 estimate and not an interval enclosure, so score signs, KKT roots, and global \
973 candidate ordering are uncertified even when the separate residual quadrature \
974 converges; use an explicitly fixed log lambda"
975 ),
976 Self::RemlOptimumResolutionFlat {
977 lo,
978 hi,
979 max_score_gap,
980 score_resolution,
981 } => write!(
982 f,
983 "residual cascade: REML optimum is value-resolved but not stationary on \
984 [{lo}, {hi}] (maximum score gap {max_score_gap}, score resolution \
985 {score_resolution})"
986 ),
987 Self::RemlScoreSearchUndecomposable {
988 columns,
989 rank,
990 identifiable,
991 subdivisions,
992 budget,
993 log_lambda_lo,
994 log_lambda_hi,
995 } => {
996 write!(
997 f,
998 "residual cascade: the certified REML score search spent {subdivisions} cell \
999 subdivisions on log lambda in [{log_lambda_lo}, {log_lambda_hi}] without \
1000 decomposing it, exceeding the budget {budget} derived from that domain and \
1001 the requested resolution"
1002 )?;
1003 if rank > identifiable {
1004 write!(
1005 f,
1006 "; the design is rank deficient against its data — {rank} penalized Schur \
1007 modes ({columns} columns) against {identifiable} identifiable directions \
1008 — so the profiled residual interpolates and the score is flat by rank \
1009 deficiency, with no stationary point to isolate; refine less, or fix log \
1010 lambda explicitly"
1011 )
1012 } else {
1013 write!(
1014 f,
1015 "; the design is identified ({rank} penalized Schur modes from {columns} \
1016 columns against {identifiable} identifiable directions), so the flat \
1017 criterion has some other cause and the budget is reporting it rather \
1018 than diagnosing it"
1019 )
1020 }
1021 }
1022 Self::RemlValueOrderingUnresolved {
1023 maximum_excess,
1024 comparison_resolution,
1025 } => write!(
1026 f,
1027 "residual cascade: selected REML representative can trail another exact \
1028 candidate by {maximum_excess}, beyond comparison resolution \
1029 {comparison_resolution}"
1030 ),
1031 Self::Underresolved {
1032 checkpoint,
1033 evidence,
1034 obstruction,
1035 } => match evidence {
1036 Some(evidence) => write!(
1037 f,
1038 "residual cascade underresolved after {} levels: one more level still earns \
1039 marginal likelihood — {evidence} — so the cascade's own evidence, not a \
1040 tolerance constant, is what this capacity refuses; {obstruction}",
1041 checkpoint.num_levels(),
1042 ),
1043 None => write!(
1044 f,
1045 "residual cascade underresolved after {} levels: no exact comparison against \
1046 the candidate set exists — it was never formed, or the design carrying it is \
1047 past the exact log-determinant's budget — so nothing can certify the \
1048 discretization spent; {obstruction}",
1049 checkpoint.num_levels(),
1050 ),
1051 },
1052 }
1053 }
1054}
1055
1056impl std::error::Error for ResidualCascadeError {}
1057
1058/// One resolution level's geometry in a persisted snapshot: the data needed to
1059/// rebuild a `Level` (its lookup grid, bumps, and column block) without the
1060/// training rows. Centers are flattened `dim`-major (`dim` floats per center).
1061#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1062pub struct LevelState {
1063 pub h: f64,
1064 pub delta: f64,
1065 pub weight: f64,
1066 pub col_offset: u64,
1067 /// `dim·n_centers` scaled-coordinate floats, center-major.
1068 pub centers: Vec<f64>,
1069}
1070
1071/// Serializable snapshot of a [`ResidualCascadeFit`] (#1032 persistence
1072/// prerequisite). Holds everything `predict` and sample-size-based reporting
1073/// need and no training row values:
1074/// - MEAN: the nested geometry (`dim`/`metric`/box/`sobolev_s` + per-level
1075/// centers/δ/weights/col-offsets) and the root polynomial layer are all that
1076/// `basis_row_scaled`·`coeff` reads;
1077/// - VARIANCE: the factored precision `predict_chol` — the lower Cholesky factor
1078/// `L` of `A = X'WX + λD` at the fit's λ — which the posterior-variance solve
1079/// `x'A⁻¹x` replays against (the training design that originally assembled `A`
1080/// is dropped).
1081///
1082/// `from_state` rebuilds a predict-capable fit whose `Core` carries empty
1083/// training CSR and `predict_chol = Some(L)`; `solve_coeff` then routes the
1084/// variance solve through `L`. The reconstructed fit cannot be re-fit or
1085/// resampled (it has no rows), only predicted from — exactly the persistence
1086/// contract.
1087#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1088pub struct ResidualCascadeState {
1089 /// Original training row count. Required on the wire.
1090 pub training_sample_size: std::num::NonZeroU64,
1091 pub dim: u64,
1092 /// Per-axis metric scaling (length 3; trailing entries are 1 for `dim < 3`).
1093 pub metric: [f64; 3],
1094 pub z_lo: [f64; 3],
1095 pub z_range: [f64; 3],
1096 pub sobolev_s: f64,
1097 pub levels: Vec<LevelState>,
1098 /// Total column count `dim + 1 + Σ centers`.
1099 pub m: u64,
1100 /// `Σ_j log d_j` over penalized columns (kept so restored REML scalars stay
1101 /// comparable across cascade depths).
1102 pub pen_logdet_const: f64,
1103 /// Posterior-mode coefficients (length `m`).
1104 pub coeff: Vec<f64>,
1105 pub log_lambda: f64,
1106 pub sigma2: f64,
1107 pub restricted_loglik: f64,
1108 pub rss_pen: f64,
1109 /// Lower Cholesky factor `L` of `A = X'WX + λD` at the fit's λ, `m × m`
1110 /// row-major — the factored precision the variance solve replays through.
1111 pub predict_chol: Vec<f64>,
1112}
1113
1114/// Forward substitution `L y = b` (lower factor, row-major) into `out`.
1115fn forward_sub_into(l: &[f64], p: usize, b: &[f64], out: &mut [f64]) {
1116 for i in 0..p {
1117 let mut s = b[i];
1118 for t in 0..i {
1119 s -= l[i * p + t] * out[t];
1120 }
1121 out[i] = s / l[i * p + i];
1122 }
1123}
1124
1125/// Back substitution `Lᵀ z = y` (lower factor, row-major) into `out`.
1126fn back_sub_into(l: &[f64], p: usize, y: &[f64], out: &mut [f64]) {
1127 for i in (0..p).rev() {
1128 let mut s = y[i];
1129 for t in i + 1..p {
1130 s -= l[t * p + i] * out[t];
1131 }
1132 out[i] = s / l[i * p + i];
1133 }
1134}
1135
1136/// Coarse-space additive-Schwarz preconditioner for the iterative route
1137/// (issue #1032). `A = X'WX + λD` is preconditioned by the symmetric positive
1138/// definite block-diagonal `P = blockdiag(A_CC, diag(A_FF))`, where the coarse
1139/// index set `C = [0, ncoarse)` is the polynomial layer plus the data-dominated
1140/// (coarsest) levels and `F` the penalty-dominated fine tail — see the
1141/// [`COARSE_DOMINANCE`]/[`COARSE_SPACE_MAX`] docs for why this delivers
1142/// n-independent CG iteration counts where the pure-Jacobi diagonal does not.
1143///
1144/// `solve` applies `P⁻¹` (exact coarse Cholesky solve ⊕ fine Jacobi). For the
1145/// SLQ log-determinant the symmetric factor `R = blockdiag(L_CC, diag√A_FF)`
1146/// with `P = R Rᵀ` is exposed through `apply_r_inv`/`apply_r_inv_t`, and
1147/// `log|P| = log|A_CC| + Σ_F log A_jj`.
1148struct Preconditioner {
1149 /// First fine column; coarse block is the principal `[0, ncoarse)` submatrix.
1150 ncoarse: usize,
1151 /// Lower Cholesky factor of the coarse block `A_CC` (`ncoarse × ncoarse`).
1152 coarse_chol: Vec<f64>,
1153 /// `log|A_CC|` (exact).
1154 coarse_logdet: f64,
1155 /// `1/A_jj` on the fine columns `[ncoarse, m)`.
1156 inv_fine: Vec<f64>,
1157 /// `1/√A_jj` on the fine columns (the `R⁻¹`/`R⁻ᵀ` fine scaling).
1158 inv_sqrt_fine: Vec<f64>,
1159 /// `Σ_F log A_jj` (the fine part of `log|P|`).
1160 fine_logdet: f64,
1161}
1162
1163impl Preconditioner {
1164 /// `out = P⁻¹ r`: exact coarse solve on `[0, ncoarse)`, Jacobi on the tail.
1165 fn solve(&self, r: &[f64], out: &mut [f64]) {
1166 let nc = self.ncoarse;
1167 let zc = chol_solve(&self.coarse_chol, nc, &r[..nc]);
1168 out[..nc].copy_from_slice(&zc);
1169 for (k, o) in out[nc..].iter_mut().enumerate() {
1170 *o = r[nc + k] * self.inv_fine[k];
1171 }
1172 }
1173
1174 /// `out = R⁻ᵀ v` (coarse: `L_CCᵀ` back-solve; fine: `/√A_jj`).
1175 fn apply_r_inv_t(&self, v: &[f64], out: &mut [f64]) {
1176 let nc = self.ncoarse;
1177 back_sub_into(&self.coarse_chol, nc, &v[..nc], &mut out[..nc]);
1178 for (k, o) in out[nc..].iter_mut().enumerate() {
1179 *o = v[nc + k] * self.inv_sqrt_fine[k];
1180 }
1181 }
1182
1183 /// `out = R⁻¹ v` (coarse: `L_CC` forward-solve; fine: `/√A_jj`).
1184 fn apply_r_inv(&self, v: &[f64], out: &mut [f64]) {
1185 let nc = self.ncoarse;
1186 forward_sub_into(&self.coarse_chol, nc, &v[..nc], &mut out[..nc]);
1187 for (k, o) in out[nc..].iter_mut().enumerate() {
1188 *o = v[nc + k] * self.inv_sqrt_fine[k];
1189 }
1190 }
1191
1192 /// `log|P| = log|A_CC| + Σ_F log A_jj`.
1193 fn logdet(&self) -> f64 {
1194 self.coarse_logdet + self.fine_logdet
1195 }
1196}
1197
1198/// One positive-semidefinite eigenmode of the penalty-whitened Schur
1199/// complement. `weight == 1` on the dense exact route; on the large route it
1200/// is the fixed-probe Lanczos quadrature weight. The weights sum to the
1201/// penalized rank, so constants have the same null-recovery limit on both
1202/// routes.
1203#[derive(Clone, Copy)]
1204struct CascadeSpectralMode {
1205 eigenvalue: f64,
1206 weight: f64,
1207}
1208
1209/// One Lanczos run's Jacobi matrix on the penalty-whitened Schur complement
1210/// `B`, plus everything a Golub–Meurant quadrature rule needs from it.
1211///
1212/// See [`Core::schur_lanczos`]. `alpha` and `beta` are `T_m`'s diagonal and
1213/// off-diagonal, with `beta.len() == alpha.len() - 1` whenever `alpha` is
1214/// non-empty, which is the shape [`symmetric_tridiagonal_eigen`] reads.
1215struct SchurLanczos {
1216 /// `alpha_1..alpha_m` — the diagonal of `T_m`.
1217 alpha: Vec<f64>,
1218 /// `beta_1..beta_{m-1}` — the off-diagonal INSIDE `T_m`.
1219 beta: Vec<f64>,
1220 /// `||r_m||`, the `(m+1, m)` entry of the untruncated Jacobi matrix. It is
1221 /// the residual of the Krylov approximation, so `tail == 0` means
1222 /// `K_m(B, start)` is `B`-invariant and the Gauss rule is EXACT.
1223 tail: f64,
1224 /// `max_i |alpha_i|` — a Rayleigh-quotient lower bound on `||B||` over the
1225 /// Krylov space, and the scale the invariance certificate is stated in.
1226 spectral_scale: f64,
1227 /// `||start||^2`, the total mass of the quadrature measure: `Sum_j w_j`.
1228 start_norm_sq: f64,
1229 /// `tail` sits at the run's own roundoff floor, or the run consumed the full
1230 /// penalized rank. Either way `K_m(B, start)` is (numerically) invariant and
1231 /// the Gauss rule reproduces `start' g(B) start` for EVERY analytic `g` and
1232 /// every `lambda` — not approximately, but to the arithmetic's own floor.
1233 invariant: bool,
1234}
1235
1236/// Lambda-independent spectral representation of the profiled REML score.
1237///
1238/// Partition the normal matrix into the polynomial null space `0` and the
1239/// penalized cascade columns `1`. Eliminating the null block gives
1240///
1241/// `|G + lambda D| / |lambda D|_+ = |G00| |I + B/lambda|`,
1242///
1243/// with `B = D^(-1/2) (G11 - G10 G00^(-1) G01) D^(-1/2)`. Consequently every
1244/// determinant mode is an analytic logistic function of `log(lambda)`. The
1245/// representation is built once, rather than re-running a basin-selecting
1246/// lattice of lambda-dependent factorizations.
1247///
1248/// The same elimination puts the PROFILED RESIDUAL in the same form wherever
1249/// the eigenbasis survives the construction — see [`CascadeResidualForm`].
1250struct CascadeRemlProfile<'a> {
1251 core: &'a Core,
1252 null_logdet: f64,
1253 modes: Vec<CascadeSpectralMode>,
1254 residual: CascadeResidualForm,
1255}
1256
1257/// The lambda-independent spectral form of the PROFILED RESIDUAL
1258/// `R(lambda) = y'Wy - b'A(lambda)^(-1) b`.
1259///
1260/// The same null-space elimination and penalty whitening that turns the
1261/// determinant into a mode sum does the same thing to the residual. In the
1262/// Schur eigenbasis `B = V Theta V'`, `A(lambda)` acts as `Theta + lambda I`,
1263/// so with
1264///
1265/// `p = V' D^(-1/2) (b1 - G10 G00^(-1) b0)` and
1266/// `S_k(lambda) = sum_i p_i^2 / (theta_i + lambda)^k`,
1267///
1268/// `R = anchor_energy - S1`, and the three quadratic forms the score jet needs
1269/// are the next three moments of that same sum:
1270///
1271/// `c'Dc = S2`, `(Dc)'A^(-1)(Dc) = S3`, `u'Du = S4` for `u = A^(-1) D c`.
1272///
1273/// `anchor_energy = y'Wy - b0' G00^(-1) b0` is the part of the residual no
1274/// lambda can move.
1275struct CascadeResidualSpectrum {
1276 /// `theta_i`, the Schur eigenvalue of mode `i` — the SAME numbers the
1277 /// determinant modes carry.
1278 eigenvalue: Vec<f64>,
1279 /// Every mode's penalty scale, which is exactly `1` because the Schur
1280 /// complement was whitened by `D^(-1/2)` before it was decomposed. It is
1281 /// materialized because [`AffineRemlProfile`] takes the pencil
1282 /// `h_i = g_i + lambda s_i` as two parallel slices.
1283 penalty: Vec<f64>,
1284 /// `p_i^2`, the squared projection of the null-eliminated, penalty-whitened
1285 /// right-hand side onto mode `i`.
1286 projected_square: Vec<f64>,
1287 /// `y'Wy - b0' G00^(-1) b0`, as the single-response slice
1288 /// [`AffineRemlProfile`] expects.
1289 anchor_energy: [f64; 1],
1290}
1291
1292impl CascadeResidualSpectrum {
1293 /// `(R, S2, S3, S4)` at `lambda`. Every `theta_i` is nonnegative by
1294 /// construction and `lambda` is strictly positive, so every denominator is
1295 /// strictly positive; the caller still rejects a nonpositive `R`, which is a
1296 /// statement about the DATA rather than about this arithmetic.
1297 fn moments(&self, lambda: f64) -> (f64, f64, f64, f64) {
1298 let [s1, s2, s3, s4] = self.moment_sums(lambda);
1299 (self.anchor_energy[0] - s1, s2, s3, s4)
1300 }
1301
1302 /// `[S_1, S_2, S_3, S_4]` at `lambda`, before the anchor subtraction that
1303 /// turns `S_1` into the profiled residual.
1304 ///
1305 /// Exposed separately because `S_1` must NOT be recovered from `R` by
1306 /// undoing that subtraction. At the top of the search domain
1307 /// `lambda ~ theta_max/sqrt(eps)`, so `S_1 ~ ||beta||^2 sqrt(eps)/theta_max`
1308 /// and `anchor - S_1` loses about eight digits of `S_1`; recovering it would
1309 /// leave a `sqrt(eps)` relative error, which is exactly the resolution the
1310 /// nested-rule certificate is charged at. Every `S_k` is a sum of strictly
1311 /// positive terms, so read directly it carries no cancellation at all.
1312 fn moment_sums(&self, lambda: f64) -> [f64; 4] {
1313 let mut sums = [0.0_f64; 4];
1314 for (&theta, &projected_square) in self.eigenvalue.iter().zip(&self.projected_square) {
1315 let h = theta + lambda;
1316 let mut term = projected_square;
1317 for sum in &mut sums {
1318 term /= h;
1319 *sum += term;
1320 }
1321 }
1322 sums
1323 }
1324}
1325
1326/// Numerical evidence for the profiled-residual point quadrature past the dense
1327/// cap.
1328///
1329/// This is deliberately not a REML proof object. Krylov invariance makes the
1330/// residual rule exact, but a geometric tail estimate is only point-evaluation
1331/// evidence. Neither case encloses the independent stochastic determinant, so
1332/// [`ResidualCascadeDesign::fit_reml`] refuses the entire iterative route before
1333/// this evidence can participate in fit certification.
1334#[derive(Clone, Copy, Debug)]
1335struct ResidualQuadratureEvidence {
1336 /// Nodes in the rule this evidence is about.
1337 steps: usize,
1338 /// Nodes in the nested coarser rule it was charged against; `0` when the
1339 /// Krylov space closed and no comparison was needed.
1340 coarse_steps: usize,
1341 /// Penalized Schur rank. The reachable Krylov dimension may be smaller,
1342 /// because `rank(B) <= n - nullity`.
1343 rank: usize,
1344 /// Step budget the growth loop was allowed, before that ceiling.
1345 budget: usize,
1346 /// `||r_m|| / max_i |alpha_i|`: the Krylov residual against the operator
1347 /// scale. At roundoff, `K_m(B, beta)` is invariant and the rule is exact.
1348 relative_tail: f64,
1349 /// Geometric estimate of the rule's remaining relative error over
1350 /// `S_1..S_4` and the whole lambda domain, from three nested rules.
1351 tail_estimate: f64,
1352 /// The resolution `tail_estimate` had to reach.
1353 target: f64,
1354 /// Whether the Krylov space closed, making the residual rule exact.
1355 invariant: bool,
1356 /// Whether the rule may be used for the diagnostic point criterion. This is
1357 /// never sufficient to authorize automatic REML.
1358 accepted_for_point_evaluation: bool,
1359 /// `|sum_j w_j / ||beta||^2 - 1|` — the free mass self-check of a Gauss rule.
1360 mass_defect: f64,
1361 /// Fraction of `||beta||^2` that landed on roundoff-level nodes and was
1362 /// dropped as null-space mass.
1363 dropped_mass_fraction: f64,
1364}
1365
1366/// Extrapolated estimate of the profiled-residual quadrature's REMAINING relative
1367/// error, from three NESTED Gauss rules for the same measure, over `S_1..S_4` and
1368/// the whole `log lambda` domain.
1369///
1370/// An estimate, stated as one: it is a rate fitted to two observed gaps and
1371/// summed, not an inequality. It can authorize only the diagnostic point
1372/// criterion represented by [`ResidualQuadratureEvidence`], never automatic
1373/// REML: the independent stochastic determinant still lacks exact-real
1374/// value/derivative enclosures. The exact-dense comparison in
1375/// `the_quadrature_tail_estimate_bounds_the_error_against_the_exact_spectrum_2503`
1376/// charges the estimate against its intended numerical use at every production
1377/// budget.
1378///
1379/// WHY NOT TWO RULES. `(theta + lambda)^-k` has positive even derivatives, so the
1380/// standard Gauss error representation is one-signed and every rule
1381/// UNDER-estimates its integral: `G_j <= S_k` for every `j`. Hence
1382/// `G_m - G_{m/2} <= S_k - G_{m/2}` — the gap between two nested rules is an exact
1383/// LOWER bound on the coarser one's error. That direction is rigorous and it is
1384/// also not what a certificate needs: a small gap says the two errors are nearly
1385/// EQUAL, which is equally consistent with both being small and with both being
1386/// stuck. A two-rule agreement test is blind to stagnation by construction.
1387///
1388/// WHAT THREE RULES BUY. All the gaps are of one sign, so the remaining error is
1389/// the TAIL of the gap series, and three rules are enough to fit the decay that
1390/// tail obeys. The decay is not assumed: the Gauss-rule error for a resolvent
1391/// falls like `((sqrt(kappa) - 1)/(sqrt(kappa) + 1))^{2m}` — geometric in the node
1392/// count — so with `err(j) = C x^{4j/m}` sampled at `j = m/4, m/2, m` and
1393/// `x = r^{m/4}`,
1394///
1395/// ```text
1396/// g1 = G_{m/2} - G_{m/4} = C(x - x^2) g2 = G_m - G_{m/2} = C(x^2 - x^4)
1397/// rho = |g2|/|g1| = x(1 + x) tail = C x^4 = |g2| * x^2/(1 - x^2)
1398/// ```
1399///
1400/// so `x = (sqrt(1 + 4 rho) - 1)/2` recovers the rate from the two observed gaps
1401/// and the tail follows in closed form. The direction of the residual
1402/// approximation is the safe one: the effective rate IMPROVES with `m` once the
1403/// extreme Ritz values converge, so a rate fitted on `[m/4, m/2]` over-states the
1404/// tail beyond `m`.
1405///
1406/// `x >= 1` — no contraction — has no tail to extrapolate, and there the last
1407/// OBSERVED movement is reported instead. That decides correctly at both ends
1408/// without a special case: a rule that is genuinely stuck is still moving by more
1409/// than the target and is refused, while a rule that has already converged, whose
1410/// two gaps sit near the arithmetic floor and whose RATIO is therefore pure noise,
1411/// is not refused for the noise in a ratio. This is the stagnation case a two-rule
1412/// agreement test cannot see at all.
1413///
1414/// A gap already at the arithmetic's own floor (`eps * m * |G_m|`, the rounding of
1415/// the sum being compared) is converged, not stagnant, and contributes nothing.
1416///
1417/// WHICH QUANTITIES. The four moments, not the profiled residual `R = anchor -
1418/// S_1`, and `S_1` is read directly rather than recovered from `R` — see
1419/// [`CascadeResidualSpectrum::moment_sums`]. An error of `sqrt(eps)` relative on
1420/// `S_1` is an error of at most `sqrt(eps) * anchor` absolute on `R`, which is the
1421/// statement worth making about a difference that can cancel to nine digits.
1422///
1423/// GRID. Each `S_k` is a positive mixture of `(theta + lambda)^-k`, so
1424/// `|d log S_k / d log lambda| = k * (weighted mean of lambda/(theta+lambda)) <= k
1425/// <= 4`: every moment moves by at most a factor `e^4` per e-fold of `lambda`.
1426/// Four samples per e-fold resolve that, and the endpoints are always sampled.
1427fn residual_quadrature_tail_estimate(
1428 fine: &CascadeResidualSpectrum,
1429 mid: &CascadeResidualSpectrum,
1430 coarse: &CascadeResidualSpectrum,
1431 steps: usize,
1432 (lo, hi): (f64, f64),
1433) -> Result<f64, String> {
1434 if !(lo.is_finite() && hi.is_finite() && lo < hi) {
1435 return Err(format!(
1436 "residual cascade: invalid quadrature certification domain [{lo}, {hi}]"
1437 ));
1438 }
1439 let cells = (4.0 * (hi - lo)).ceil().max(8.0);
1440 if !cells.is_finite() {
1441 return Err(format!(
1442 "residual cascade: unbounded quadrature certification domain [{lo}, {hi}]"
1443 ));
1444 }
1445 let cells = cells as usize;
1446 let rounding = f64::EPSILON * steps.max(1) as f64;
1447 let mut worst = 0.0_f64;
1448 let mut sampled = 0usize;
1449 for step in 0..=cells {
1450 let log_lambda = lo + (hi - lo) * step as f64 / cells as f64;
1451 let lambda = log_lambda.exp();
1452 if !(lambda.is_finite() && lambda > 0.0) {
1453 continue;
1454 }
1455 sampled += 1;
1456 let fine_moments = fine.moment_sums(lambda);
1457 let mid_moments = mid.moment_sums(lambda);
1458 let coarse_moments = coarse.moment_sums(lambda);
1459 for index in 0..4 {
1460 let (value, previous, earlier) =
1461 (fine_moments[index], mid_moments[index], coarse_moments[index]);
1462 if !(value.is_finite() && previous.is_finite() && earlier.is_finite()) {
1463 return Err(format!(
1464 "residual cascade: non-finite nested-rule moment S{} at log lambda \
1465 {log_lambda} ({value}, {previous}, {earlier})",
1466 index + 1
1467 ));
1468 }
1469 let magnitude = value.abs();
1470 if !(magnitude > 0.0) {
1471 // All three rules report an identically zero moment: nothing is
1472 // moving and there is nothing to extrapolate.
1473 if previous != 0.0 || earlier != 0.0 {
1474 return Ok(f64::INFINITY);
1475 }
1476 continue;
1477 }
1478 let recent = (value - previous).abs();
1479 if recent <= rounding * magnitude {
1480 // The rule stopped moving at the arithmetic's own floor.
1481 continue;
1482 }
1483 let older = (previous - earlier).abs();
1484 let ratio = recent / older;
1485 // `x` solves `x^2 + x = ratio`: the per-quarter-budget decay rate the
1486 // two observed gaps imply.
1487 let rate = 0.5 * ((1.0 + 4.0 * ratio).sqrt() - 1.0);
1488 worst = worst.max(if rate < 1.0 {
1489 let square = rate * rate;
1490 recent * square / ((1.0 - square) * magnitude)
1491 } else {
1492 // NOT CONTRACTING: the geometric model does not apply and there
1493 // is nothing to extrapolate, so what is reported is the last
1494 // OBSERVED movement and no more. That is the honest quantity, and
1495 // it decides correctly at both ends without a special case. A rule
1496 // that is genuinely stuck is still moving by more than the target,
1497 // so this refuses it; a rule that has converged and whose two gaps
1498 // are both near the arithmetic floor — where their RATIO is pure
1499 // noise and routinely exceeds one — is not refused for the noise
1500 // in a ratio when its movement is already inside the target.
1501 recent / magnitude
1502 });
1503 }
1504 }
1505 if sampled == 0 {
1506 // No representable `lambda` on the declared domain, so the rules were
1507 // never compared. Agreement on zero samples is not agreement.
1508 return Err(format!(
1509 "residual cascade: the quadrature certification domain [{lo}, {hi}] contains no \
1510 representable lambda, so no nested-rule comparison was made"
1511 ));
1512 }
1513 Ok(worst)
1514}
1515
1516/// One Golub-Meurant Gauss rule read off a Lanczos run's Jacobi matrix, with the
1517/// two self-checks its construction hands over for free.
1518struct ResidualGaussRule {
1519 spectrum: CascadeResidualSpectrum,
1520 /// `|sum_j w_j / ||beta||^2 - 1|`.
1521 mass_defect: f64,
1522 /// Fraction of `||beta||^2` dropped by the two node floors.
1523 dropped_mass_fraction: f64,
1524}
1525
1526/// Where the profiled residual and its three log-lambda derivatives come from.
1527///
1528/// Both forms describe the SAME function of lambda; they differ only in what
1529/// the design's Schur decomposition left behind. Under the dense sizing cap the
1530/// determinant spectrum comes from a full eigendecomposition, so the eigen-BASIS
1531/// exists and the residual is a closed-form sum over exactly the modes the
1532/// determinant already uses — no linear solve at any lambda, and the whole score
1533/// is O(rank) per trial after the one decomposition.
1534///
1535/// Past the cap the determinant is a fixed-probe Hutchinson quadrature whose
1536/// nodes carry no basis to project the right-hand side onto — but the RESIDUAL
1537/// does not need one. It is a single quadratic form of a single known vector, so
1538/// one Lanczos run seeded with that vector gives the Golub–Meurant Gauss rule
1539/// for `S_k(lambda) = beta'(B + lambda I)^-k beta`, in the same node/weight
1540/// shape the dense route stores. Krylov invariance is exact residual evidence;
1541/// a contracting nested-rule tail can also support the explicitly diagnostic point
1542/// criterion, but is never promoted to an exact-real fit certificate. If neither
1543/// condition holds, construction refuses instead of retrying every point with
1544/// the ill-conditioned solve that caused #2503.
1545enum CascadeResidualForm {
1546 /// Exact eigenbasis projection under the dense cap. Interval-extendable via
1547 /// [`CascadeRemlProfile::affine_view`], because the determinant modes on
1548 /// this route are the SAME unit-weight modes.
1549 Spectral(CascadeResidualSpectrum),
1550 /// The Golub–Meurant point quadrature past the dense cap, admitted only for
1551 /// diagnostic score evaluation after its own numerical evidence passes.
1552 ///
1553 /// Never affine-viewable: this route's DETERMINANT modes are Hutchinson Ritz
1554 /// nodes with fractional weights, unrelated to the residual run's nodes.
1555 Quadrature(CascadeResidualSpectrum),
1556}
1557
1558impl CascadeResidualForm {
1559 /// The lambda-independent spectral form, when this route has one.
1560 fn spectrum(&self) -> &CascadeResidualSpectrum {
1561 match self {
1562 Self::Spectral(spectrum) | Self::Quadrature(spectrum) => spectrum,
1563 }
1564 }
1565}
1566
1567struct CascadeScoreEvaluation {
1568 jet: ScoreJet,
1569 /// `log|G + lambda D| - rank(D) log(lambda) - log|D|_+`.
1570 normalized_logdet: f64,
1571}
1572
1573/// The determinant half of the score at one `log lambda`.
1574struct DeterminantParts {
1575 /// `log|G + lambda D| - rank(D) log(lambda) - log|D|_+`.
1576 normalized_logdet: f64,
1577 /// `d/d log lambda`: `-sum_i w_i t_i` with `t_i = theta_i/(theta_i+lambda)`.
1578 /// Nonpositive, and INCREASING in `log lambda` because every `t_i` falls.
1579 first: f64,
1580 /// `d^2/d log lambda^2`: `sum_i w_i t_i (1-t_i)`. Nonnegative.
1581 second: f64,
1582}
1583
1584/// Machine-resolved bounded domain containing every determinant transition
1585/// `lambda ~ theta`. Outside it, every positive mode is within `sqrt(epsilon)` of
1586/// its analytic small- or large-lambda limit. The bounds scale with the actual
1587/// design spectrum rather than a fixed log-lambda window.
1588///
1589/// A free function rather than a profile method because the profiled RESIDUAL's
1590/// point-evaluation evidence has to be charged over exactly this interval, and
1591/// the residual is built while the profile is being assembled — the interval is
1592/// a function of the determinant modes alone, so it is available at that point.
1593fn certified_log_lambda_domain_from_modes(
1594 modes: &[CascadeSpectralMode],
1595) -> Result<(f64, f64), String> {
1596 let mut smallest = f64::INFINITY;
1597 let mut largest = 0.0_f64;
1598 for mode in modes {
1599 if mode.weight > 0.0 && mode.eigenvalue > 0.0 {
1600 smallest = smallest.min(mode.eigenvalue);
1601 largest = largest.max(mode.eigenvalue);
1602 }
1603 }
1604 if !(smallest.is_finite() && smallest > 0.0 && largest.is_finite() && largest > 0.0) {
1605 return Err(
1606 "residual cascade: the data identify no positive penalized Schur mode; log lambda is not estimable"
1607 .into(),
1608 );
1609 }
1610 let log_relative_resolution = certified_ln_positive(f64::EPSILON.sqrt()).ok_or_else(|| {
1611 "residual cascade: could not enclose the spectral-domain resolution".to_string()
1612 })?;
1613 let log_smallest = certified_ln_positive(smallest).ok_or_else(|| {
1614 "residual cascade: could not enclose the smallest spectral transition".to_string()
1615 })?;
1616 let log_largest = certified_ln_positive(largest).ok_or_else(|| {
1617 "residual cascade: could not enclose the largest spectral transition".to_string()
1618 })?;
1619 let minimum_log = certified_ln_positive(f64::MIN_POSITIVE).ok_or_else(|| {
1620 "residual cascade: could not enclose the minimum-normal logarithm".to_string()
1621 })?;
1622 let maximum_log = certified_ln_positive(f64::MAX).ok_or_else(|| {
1623 "residual cascade: could not enclose the maximum-finite logarithm".to_string()
1624 })?;
1625 let lo = log_smallest
1626 .add(log_relative_resolution)
1627 .lo
1628 .max(minimum_log.lo);
1629 let hi = log_largest
1630 .sub(log_relative_resolution)
1631 .hi
1632 .min(maximum_log.lo);
1633 if !(lo.is_finite() && hi.is_finite() && lo < hi) {
1634 return Err(format!(
1635 "residual cascade: invalid spectrum-derived log-lambda domain [{lo}, {hi}]"
1636 ));
1637 }
1638 Ok((lo, hi))
1639}
1640
1641impl CascadeRemlProfile<'_> {
1642 fn log_lambda_domain(&self) -> Result<(f64, f64), String> {
1643 certified_log_lambda_domain_from_modes(&self.modes)
1644 }
1645
1646 /// This profile as the affine spectral REML score it is, when the residual
1647 /// is spectral.
1648 ///
1649 /// With `h_i(lambda) = theta_i + lambda` the cascade's dense-route score is
1650 /// term for term an [`AffineRemlProfile`]: `sum log h_i - rank log lambda`
1651 /// is the normalized log-determinant, `R = anchor - sum p_i^2/h_i` is the
1652 /// profiled residual, and there is one response. The point of saying so is
1653 /// the ENCLOSURE. `AffineRemlProfile::enclose` evaluates the mode kernels on
1654 /// an interval lambda, so it is a genuine interval extension whose width
1655 /// collapses with the cell. The former endpoint-jet/global-Lipschitz
1656 /// enclosure did not collapse fast enough in saturated tails and has been
1657 /// removed.
1658 ///
1659 /// [`CascadeResidualForm::Quadrature`] is deliberately excluded even though
1660 /// it carries the same spectral shape. `AffineRemlProfile` computes the
1661 /// determinant from the modes it is handed — `sum_i log h_i - rank log
1662 /// lambda` — and past the dense cap the determinant is a Hutchinson
1663 /// quadrature over 24 independent probes with fractional weights, which is
1664 /// neither the residual run's node set nor unit-weight. Handing it the
1665 /// residual nodes would silently substitute one determinant for another.
1666 fn affine_view(&self) -> Result<Option<AffineRemlProfile<'_>>, String> {
1667 let CascadeResidualForm::Spectral(spectrum) = &self.residual else {
1668 return Ok(None);
1669 };
1670 let core = self.core;
1671 AffineRemlProfile::new(
1672 &spectrum.eigenvalue,
1673 &spectrum.penalty,
1674 &spectrum.projected_square,
1675 &spectrum.anchor_energy,
1676 (core.y.len() - core.nullity()) as f64,
1677 // Every whitened mode carries penalty scale 1, and the certified-null
1678 // modes were already dropped when the spectrum was built (see
1679 // `dense_cascade_spectrum`), so the penalized determinant rank is the
1680 // number of POSITIVE Schur modes — which is what makes this
1681 // enclosure's width track the score's own and not the arithmetic's
1682 // failure to cancel `Z·log λ` against `rank·log λ`.
1683 spectrum.penalty.len(),
1684 self.null_logdet,
1685 )
1686 .map(Some)
1687 .map_err(|error| format!("residual cascade: affine spectral profile rejected: {error}"))
1688 }
1689
1690 /// The normalized log-determinant and its first two `log lambda`
1691 /// derivatives.
1692 ///
1693 /// `O(modes)` and free of linear algebra on every route.
1694 fn determinant_parts(&self, log_lambda: f64, lambda: f64) -> DeterminantParts {
1695 let mut parts = DeterminantParts {
1696 normalized_logdet: self.null_logdet,
1697 first: 0.0,
1698 second: 0.0,
1699 };
1700 for mode in &self.modes {
1701 let theta = mode.eigenvalue;
1702 let weight = mode.weight;
1703 if theta == 0.0 || weight == 0.0 {
1704 continue;
1705 }
1706 // Stable forms for log(1 + theta/lambda) and
1707 // t=theta/(lambda+theta), including widely separated scales.
1708 let log_theta = theta.ln();
1709 parts.normalized_logdet += weight
1710 * if log_theta > log_lambda {
1711 (log_theta - log_lambda) + (log_lambda - log_theta).exp().ln_1p()
1712 } else {
1713 (log_theta - log_lambda).exp().ln_1p()
1714 };
1715 let t = if theta > lambda {
1716 1.0 / (1.0 + lambda / theta)
1717 } else {
1718 theta / (lambda + theta)
1719 };
1720 parts.first -= weight * t;
1721 parts.second += weight * t * (1.0 - t);
1722 }
1723 parts
1724 }
1725
1726 fn evaluate(&self, log_lambda: f64) -> Result<CascadeScoreEvaluation, String> {
1727 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
1728 .map_err(|error| format!("residual cascade: {error}"))?;
1729
1730 let core = self.core;
1731 // R = y'Wy - b'A^-1b. With A' = lambda D,
1732 // R' = lambda c'Dc and
1733 // R'' = lambda c'Dc - 2 lambda^2 (Dc)'A^-1(Dc).
1734 // Both admitted forms are lambda-independent spectral sums. The
1735 // iterative constructor requires either an invariant Krylov space or a
1736 // sufficiently small nested-rule tail estimate instead of reviving the
1737 // ill-conditioned per-lambda solved fallback from #2503.
1738 let (rss, penalty_energy, inverse_penalty_energy, _third_energy) =
1739 self.residual.spectrum().moments(lambda);
1740 if !(rss.is_finite() && rss > 0.0) {
1741 return Err(format!(
1742 "residual cascade: degenerate penalized residual {rss}"
1743 ));
1744 }
1745 let rss_d1 = lambda * penalty_energy;
1746 let lambda2 = lambda * lambda;
1747 let rss_d2 = rss_d1 - 2.0 * lambda2 * inverse_penalty_energy;
1748 let DeterminantParts {
1749 normalized_logdet,
1750 first: determinant_d1,
1751 second: determinant_d2,
1752 } = self.determinant_parts(log_lambda, lambda);
1753
1754 let dof = (core.y.len() - core.nullity()) as f64;
1755 let rss_log_d1 = rss_d1 / rss;
1756 let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
1757 let jet = ScoreJet {
1758 value: -0.5 * (normalized_logdet + dof * (rss / dof).ln()),
1759 derivative: -0.5 * (determinant_d1 + dof * rss_log_d1),
1760 curvature: -0.5 * (determinant_d2 + dof * rss_log_d2),
1761 // The cascade criterion API does not consume a third derivative;
1762 // certified dense-route search evaluates the affine interval
1763 // extension directly, so no endpoint third derivative is needed.
1764 third: 0.0,
1765 };
1766 if !(jet.value.is_finite() && jet.derivative.is_finite() && jet.curvature.is_finite()) {
1767 return Err(format!(
1768 "residual cascade: non-finite REML jet at log lambda {log_lambda}: value {}, derivative {}, curvature {}",
1769 jet.value, jet.derivative, jet.curvature
1770 ));
1771 }
1772 Ok(CascadeScoreEvaluation {
1773 jet,
1774 normalized_logdet,
1775 })
1776 }
1777
1778}
1779
1780/// Read `(row, col)` of a symmetric `m × m` Gram held as its row-major UPPER
1781/// triangle — the encoding of both `Core::dense_gram` and
1782/// [`Core::assemble_upper_gram`].
1783#[inline]
1784fn upper_gram_entry(gram: &[f64], m: usize, row: usize, col: usize) -> f64 {
1785 let (i, j) = if row <= col { (row, col) } else { (col, row) };
1786 gram[i * m + j]
1787}
1788
1789impl Core {
1790 #[inline]
1791 fn dense_gram_entry(&self, row: usize, col: usize) -> Option<f64> {
1792 let gram = self.dense_gram.as_ref()?;
1793 Some(upper_gram_entry(gram, self.m, row, col))
1794 }
1795
1796 /// The two Gram blocks the certified Schur complement is made of, and the
1797 /// ONLY two it needs: the penalized block `G₁₁` as a row-major PACKED upper
1798 /// triangle (`rank(rank+1)/2` entries) and the cross block `G₁₀ᵀ = G₀₁` as a
1799 /// dense `q × rank` array, `q = nullity() ≤ 4`.
1800 ///
1801 /// The `m × m` upper `X'WX` this used to assemble in full is never formed.
1802 /// That matters for one reason and it is not tidiness: the width at which a
1803 /// design can be certified is DERIVED from the route's live memory
1804 /// ([`CERTIFIED_SPECTRUM_MAX`]), so an `m²` transient that the mathematics
1805 /// does not consume is a `1/√2` factor on every design this crate can prove
1806 /// a smoothing parameter for. The null block `G₀₀` is not returned either —
1807 /// [`Self::null_gram_factor`] already builds and factors it directly.
1808 ///
1809 /// The cache is read when the design carries one, so a narrow design's Schur
1810 /// entries stay the exact `f64` they were; past the cache the same entries
1811 /// are accumulated from the CSR rows in one `O(nnz·q)` pass, in the same
1812 /// row-major order the cache was built in.
1813 fn assemble_schur_gram_blocks(&self) -> (Vec<f64>, Vec<f64>) {
1814 let m = self.m;
1815 let q = self.nullity();
1816 let rank = m - q;
1817 let mut penalized = vec![0.0_f64; packed_upper_len(rank)];
1818 let mut cross = vec![0.0_f64; q * rank];
1819 if let Some(gram) = &self.dense_gram {
1820 for k in 0..q {
1821 for j in 0..rank {
1822 cross[k * rank + j] = upper_gram_entry(gram, m, k, q + j);
1823 }
1824 }
1825 for i in 0..rank {
1826 let base = packed_upper_row_offset(rank, i);
1827 for j in i..rank {
1828 penalized[base + (j - i)] = upper_gram_entry(gram, m, q + i, q + j);
1829 }
1830 }
1831 return (penalized, cross);
1832 }
1833 for row in 0..self.w.len() {
1834 let lo = self.row_ptr[row];
1835 let hi = self.row_ptr[row + 1];
1836 for ea in lo..hi {
1837 let ca = self.col_idx[ea] as usize;
1838 let weighted = self.w[row] * self.vals[ea];
1839 // Columns are sorted within a row, so `eb >= ea` is exactly the
1840 // upper triangle and the two blocks are told apart by `ca`.
1841 if ca < q {
1842 for eb in ea..hi {
1843 let cb = self.col_idx[eb] as usize;
1844 if cb >= q {
1845 cross[ca * rank + (cb - q)] += weighted * self.vals[eb];
1846 }
1847 }
1848 } else {
1849 let i = ca - q;
1850 let base = packed_upper_row_offset(rank, i);
1851 for eb in ea..hi {
1852 let j = self.col_idx[eb] as usize - q;
1853 penalized[base + (j - i)] += weighted * self.vals[eb];
1854 }
1855 }
1856 }
1857 }
1858 (penalized, cross)
1859 }
1860
1861 /// Whether the certified spectral proof can be built on this core.
1862 ///
1863 /// `false` is the one honest refusal left — the design is wider than
1864 /// [`CERTIFIED_SPECTRUM_MAX`], so the Schur complement the proof is made of
1865 /// does not fit its memory budget, or the core was rebuilt from a persisted
1866 /// state and has no design left to assemble one from. Crossing
1867 /// [`DENSE_GRAM_MAX`] alone does not forfeit the proof.
1868 fn certified_spectrum_available(&self) -> bool {
1869 self.m <= CERTIFIED_SPECTRUM_MAX && !self.w.is_empty()
1870 }
1871
1872 /// Factor the unpenalized polynomial Gram block. It is tiny (`dim+1 <= 4`)
1873 /// on every route and is the exact anchor for the Schur complement.
1874 fn null_gram_factor(&self) -> Result<(Vec<f64>, f64), String> {
1875 let q = self.nullity();
1876 let mut gram = vec![0.0; q * q];
1877 if self.dense_gram.is_some() {
1878 for i in 0..q {
1879 for j in i..q {
1880 let value = self.dense_gram_entry(i, j).expect("dense Gram exists");
1881 gram[i * q + j] = value;
1882 gram[j * q + i] = value;
1883 }
1884 }
1885 } else {
1886 for row in 0..self.w.len() {
1887 let lo = self.row_ptr[row];
1888 let hi = self.row_ptr[row + 1];
1889 for ea in lo..hi {
1890 let ca = self.col_idx[ea] as usize;
1891 if ca >= q {
1892 break;
1893 }
1894 let weighted = self.w[row] * self.vals[ea];
1895 for eb in ea..hi {
1896 let cb = self.col_idx[eb] as usize;
1897 if cb >= q {
1898 break;
1899 }
1900 gram[ca * q + cb] += weighted * self.vals[eb];
1901 }
1902 }
1903 }
1904 for i in 0..q {
1905 for j in i + 1..q {
1906 gram[j * q + i] = gram[i * q + j];
1907 }
1908 }
1909 }
1910 let logdet = cholesky_logdet(&mut gram, q).map_err(|error| {
1911 format!("residual cascade: polynomial null-space factorization failed: {error}")
1912 })?;
1913 Ok((gram, logdet))
1914 }
1915
1916 /// Apply the penalty-whitened Schur complement `B` without materializing
1917 /// the data Gram. Scratch buffers are supplied by the Lanczos caller so
1918 /// each iteration remains allocation-free apart from the tiny null solve.
1919 fn schur_whitened_matvec(
1920 &self,
1921 null_chol: &[f64],
1922 input: &[f64],
1923 output: &mut [f64],
1924 full: &mut [f64],
1925 gram_full: &mut [f64],
1926 projected_null: &mut [f64],
1927 ) {
1928 let q = self.nullity();
1929 full.fill(0.0);
1930 for (i, &value) in input.iter().enumerate() {
1931 full[q + i] = value / self.pen_diag[q + i].sqrt();
1932 }
1933 self.matvec(0.0, full, gram_full);
1934 let null_coeff = chol_solve(null_chol, q, &gram_full[..q]);
1935 full.fill(0.0);
1936 full[..q].copy_from_slice(&null_coeff);
1937 self.matvec(0.0, full, projected_null);
1938 for i in 0..output.len() {
1939 output[i] = (gram_full[q + i] - projected_null[q + i]) / self.pen_diag[q + i].sqrt();
1940 }
1941 }
1942
1943 /// One full-reorthogonalization Lanczos run on the penalty-whitened Schur
1944 /// complement `B`, from a caller-supplied start vector.
1945 ///
1946 /// The determinant sweep (a Rademacher probe per run) and the profiled
1947 /// residual (one run seeded with the whitened right-hand side) are the same
1948 /// Krylov process on the same operator, and they read the same Jacobi matrix
1949 /// afterwards. Sharing ONE implementation is not tidiness: an accuracy gate
1950 /// on the residual quadrature that measured a copy of this recurrence would
1951 /// certify a routine that does not ship.
1952 ///
1953 /// The returned `T_m` is the Jacobi matrix of the Gauss quadrature rule for
1954 /// the measure `mu` that `start` induces on the spectrum of `B`, so
1955 /// `start' g(B) start ≈ ||start||^2 · e_1' g(T_m) e_1` for every analytic
1956 /// `g` — the Golub–Meurant rule. Two things decide whether that `≈` is an
1957 /// `=`, and both ride on the returned [`SchurLanczos`]: `tail`, the `(m+1, m)`
1958 /// entry the truncation dropped, and `dimension_ceiling`, the caller's bound
1959 /// on how many dimensions `K(B, start)` can have at all — reaching it leaves
1960 /// the space nothing to grow into, whatever roundoff has left in `tail`.
1961 fn schur_lanczos(
1962 &self,
1963 null_chol: &[f64],
1964 start: &[f64],
1965 max_steps: usize,
1966 dimension_ceiling: usize,
1967 ) -> Result<SchurLanczos, String> {
1968 let nullity = self.nullity();
1969 let rank = self.m - nullity;
1970 if start.len() != rank {
1971 return Err(format!(
1972 "residual cascade: Lanczos start vector carries {} entries against penalized \
1973 Schur rank {rank}",
1974 start.len()
1975 ));
1976 }
1977 // `Sum_j w_j = ||start||^2` exactly, which is the free mass self-check
1978 // every caller applies to the weights it derives from this run.
1979 let start_norm_sq = start.iter().map(|value| value * value).sum::<f64>();
1980 let start_norm = start_norm_sq.sqrt();
1981 if !(start_norm.is_finite() && start_norm > 0.0) {
1982 return Err(format!(
1983 "residual cascade: Lanczos start vector has non-positive norm {start_norm}"
1984 ));
1985 }
1986 let steps = max_steps.min(rank);
1987 let mut full = vec![0.0; self.m];
1988 let mut gram_full = vec![0.0; self.m];
1989 let mut projected_null = vec![0.0; self.m];
1990 let mut matvec = vec![0.0; rank];
1991 // The reorthogonalization basis, `steps x rank` row-major in ONE allocation
1992 // rather than a `Vec` of `Vec`s. This is the run's whole memory footprint
1993 // (see `RESIDUAL_QUADRATURE_BASIS_BYTES`) and, at these sizes, its whole
1994 // cost: the loop below streams it end to end at every step, so it is
1995 // bandwidth-bound and the per-vector indirection was pure overhead.
1996 // Reserving it up front also removes the reallocation-and-copy that a
1997 // growing `Vec` of a few hundred megabytes otherwise pays repeatedly. The
1998 // arithmetic and its ORDER are unchanged, so this is bit-identical.
1999 let mut basis: Vec<f64> = Vec::with_capacity(steps.saturating_mul(rank));
2000 let mut q: Vec<f64> = start.iter().map(|&value| value / start_norm).collect();
2001 let mut q_previous: Option<Vec<f64>> = None;
2002 let mut alpha: Vec<f64> = Vec::with_capacity(steps);
2003 let mut beta: Vec<f64> = Vec::with_capacity(steps.saturating_sub(1));
2004 // `max_i |alpha_i|` is a lower bound on `||B||` restricted to the Krylov
2005 // space (every `alpha_i` is a Rayleigh quotient) and it only rises, which
2006 // is exactly what the break floor below needs. Stating that floor against
2007 // the CURRENT `alpha_i` instead — as this recurrence originally did — makes
2008 // it COLLAPSE in the regime it exists to detect: once the Krylov space has
2009 // consumed the operator's range, the remaining iterates are roundoff, the
2010 // Rayleigh quotients go to zero with them, and the floor chases the
2011 // residual down instead of catching it. Measured on the #2503 `n = 2500`
2012 // fixture, where `rank = 16565` but `rank(B) <= n - nullity = 2497`, so
2013 // 85% of the space is null: the run ground to 2023 steps without ever
2014 // reporting invariance and produced a Ritz value at `-7.9e-11`.
2015 let mut spectral_scale = 0.0_f64;
2016 let mut tail = 0.0_f64;
2017 // A Krylov space that has consumed its whole DIMENSION is invariant: there
2018 // is nothing left for it to grow into. `dimension_ceiling` is the caller's
2019 // bound on that dimension — `rank` is the trivial one, but for a start
2020 // vector inside `range(B)` the binding bound is `rank(B) <= n - nullity`
2021 // (`B = Z'WZ` with `W^(1/2) Z = (I - P) W^(1/2) X_1` and `P` of rank
2022 // `nullity`), which on a bounding-box-filled cascade is an order of
2023 // magnitude below `rank`. Reaching it makes the Gauss rule exact whatever
2024 // the accumulated roundoff has left in `tail`.
2025 let mut invariant = steps >= dimension_ceiling;
2026 for step in 0..steps {
2027 self.schur_whitened_matvec(
2028 null_chol,
2029 &q,
2030 &mut matvec,
2031 &mut full,
2032 &mut gram_full,
2033 &mut projected_null,
2034 );
2035 let diagonal = matvec
2036 .iter()
2037 .zip(q.iter())
2038 .map(|(&a, &b)| a * b)
2039 .sum::<f64>();
2040 alpha.push(diagonal);
2041 spectral_scale = spectral_scale.max(diagonal.abs());
2042 let mut residual = matvec.clone();
2043 for i in 0..rank {
2044 residual[i] -= diagonal * q[i];
2045 }
2046 if let Some(previous) = &q_previous {
2047 let previous_beta = beta.last().copied().unwrap_or(0.0);
2048 for i in 0..rank {
2049 residual[i] -= previous_beta * previous[i];
2050 }
2051 }
2052 basis.extend_from_slice(&q);
2053 for direction in basis.chunks_exact(rank) {
2054 let projection = residual
2055 .iter()
2056 .zip(direction.iter())
2057 .map(|(&a, &b)| a * b)
2058 .sum::<f64>();
2059 for (value, &component) in residual.iter_mut().zip(direction) {
2060 *value -= projection * component;
2061 }
2062 }
2063 let norm = residual
2064 .iter()
2065 .map(|value| value * value)
2066 .sum::<f64>()
2067 .sqrt();
2068 if !norm.is_finite() {
2069 return Err(
2070 "residual cascade: Schur-spectrum Lanczos produced a non-finite norm".into(),
2071 );
2072 }
2073 tail = norm;
2074 let rounding_floor =
2075 f64::EPSILON * alpha.len() as f64 * spectral_scale.max(f64::MIN_POSITIVE);
2076 if norm <= rounding_floor {
2077 invariant = true;
2078 break;
2079 }
2080 if step + 1 == steps {
2081 break;
2082 }
2083 beta.push(norm);
2084 q_previous = Some(std::mem::replace(&mut q, residual));
2085 for value in &mut q {
2086 *value /= norm;
2087 }
2088 }
2089 Ok(SchurLanczos {
2090 alpha,
2091 beta,
2092 tail,
2093 spectral_scale,
2094 start_norm_sq,
2095 invariant,
2096 })
2097 }
2098
2099 /// Exact Schur spectrum, together with the response's coordinates in the
2100 /// eigenbasis it is computed from — and WITHOUT ever forming that basis.
2101 ///
2102 /// # What the certified profile actually consumes
2103 ///
2104 /// Two objects, and they are all this returns: every eigenvalue `θ_i` of
2105 /// `B = D^{−1/2}(G₁₁ − G₁₀G₀₀^{−1}G₀₁)D^{−1/2} = VΘVᵀ`, which is what makes
2106 /// each determinant mode an analytic kernel of `θ_i + λ`, and the single
2107 /// projected vector `Vᵀβ` for the whitened response `β`, whose squares are
2108 /// the residual moments' weights. The eigenVECTORS are read at exactly one
2109 /// place — that projection — and nowhere else.
2110 ///
2111 /// A general eigendecomposition cannot hand over `Vᵀβ` without building the
2112 /// whole `rank × rank` `V`, plus its tridiagonalization workspace. That is
2113 /// not a tidiness question here: [`CERTIFIED_SPECTRUM_MAX`] is DERIVED from
2114 /// this route's live memory, so every `m²` block it holds is a `1/√blocks`
2115 /// factor on the widest design this crate can select a smoothing parameter
2116 /// for at all — and a 6000-row cascade that identifies 5997 penalized
2117 /// directions was refused at 2893 for exactly that reason (#2758).
2118 ///
2119 /// So the decomposition is taken through
2120 /// [`gam_linalg::packed_symmetric_spectrum`], which reduces the PACKED
2121 /// triangle in place and carries `β` alongside: `V = QW` for the Householder
2122 /// `Q` and the QL `W`, so `Vᵀβ = Wᵀ(Qᵀβ)` is accumulated one vector at a
2123 /// time and neither factor is materialized. The mathematics is unchanged —
2124 /// all eigenvalues, the exact projection — and the residency is one packed
2125 /// triangle rather than the seven-plus `m²` blocks `eigh` was measured to
2126 /// hold.
2127 fn dense_cascade_spectrum(
2128 &self,
2129 null_chol: &[f64],
2130 ) -> Result<(Vec<CascadeSpectralMode>, CascadeResidualSpectrum), String> {
2131 let q = self.nullity();
2132 let rank = self.m - q;
2133 // The whitened right-hand side is built BEFORE the Schur triangle so
2134 // the peak holds one `O(rank²)` object, not two: `whitened_residual_rhs`
2135 // goes through `matvec` and allocates only `O(m)`.
2136 let (whitened, anchor_energy) = self.whitened_residual_rhs(null_chol);
2137 let (mut schur, cross_block) = self.assemble_schur_gram_blocks();
2138 // `G₀₀^{−1}G₀₁` once for every column, `q ≤ 4` rows: the null
2139 // elimination, held as `q × rank` rather than re-solved per entry.
2140 let mut eliminated = vec![0.0_f64; q * rank];
2141 let mut column = vec![0.0_f64; q];
2142 for j in 0..rank {
2143 for (k, value) in column.iter_mut().enumerate() {
2144 *value = cross_block[k * rank + j];
2145 }
2146 let solved = chol_solve(null_chol, q, &column);
2147 for (k, &coefficient) in solved.iter().enumerate() {
2148 eliminated[k * rank + j] = coefficient;
2149 }
2150 }
2151 // `B = D^{−1/2}(G₁₁ − G₀₁ᵀG₀₀^{−1}G₀₁)D^{−1/2}`, in place on the packed
2152 // triangle the Gram block was accumulated into.
2153 for i in 0..rank {
2154 let base = packed_upper_row_offset(rank, i);
2155 let scale_i = self.pen_diag[q + i];
2156 for j in i..rank {
2157 let mut value = schur[base + (j - i)];
2158 for k in 0..q {
2159 value -= cross_block[k * rank + i] * eliminated[k * rank + j];
2160 }
2161 schur[base + (j - i)] = value / (scale_i * self.pen_diag[q + j]).sqrt();
2162 }
2163 }
2164 drop(cross_block);
2165 drop(eliminated);
2166 // `projected` enters as `β` and leaves as `Vᵀβ`, in the same ascending
2167 // order as `eigenvalues`.
2168 let mut projected = whitened;
2169 let eigenvalues = packed_symmetric_spectrum_with_probe(rank, &mut schur, &mut projected)
2170 .map_err(|error| {
2171 format!("residual cascade: Schur-complement eigendecomposition failed: {error}")
2172 })?;
2173 drop(schur);
2174 let scale = eigenvalues
2175 .iter()
2176 .copied()
2177 .map(f64::abs)
2178 .fold(0.0, f64::max);
2179 let roundoff = f64::EPSILON * rank.max(1) as f64 * scale.max(f64::MIN_POSITIVE);
2180 // A mode inside the decomposition's OWN roundoff floor is a null
2181 // direction of the whitened design, not a small positive one. The floor
2182 // is the same quantity the semidefiniteness check below is stated in;
2183 // reading it in one direction only ("this is not really negative") and
2184 // not the other ("so it is not really positive either") is what lets a
2185 // noise-level eigenvalue set the small-lambda end of the search domain
2186 // and divide into the residual there.
2187 let certified = |eigenvalue: f64| {
2188 if eigenvalue > roundoff {
2189 eigenvalue
2190 } else {
2191 0.0
2192 }
2193 };
2194 let modes = eigenvalues
2195 .iter()
2196 .copied()
2197 .enumerate()
2198 .map(|(index, eigenvalue)| {
2199 if !eigenvalue.is_finite() || eigenvalue < -roundoff {
2200 Err(format!(
2201 "residual cascade: penalty-whitened Schur mode {index} is not positive semidefinite ({eigenvalue})"
2202 ))
2203 } else {
2204 Ok(CascadeSpectralMode {
2205 eigenvalue: certified(eigenvalue),
2206 weight: 1.0,
2207 })
2208 }
2209 })
2210 .collect::<Result<Vec<_>, String>>()?;
2211
2212 // A null mode carries NO response energy, exactly. The Schur complement
2213 // and the whitened right-hand side are built from the same design `Z`:
2214 // `B = Z'WZ` and `beta = Z'Wy`, so `Bv = 0` gives `Zv = 0` and hence
2215 // `v'beta = (Zv)'Wy = 0`. What the arithmetic returns for such a mode is
2216 // roundoff — and the residual sum divides it by `theta + lambda`, which
2217 // at the bottom of the search domain is SMALLER than that roundoff. On a
2218 // 558-column cascade the three null modes carried `p^2 ~ 3e-16` against
2219 // `lambda ~ 4e-19` and drove the profiled residual to -764 where the
2220 // mathematics bounds it below by the unpenalized residual sum of
2221 // squares. Restoring the exact identity is not a tolerance.
2222 let mut projected_square = vec![0.0_f64; rank];
2223 for (j, square) in projected_square.iter_mut().enumerate() {
2224 if certified(eigenvalues[j]) == 0.0 {
2225 continue;
2226 }
2227 *square = projected[j] * projected[j];
2228 }
2229 if !(anchor_energy.is_finite() && projected_square.iter().all(|v| v.is_finite())) {
2230 return Err(format!(
2231 "residual cascade: non-finite spectral residual representation (anchor {anchor_energy})"
2232 ));
2233 }
2234 // Certified-NULL modes are dropped, not carried as zeros, and the
2235 // penalized determinant rank drops with them.
2236 //
2237 // Exact, not an approximation: with `Z` null modes,
2238 // `Σ_i log(θ_i+λ) − rank·log λ = Σ_{θ>0} log(θ+λ) + Z·log λ − rank·log λ
2239 // = Σ_{θ>0} log(θ+λ) − (rank−Z)·log λ`,
2240 // and a null mode's response energy is exactly zero (`Bv = 0` gives
2241 // `Zv = 0`, so `v'β = (Zv)'Wy = 0`), so the residual sum is unchanged
2242 // too. `determinant_parts` already skips `θ == 0` on the SCALAR path for
2243 // the same reason.
2244 //
2245 // Carrying them costs nothing on the scalar path and real width on the
2246 // INTERVAL path, which is why this is not cosmetic.
2247 // `AffineRemlProfile::enclose` evaluates the same expression in interval
2248 // arithmetic, where `Z·[log λ] − rank·[log λ]` does NOT cancel: it returns
2249 // a width proportional to `(Z + rank)·width(log λ)` where the real
2250 // function's width is proportional to the number of POSITIVE modes. On a
2251 // rank-deficient wide cascade — `m` columns against `n` rows with `m ≫ n`,
2252 // which is what box-filling nets produce on a small sample — `Z` is almost
2253 // all of `rank`, so every score enclosure is inflated by that ratio.
2254 //
2255 // What this does NOT do, on its own, is make such a design certifiable:
2256 // a 36-row / 1725-column design with all 1692 nulls dropped and only 33
2257 // modes left was still refused, so the inflation was not that design's
2258 // blocker. The blocker turned out to be a different and larger
2259 // overestimation one level down — `AffineRemlProfile::enclose` was a
2260 // natural interval extension whose VALUE range was first order in the
2261 // cell width with constant `rank` (`33.0·w`, measured over six decades)
2262 // against an exact `|f'|` of `1.15e-5`, so no cell could be retired as
2263 // resolution-flat and the search died on its subdivision budget. With
2264 // that enclosure centred, the same design certifies in about a second
2265 // (`auto_reml_certifies_a_design_the_data_cannot_identify`). Dropping the
2266 // nulls is kept because it is exact and strictly tightens every
2267 // enclosure, which is its own reason.
2268 let mut kept_eigenvalue = Vec::with_capacity(rank);
2269 let mut kept_projected_square = Vec::with_capacity(rank);
2270 for (index, &eigenvalue) in eigenvalues.iter().enumerate() {
2271 if certified(eigenvalue) > 0.0 {
2272 kept_eigenvalue.push(certified(eigenvalue));
2273 kept_projected_square.push(projected_square[index]);
2274 }
2275 }
2276 let kept = kept_eigenvalue.len();
2277 Ok((
2278 modes,
2279 CascadeResidualSpectrum {
2280 eigenvalue: kept_eigenvalue,
2281 penalty: vec![1.0; kept],
2282 projected_square: kept_projected_square,
2283 anchor_energy: [anchor_energy],
2284 },
2285 ))
2286 }
2287
2288 /// Fixed-probe Lanczos quadrature of the lambda-independent Schur
2289 /// spectrum. Unlike the previous lambda-dependent SLQ call, its nodes and
2290 /// weights define one smooth analytic score across the entire search
2291 /// domain, so differentiating the scalar kernels is exact for the score
2292 /// being optimized.
2293 fn iterative_cascade_spectrum(
2294 &self,
2295 null_chol: &[f64],
2296 ) -> Result<Vec<CascadeSpectralMode>, String> {
2297 let q0 = self.nullity();
2298 let rank = self.m - q0;
2299 let steps = SLQ_LANCZOS_STEPS.min(rank);
2300 let mut modes = Vec::with_capacity(SLQ_PROBES * steps);
2301
2302 for probe in 0..SLQ_PROBES {
2303 let mut rng =
2304 SplitMix64::new(RNG_SEED ^ (probe as u64).wrapping_mul(0xD134_2543_DE82_EF95));
2305 // Unit-entry Rademacher probe. `schur_lanczos` normalizes it, and
2306 // `||probe||^2 = rank` exactly (a sum of `rank` ones), so the
2307 // Hutchinson scaling below reads that norm rather than restating it.
2308 let probe_vector = (0..rank).map(|_| rng.next_sign()).collect::<Vec<_>>();
2309 let SchurLanczos {
2310 alpha,
2311 beta,
2312 start_norm_sq,
2313 ..
2314 } = self.schur_lanczos(null_chol, &probe_vector, steps, rank)?;
2315 let (eigenvalues, first_components) = symmetric_tridiagonal_eigen(&alpha, &beta)?;
2316 let scale = eigenvalues
2317 .iter()
2318 .copied()
2319 .map(f64::abs)
2320 .fold(0.0, f64::max);
2321 let roundoff = f64::EPSILON * alpha.len().max(1) as f64 * scale.max(f64::MIN_POSITIVE);
2322 for (index, (&eigenvalue, &first)) in
2323 eigenvalues.iter().zip(first_components.iter()).enumerate()
2324 {
2325 if !eigenvalue.is_finite() || eigenvalue < -roundoff {
2326 return Err(format!(
2327 "residual cascade: Schur-spectrum Ritz value {index} is not positive semidefinite ({eigenvalue})"
2328 ));
2329 }
2330 let weight = start_norm_sq * first * first / SLQ_PROBES as f64;
2331 if !(weight.is_finite() && weight >= 0.0) {
2332 return Err(format!(
2333 "residual cascade: invalid Schur-spectrum quadrature weight {weight}"
2334 ));
2335 }
2336 modes.push(CascadeSpectralMode {
2337 // Same reading of the same floor as the dense route: a Ritz
2338 // value inside the quadrature's own roundoff is a null
2339 // direction, not a small positive mode. Admitting it as
2340 // positive lets it set the small-lambda end of
2341 // `log_lambda_domain`, which is how the search comes to
2342 // demand a solve of `X'WX + λD` at a λ that leaves the
2343 // matrix numerically singular.
2344 eigenvalue: if eigenvalue > roundoff {
2345 eigenvalue
2346 } else {
2347 0.0
2348 },
2349 weight,
2350 });
2351 }
2352 }
2353 Ok(modes)
2354 }
2355
2356 /// `beta = D^(-1/2)(b1 - G10 G00^(-1) b0)` and
2357 /// `anchor_energy = y'Wy - b0' G00^(-1) b0`: the null-eliminated,
2358 /// penalty-whitened right-hand side and the part of the profiled residual no
2359 /// lambda can move.
2360 ///
2361 /// Identical in exact arithmetic to what [`Self::dense_cascade_spectrum`]
2362 /// builds inline, but routed through [`Self::matvec`] instead of
2363 /// `dense_gram_entry`, so it is available past the dense cap. `matvec(0, v)`
2364 /// applies the FULL `X'WX`, and only its `1`-block rows are read — that is
2365 /// `G10 (G00^(-1) b0)`, the cross term, with no dense Gram formed.
2366 fn whitened_residual_rhs(&self, null_chol: &[f64]) -> (Vec<f64>, f64) {
2367 let q = self.nullity();
2368 let rank = self.m - q;
2369 let null_coeff = chol_solve(null_chol, q, &self.rhs[..q]);
2370 let mut full = vec![0.0; self.m];
2371 full[..q].copy_from_slice(&null_coeff);
2372 let mut cross = vec![0.0; self.m];
2373 self.matvec(0.0, &full, &mut cross);
2374 let beta = (0..rank)
2375 .map(|i| (self.rhs[q + i] - cross[q + i]) / self.pen_diag[q + i].sqrt())
2376 .collect::<Vec<_>>();
2377 let anchor_energy = self.ytwy
2378 - self.rhs[..q]
2379 .iter()
2380 .zip(null_coeff.iter())
2381 .map(|(&b, &c)| b * c)
2382 .sum::<f64>();
2383 (beta, anchor_energy)
2384 }
2385
2386 /// One Golub-Meurant Gauss rule from the leading `steps x steps` block of a
2387 /// beta-seeded Lanczos run's Jacobi matrix.
2388 ///
2389 /// `T_m`'s leading `j x j` block IS `T_j`, the Jacobi matrix the same run
2390 /// would have produced had it stopped at step `j` — the Lanczos recurrence is
2391 /// forward. So every nested rule of a single run is available for the cost of
2392 /// one tridiagonal eigendecomposition, which is what makes the convergence
2393 /// comparison in [`Self::iterative_residual_spectrum`] free of a second run.
2394 ///
2395 /// Nodes are the Ritz values `theta_j`, weights are `||beta||^2 tau_j^2` with
2396 /// `tau_j` the first component of Ritz vector `j` — exactly the
2397 /// `(eigenvalue, projected_square)` pair [`CascadeResidualSpectrum`] stores,
2398 /// so the iterative route populates the same struct and inherits
2399 /// [`CascadeResidualSpectrum::moments`] unchanged.
2400 ///
2401 /// TWO NODE FLOORS, both derived rather than tuned.
2402 ///
2403 /// The first is the dense route's, read the same way: a node inside the
2404 /// decomposition's own roundoff (`eps*m*theta_max`) is a NULL direction of the
2405 /// whitened design, and a null direction carries no response energy exactly
2406 /// (`Bv = 0` gives `Zv = 0` and hence `v'beta = 0`).
2407 ///
2408 /// The second is on the WEIGHT, and it exists because the first is not
2409 /// sufficient for a RITZ value. Measured (#2503, `side=14 levels=4`, rank 203,
2410 /// 96 steps): one node at `theta = 2.65e-11` — `1.8e-12` of `theta_max`, but
2411 /// 80x ABOVE the eigenvalue floor, so that floor passes it — carrying weight
2412 /// `8.9e-27 ||beta||^2`. At the bottom of the search domain
2413 /// (`lambda ~ 2.9e-11`) that single node contributes `w/(theta+lambda)^4` and
2414 /// `S_4` comes out `3.6e7` RELATIVE off while `S_2` is still right to `6e-9`.
2415 /// The catastrophe #2503 attributed to quadrature truncation is one node whose
2416 /// weight is pure roundoff and whose position happens to sit under `lambda`.
2417 ///
2418 /// The floor is the SQUARE of the roundoff in the quantity being squared. A
2419 /// Ritz vector's first component comes out of a QL sweep that accumulates
2420 /// `O(m)` plane rotations, so a component that should be zero comes out at
2421 /// `~eps*m`; its square, times the mass, is `(eps*m)^2 ||beta||^2`. Measured
2422 /// against that prediction across three fixtures and eleven step budgets:
2423 /// every spurious weight landed in `[1e-258, 4e-27] ||beta||^2` — i.e. at or
2424 /// below `(eps*m)^2` — while the smallest GENUINE mass the rules carried was
2425 /// `4.1e-14 ||beta||^2`, thirteen orders above the floor. The earlier
2426 /// `eps*m*||beta||^2` floor did over-drop that genuine mass, and the symptom
2427 /// was diagnostic: it GREW with `m`, so a longer run dropped more, which no
2428 /// convergent process can be right about.
2429 fn residual_gauss_rule(
2430 &self,
2431 run: &SchurLanczos,
2432 steps: usize,
2433 anchor_energy: f64,
2434 measure_mass: f64,
2435 ) -> Result<ResidualGaussRule, String> {
2436 let steps = steps.min(run.alpha.len());
2437 let (ritz, first_components) = symmetric_tridiagonal_eigen(
2438 &run.alpha[..steps],
2439 &run.beta[..steps.saturating_sub(1)],
2440 )?;
2441 let scale = ritz.iter().copied().map(f64::abs).fold(0.0, f64::max);
2442 let count = steps.max(1) as f64;
2443 let eigenvalue_floor = f64::EPSILON * count * scale.max(f64::MIN_POSITIVE);
2444 let component_roundoff = f64::EPSILON * count;
2445 let mass_floor = component_roundoff * component_roundoff * measure_mass;
2446
2447 let mut eigenvalue = Vec::with_capacity(ritz.len());
2448 let mut projected_square = Vec::with_capacity(ritz.len());
2449 let mut total_mass = 0.0_f64;
2450 let mut dropped_mass = 0.0_f64;
2451 // A Ritz value is not an eigenvalue: it comes out of a Lanczos recurrence
2452 // and a QL sweep whose backward error grows with the step count, so the
2453 // threshold at which negativity stops being arithmetic and starts being a
2454 // statement about `B` is NOT the eigenvalue floor. Below `sqrt(eps)*theta_max`
2455 // — the resolution this module works at throughout, the same one
2456 // `certified_log_lambda_domain_from_modes` pads with — a negative Ritz value is
2457 // indistinguishable from the zero it is approximating, and is clamped to
2458 // the null direction it represents. Above it, the penalty-whitened Schur
2459 // complement is genuinely indefinite, which is a defect and not roundoff.
2460 let indefinite = f64::EPSILON.sqrt() * scale.max(f64::MIN_POSITIVE);
2461 for (index, (&theta, &first)) in ritz.iter().zip(first_components.iter()).enumerate() {
2462 if !theta.is_finite() || theta < -indefinite {
2463 return Err(format!(
2464 "residual cascade: profiled-residual Ritz value {index} of {steps} is not \
2465 positive semidefinite ({theta}); the whitened Schur complement is \
2466 indefinite beyond the run's own resolution {indefinite}"
2467 ));
2468 }
2469 let weight = run.start_norm_sq * first * first;
2470 if !(weight.is_finite() && weight >= 0.0) {
2471 return Err(format!(
2472 "residual cascade: invalid profiled-residual quadrature weight {weight}"
2473 ));
2474 }
2475 total_mass += weight;
2476 if theta <= eigenvalue_floor || weight <= mass_floor {
2477 dropped_mass += weight;
2478 eigenvalue.push(0.0);
2479 projected_square.push(0.0);
2480 } else {
2481 eigenvalue.push(theta);
2482 projected_square.push(weight);
2483 }
2484 }
2485 // The weights of ANY Gauss rule sum to the measure's total mass, so
2486 // `sum_j w_j = ||beta||^2` is a free self-check on the whole Jacobi
2487 // pipeline — the recurrence, the reorthogonalization and the tridiagonal
2488 // eigensolver at once. It is charged against the accumulated rounding of
2489 // the sum it checks, not against a tuned slack.
2490 let mass_defect = ((total_mass - measure_mass) / measure_mass).abs();
2491 let mass_tolerance = 8.0 * f64::EPSILON * count;
2492 if !(mass_defect <= mass_tolerance) {
2493 return Err(format!(
2494 "residual cascade: profiled-residual quadrature weights sum to {total_mass} \
2495 against the measure mass {measure_mass} (relative defect {mass_defect} over \
2496 tolerance {mass_tolerance}); the Gauss rule for a measure of mass m has weights \
2497 summing to m, so the Jacobi matrix or its eigendecomposition is wrong"
2498 ));
2499 }
2500 let modes = eigenvalue.len();
2501 Ok(ResidualGaussRule {
2502 spectrum: CascadeResidualSpectrum {
2503 eigenvalue,
2504 penalty: vec![1.0; modes],
2505 projected_square,
2506 anchor_energy: [anchor_energy],
2507 },
2508 mass_defect,
2509 dropped_mass_fraction: dropped_mass / measure_mass,
2510 })
2511 }
2512
2513 /// The profiled residual's spectral form past the dense cap, by Golub-Meurant
2514 /// quadrature of the SAME Schur operator the determinant sweep runs on — or
2515 /// `None` when no rule earned the right to be used.
2516 ///
2517 /// `S_k(lambda) = beta'(B + lambda I)^-k beta` is `integral (theta +
2518 /// lambda)^-k dmu(theta)` for the measure `mu` that `beta` induces on
2519 /// `spec(B)`. One Lanczos run seeded with `beta` (rather than with a
2520 /// Rademacher probe) gives the Jacobi matrix of the `m`-node Gauss rule for
2521 /// that measure; see [`Self::residual_gauss_rule`] for the rule itself and its
2522 /// two node floors. Where a rule is admitted, the whole score is solve-free at
2523 /// every `lambda`, exactly as on the dense route after #2455 — and the
2524 /// domain-endpoint `lambda` that no PCG can solve (#2503) is never solved at.
2525 ///
2526 /// WHAT ADMITS A RULE, and why it is not the step count.
2527 ///
2528 /// A Gauss rule accurate in VALUE need not be accurate in its
2529 /// lambda-DERIVATIVES: the nodes are placed to integrate one kernel, and
2530 /// `(theta + lambda)^-k` grows more peaked at the bottom of the spectrum with
2531 /// every power of `k`. `S_2, S_3, S_4` ARE the score's first three
2532 /// `log lambda` derivatives, so a rule may not be admitted on `R` alone. Two
2533 /// point-evaluation admissions are available and both are properties of the
2534 /// run, not of a calibrated budget:
2535 ///
2536 /// 1. THE KRYLOV SPACE CLOSED (`SchurLanczos::invariant`). Then
2537 /// `(B + lambda I)^-k beta` lies inside `K_m` for every `k` and every
2538 /// `lambda`, and the rule reproduces the spectral sum outright.
2539 /// 2. THE NESTED LADDER HAS CONTRACTED. Every Gauss rule for a completely
2540 /// monotone kernel UNDER-estimates its integral (the `(2m)`-th derivative
2541 /// of `(theta+lambda)^-k` is positive, so the standard error
2542 /// representation is one-signed), so the rules at `m/4`, `m/2`, `m` — free,
2543 /// since `T_m`'s leading block IS `T_j` — rise toward `S_k` with all their
2544 /// gaps of one sign, and the remaining error is the tail of those gaps.
2545 /// [`residual_quadrature_tail_estimate`] extrapolates that tail
2546 /// geometrically and REFUSES when the last two gaps do not contract, which
2547 /// is the stagnation case a bare two-rule agreement test cannot see. The
2548 /// estimate must fall below the diagnostic point resolution —
2549 /// `sqrt(eps)`, the same constant `certified_log_lambda_domain_from_modes` uses for
2550 /// endpoint padding. Measured against the exact dense eigenbasis at every
2551 /// budget on this ladder, over three designs: where the estimate passed,
2552 /// the rule was within `1e-12`.
2553 ///
2554 /// The budget GROWS geometrically until one of those fires, so the accepted
2555 /// rule is the smallest that passes rather than the largest affordable. That
2556 /// matters in both directions: past roughly 60% of the penalized rank the run
2557 /// starts producing near-null ghost nodes (measured: rank 473, from 256 steps
2558 /// on), so the cheapest passing rule is also the cleanest one.
2559 ///
2560 /// Returns `None` when neither admission holds. The caller turns that into a
2561 /// refusal; it never revives the per-`lambda` solve fallback.
2562 fn iterative_residual_spectrum(
2563 &self,
2564 null_chol: &[f64],
2565 domain: (f64, f64),
2566 ) -> Result<(Option<CascadeResidualSpectrum>, ResidualQuadratureEvidence), String> {
2567 let rank = self.m - self.nullity();
2568 let ceiling = self.residual_krylov_ceiling();
2569 let budget = self.residual_quadrature_budget();
2570 let target = f64::EPSILON.sqrt();
2571 let (beta, anchor_energy) = self.whitened_residual_rhs(null_chol);
2572 if !(anchor_energy.is_finite() && beta.iter().all(|value| value.is_finite())) {
2573 return Err(format!(
2574 "residual cascade: non-finite whitened residual right-hand side (anchor \
2575 {anchor_energy})"
2576 ));
2577 }
2578 let measure_mass = beta.iter().map(|value| value * value).sum::<f64>();
2579 if !(measure_mass > 0.0) {
2580 // No response energy outside the polynomial null space: the profiled
2581 // residual is the anchor at every lambda. A zero measure is exactly
2582 // integrated by the empty rule, so this evidence is exact.
2583 return Ok((
2584 Some(CascadeResidualSpectrum {
2585 eigenvalue: Vec::new(),
2586 penalty: Vec::new(),
2587 projected_square: Vec::new(),
2588 anchor_energy: [anchor_energy],
2589 }),
2590 ResidualQuadratureEvidence {
2591 steps: 0,
2592 coarse_steps: 0,
2593 rank,
2594 budget,
2595 relative_tail: 0.0,
2596 tail_estimate: 0.0,
2597 target,
2598 invariant: true,
2599 accepted_for_point_evaluation: true,
2600 mass_defect: 0.0,
2601 dropped_mass_fraction: 0.0,
2602 },
2603 ));
2604 }
2605 let mut steps = SLQ_LANCZOS_STEPS.min(budget);
2606 loop {
2607 let run = self.schur_lanczos(null_chol, &beta, steps, ceiling)?;
2608 let taken = run.alpha.len();
2609 let fine = self.residual_gauss_rule(&run, taken, anchor_energy, measure_mass)?;
2610 let coarse_steps = taken / 4;
2611 let tail_estimate = if run.invariant {
2612 // A closed Krylov space makes the rule exact for every kernel;
2613 // there is nothing left for a nested comparison to add.
2614 0.0
2615 } else if coarse_steps == 0 {
2616 // Fewer than four nodes leaves no nested ladder to extrapolate
2617 // along, and "no evidence" is not "converged".
2618 f64::INFINITY
2619 } else {
2620 let mid =
2621 self.residual_gauss_rule(&run, taken / 2, anchor_energy, measure_mass)?;
2622 let coarse =
2623 self.residual_gauss_rule(&run, coarse_steps, anchor_energy, measure_mass)?;
2624 residual_quadrature_tail_estimate(
2625 &fine.spectrum,
2626 &mid.spectrum,
2627 &coarse.spectrum,
2628 taken,
2629 domain,
2630 )?
2631 };
2632 let evidence = ResidualQuadratureEvidence {
2633 steps: taken,
2634 coarse_steps: if run.invariant { 0 } else { coarse_steps },
2635 rank,
2636 budget,
2637 relative_tail: run.tail / run.spectral_scale.max(f64::MIN_POSITIVE),
2638 tail_estimate,
2639 target,
2640 invariant: run.invariant,
2641 accepted_for_point_evaluation: run.invariant || tail_estimate <= target,
2642 mass_defect: fine.mass_defect,
2643 dropped_mass_fraction: fine.dropped_mass_fraction,
2644 };
2645 if evidence.accepted_for_point_evaluation {
2646 return Ok((Some(fine.spectrum), evidence));
2647 }
2648 if taken >= budget {
2649 return Ok((None, evidence));
2650 }
2651 steps = steps.saturating_mul(2).min(budget);
2652 }
2653 }
2654
2655 /// Lanczos steps the profiled-residual quadrature may grow to past the dense
2656 /// cap.
2657 ///
2658 /// Not an accuracy dial — a rule is admitted by its own convergence, not by
2659 /// reaching a step count — so this bounds how large a Krylov space we are
2660 /// willing to REORTHOGONALIZE before declining the point evaluation. The binding
2661 /// resource is the basis itself (`steps x rank` doubles held live), so the
2662 /// bound is stated as that memory and the number in the code is the one being
2663 /// reasoned about.
2664 ///
2665 /// Two structural ceilings apply on top. `rank` is the obvious one. The other
2666 /// is `n - nullity`: `B = Z'WZ` for an `n x rank` whitened design, so
2667 /// `rank(B) <= n - nullity`, and in exact arithmetic `K_m(B, beta)` cannot
2668 /// grow past that dimension. Steps beyond it are spent entirely inside the
2669 /// numerical null space, which is where the ghost nodes the weight floor has
2670 /// to clean up come from.
2671 fn residual_quadrature_budget(&self) -> usize {
2672 let rank = self.m - self.nullity();
2673 let by_memory =
2674 RESIDUAL_QUADRATURE_BASIS_BYTES / (size_of::<f64>() * rank.max(1)).max(1);
2675 by_memory
2676 .max(SLQ_LANCZOS_STEPS)
2677 .min(self.residual_krylov_ceiling())
2678 }
2679
2680 /// Largest dimension `K_m(B, beta)` can reach, so reaching it proves the space
2681 /// is `B`-invariant and the Gauss rule EXACT.
2682 ///
2683 /// `B = D^(-1/2)(G11 - G10 G00^(-1) G01) D^(-1/2) = Z'WZ` with
2684 /// `W^(1/2) Z = (I - P) W^(1/2) X_1` and `P` the `W`-projector onto the
2685 /// polynomial block, so `rank(B) <= n - nullity`; and `beta = Z'Wy` lies in
2686 /// `range(B)`, so the Krylov space cannot leave it. On a cascade whose net
2687 /// fills the bounding BOX rather than the data cloud this is the binding bound
2688 /// by an order of magnitude — measured on the `n = 800` 2-D fixture at
2689 /// refinement level 6: `rank = 7387` against `n - nullity = 797`, because 89%
2690 /// of the columns are void-filling centres the data cannot pin. Reading the
2691 /// ceiling as `rank` there made the invariance test unreachable and sent the
2692 /// route back to the solve at full budget.
2693 fn residual_krylov_ceiling(&self) -> usize {
2694 let rank = self.m - self.nullity();
2695 rank.min(self.y.len().saturating_sub(self.nullity())).max(1)
2696 }
2697
2698 fn reml_profile(&self) -> Result<CascadeRemlProfile<'_>, String> {
2699 let (null_chol, null_logdet) = self.null_gram_factor()?;
2700 // The exact spectral form is taken whenever the dense eigendecomposition
2701 // fits its memory budget — which is wider than the Gram cache, so a
2702 // design past `DENSE_GRAM_MAX` still gets the certifiable profile and
2703 // pays for the Gram only for the duration of the decomposition.
2704 let (modes, residual) = if self.certified_spectrum_available() {
2705 let (modes, spectrum) = self.dense_cascade_spectrum(&null_chol)?;
2706 (modes, CascadeResidualForm::Spectral(spectrum))
2707 } else {
2708 let modes = self.iterative_cascade_spectrum(&null_chol)?;
2709 // The residual's numerical evidence is charged over exactly the
2710 // interval the diagnostic point criterion may visit, so the
2711 // determinant modes — which define that interval — are built first.
2712 let domain = certified_log_lambda_domain_from_modes(&modes)?;
2713 let (spectrum, evidence) =
2714 self.iterative_residual_spectrum(&null_chol, domain)?;
2715 let spectrum = spectrum.ok_or_else(|| {
2716 format!(
2717 "residual cascade: profiled-residual quadrature did not resolve inside its \
2718 resource-derived budget (steps {}, coarse steps {}, rank {}, budget {}, \
2719 invariant {}, tail estimate {:.3e} against target {:.3e}, relative tail {:.3e}, \
2720 mass defect {:.3e}, dropped mass fraction {:.3e}); refusing the \
2721 ill-conditioned per-lambda solve fallback",
2722 evidence.steps,
2723 evidence.coarse_steps,
2724 evidence.rank,
2725 evidence.budget,
2726 evidence.invariant,
2727 evidence.tail_estimate,
2728 evidence.target,
2729 evidence.relative_tail,
2730 evidence.mass_defect,
2731 evidence.dropped_mass_fraction,
2732 )
2733 })?;
2734 (modes, CascadeResidualForm::Quadrature(spectrum))
2735 };
2736 Ok(CascadeRemlProfile {
2737 core: self,
2738 null_logdet,
2739 modes,
2740 residual,
2741 })
2742 }
2743
2744 /// Scale a raw point into shifted metric coordinates.
2745 fn scale_point(&self, x: &[f64]) -> [f64; 3] {
2746 let mut z = [0.0_f64; 3];
2747 for a in 0..self.dim {
2748 z[a] = self.metric[a] * x[a] - self.z_lo[a];
2749 }
2750 z
2751 }
2752
2753 /// Sparse basis row at a scaled point: polynomial layer then every bump
2754 /// whose support covers it, as (column, value) pairs sorted by column.
2755 fn basis_row_scaled(&self, z: &[f64; 3]) -> Vec<(usize, f64)> {
2756 let mut row = Vec::with_capacity(self.dim + 1 + self.levels.len() * 8);
2757 row.push((0, 1.0));
2758 for a in 0..self.dim {
2759 row.push((a + 1, 2.0 * z[a] / self.z_range[a] - 1.0));
2760 }
2761 for level in &self.levels {
2762 let start = row.len();
2763 level.grid.for_neighbors(z, |j| {
2764 let c = &level.centers[j as usize];
2765 let r = dist2(z, c, self.dim).sqrt() / level.delta;
2766 let v = wendland(r);
2767 if v > 0.0 {
2768 row.push((level.col_offset + j as usize, v));
2769 }
2770 });
2771 row[start..].sort_unstable_by_key(|&(col, _)| col);
2772 }
2773 row
2774 }
2775
2776 /// `out = (X'WX + λD)·v` through the CSR rows: O(nnz).
2777 fn matvec(&self, lambda: f64, v: &[f64], out: &mut [f64]) {
2778 for (o, (&d, &x)) in out.iter_mut().zip(self.pen_diag.iter().zip(v.iter())) {
2779 *o = lambda * d * x;
2780 }
2781 for i in 0..self.w.len() {
2782 let lo = self.row_ptr[i];
2783 let hi = self.row_ptr[i + 1];
2784 let mut t = 0.0;
2785 for e in lo..hi {
2786 t += self.vals[e] * v[self.col_idx[e] as usize];
2787 }
2788 t *= self.w[i];
2789 for e in lo..hi {
2790 out[self.col_idx[e] as usize] += self.vals[e] * t;
2791 }
2792 }
2793 }
2794
2795 /// Jacobi / level-diagonal preconditioner: `diag(X'WX) + λ·diag(λD)`.
2796 /// Levels share a constant prior weight, so this IS the level-block
2797 /// (BPX-flavored) diagonal in the multilevel frame.
2798 /// Coarse column count of the additive-Schwarz coarse space at `λ`: the
2799 /// polynomial layer plus the longest prefix of data-dominated levels
2800 /// (`λ d_l < COARSE_DOMINANCE · median diag(X'WX) over the level`), with the
2801 /// two coarsest levels always deflated and the total capped at
2802 /// [`COARSE_SPACE_MAX`]. Because `d_l` rises while the per-level data weight
2803 /// falls, the data-dominated set is a contiguous prefix, so one scan from the
2804 /// coarsest level finds the cut. (See [`COARSE_DOMINANCE`].)
2805 fn coarse_space_cols(&self, lambda: f64) -> usize {
2806 let mut ncoarse = self.nullity();
2807 let mut buf: Vec<f64> = Vec::new();
2808 for (li, level) in self.levels.iter().enumerate() {
2809 let a = level.col_offset;
2810 let b = a + level.centers.len();
2811 if b <= a {
2812 continue;
2813 }
2814 if b > COARSE_SPACE_MAX {
2815 break;
2816 }
2817 let dominated = if li < MIN_COARSE_LEVELS {
2818 true
2819 } else {
2820 buf.clear();
2821 buf.extend_from_slice(&self.gram_diag[a..b]);
2822 buf.sort_unstable_by(|x, y| x.total_cmp(y));
2823 let gram_median = buf[buf.len() / 2];
2824 lambda * level.weight < COARSE_DOMINANCE * gram_median
2825 };
2826 if dominated {
2827 ncoarse = b;
2828 } else {
2829 break;
2830 }
2831 }
2832 // Keep at least one fine column so the split is well-defined; if every
2833 // level is coarse the iterative route is degenerate anyway and the dense
2834 // route would have been taken, but guard regardless.
2835 let ncoarse = ncoarse.min(self.m);
2836 // Debug-only coarse-space layout trace (#1032). Gated on the log level so
2837 // the per-call string build stays out of this preconditioner hot path,
2838 // and routed through `log` (an `eprintln!` here trips the src banned-macro
2839 // gate and broke the build).
2840 if log::log_enabled!(log::Level::Debug) {
2841 let mut s = String::new();
2842 for (li, level) in self.levels.iter().enumerate() {
2843 let a = level.col_offset;
2844 let b = a + level.centers.len();
2845 let mut buf: Vec<f64> = self.gram_diag[a..b].to_vec();
2846 buf.sort_unstable_by(|x, y| x.total_cmp(y));
2847 let med = if buf.is_empty() {
2848 0.0
2849 } else {
2850 buf[buf.len() / 2]
2851 };
2852 let coarse = b <= ncoarse;
2853 s.push_str(&format!(
2854 " L{li}[{}c off{a} w={:.2e} λw={:.2e} med={:.2e} {}]",
2855 level.centers.len(),
2856 level.weight,
2857 lambda * level.weight,
2858 med,
2859 if coarse { "C" } else { "F" }
2860 ));
2861 }
2862 log::debug!(
2863 "[1032-COARSE] λ={lambda:.3e} m={} ncoarse={ncoarse} cap={COARSE_SPACE_MAX}{s}",
2864 self.m
2865 );
2866 }
2867 ncoarse
2868 }
2869
2870 /// Build the coarse-space additive-Schwarz preconditioner at `λ`: assemble
2871 /// and factor the coarse block `A_CC` from the CSR (coarse columns are the
2872 /// prefix `[0, ncoarse)`, and each CSR row is column-sorted, so a row's
2873 /// coarse entries are its leading run), then the Jacobi diagonal on the fine
2874 /// tail. `O(n · q_C²) + O(ncoarse³)` — paid once per `λ`, not per CG step.
2875 fn build_preconditioner(&self, lambda: f64) -> Result<Preconditioner, String> {
2876 let m = self.m;
2877 let nc = self.coarse_space_cols(lambda);
2878 let mut acc = vec![0.0_f64; nc * nc];
2879 for i in 0..self.w.len() {
2880 let lo = self.row_ptr[i];
2881 let hi = self.row_ptr[i + 1];
2882 // Leading run of coarse columns (CSR rows are column-sorted).
2883 let mut end = lo;
2884 while end < hi && (self.col_idx[end] as usize) < nc {
2885 end += 1;
2886 }
2887 for ea in lo..end {
2888 let ca = self.col_idx[ea] as usize;
2889 let va = self.w[i] * self.vals[ea];
2890 for eb in ea..end {
2891 let cb = self.col_idx[eb] as usize;
2892 acc[ca * nc + cb] += va * self.vals[eb];
2893 }
2894 }
2895 }
2896 for i in 0..nc {
2897 for j in i + 1..nc {
2898 acc[j * nc + i] = acc[i * nc + j];
2899 }
2900 }
2901 for i in 0..nc {
2902 acc[i * nc + i] += lambda * self.pen_diag[i];
2903 }
2904 let coarse_logdet = cholesky_logdet(&mut acc, nc)?;
2905 let mut inv_fine = Vec::with_capacity(m - nc);
2906 let mut inv_sqrt_fine = Vec::with_capacity(m - nc);
2907 let mut fine_logdet = 0.0;
2908 for j in nc..m {
2909 let p = self.gram_diag[j] + lambda * self.pen_diag[j];
2910 // A positive preconditioner diagonal is the whole requirement; no
2911 // floor stands between "positive" and "usable".
2912 if !(p.is_finite() && p > 0.0) {
2913 return Err(format!(
2914 "residual cascade: non-positive preconditioner diagonal {p} at column {j}"
2915 ));
2916 }
2917 inv_fine.push(1.0 / p);
2918 inv_sqrt_fine.push(1.0 / p.sqrt());
2919 fine_logdet += p.ln();
2920 }
2921 Ok(Preconditioner {
2922 ncoarse: nc,
2923 coarse_chol: acc,
2924 coarse_logdet,
2925 inv_fine,
2926 inv_sqrt_fine,
2927 fine_logdet,
2928 })
2929 }
2930
2931 /// Preconditioned CG on `(X'WX + λD)c = b` to relative residual CG_RTOL.
2932 /// Returns the solution with its backward-error certificate.
2933 fn pcg(
2934 &self,
2935 lambda: f64,
2936 b: &[f64],
2937 warm: Option<&[f64]>,
2938 ) -> Result<(Vec<f64>, f64, usize), String> {
2939 let prec = self.build_preconditioner(lambda)?;
2940 self.pcg_with(lambda, &prec, b, warm)
2941 }
2942
2943 /// [`Core::pcg`] against a preconditioner the caller already built at this
2944 /// λ. The preconditioner depends on λ and nothing else, so a caller with
2945 /// several right-hand sides at one λ builds it once.
2946 fn pcg_with(
2947 &self,
2948 lambda: f64,
2949 prec: &Preconditioner,
2950 b: &[f64],
2951 warm: Option<&[f64]>,
2952 ) -> Result<(Vec<f64>, f64, usize), String> {
2953 let m = self.m;
2954 let b_norm = b.iter().map(|v| v * v).sum::<f64>().sqrt();
2955 if b_norm == 0.0 {
2956 return Ok((vec![0.0; m], 0.0, 0));
2957 }
2958 let mut zv = vec![0.0; m];
2959 let mut x = match warm {
2960 Some(x0) => {
2961 if x0.len() != m {
2962 return Err(format!(
2963 "residual cascade: warm-start length {} != system size {m}",
2964 x0.len()
2965 ));
2966 }
2967 x0.to_vec()
2968 }
2969 None => {
2970 prec.solve(b, &mut zv);
2971 zv.clone()
2972 }
2973 };
2974 let mut r = vec![0.0; m];
2975 self.matvec(lambda, &x, &mut r);
2976 for (ri, &bi) in r.iter_mut().zip(b.iter()) {
2977 *ri = bi - *ri;
2978 }
2979 prec.solve(&r, &mut zv);
2980 let mut p_dir = zv.clone();
2981 let mut rz: f64 = r.iter().zip(zv.iter()).map(|(&a, &c)| a * c).sum();
2982 let mut ap = vec![0.0; m];
2983 let max_iters = CG_MAX_ITERS;
2984 for iter in 0..max_iters {
2985 let r_norm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
2986 if r_norm <= CG_RTOL * b_norm {
2987 return Ok((x, r_norm / b_norm, iter));
2988 }
2989 self.matvec(lambda, &p_dir, &mut ap);
2990 let pap: f64 = p_dir.iter().zip(ap.iter()).map(|(&a, &c)| a * c).sum();
2991 if !(pap.is_finite() && pap > 0.0) {
2992 return Err(format!(
2993 "residual cascade: CG curvature breakdown (p'Ap = {pap}) at iteration {iter}"
2994 ));
2995 }
2996 let alpha = rz / pap;
2997 for j in 0..m {
2998 x[j] += alpha * p_dir[j];
2999 r[j] -= alpha * ap[j];
3000 }
3001 prec.solve(&r, &mut zv);
3002 let rz_new: f64 = r.iter().zip(zv.iter()).map(|(&a, &c)| a * c).sum();
3003 let beta = rz_new / rz;
3004 rz = rz_new;
3005 for j in 0..m {
3006 p_dir[j] = zv[j] + beta * p_dir[j];
3007 }
3008 }
3009 Err(format!(
3010 "residual cascade: CG failed to reach relative residual {CG_RTOL} within \
3011 {CG_MAX_ITERS} iterations (the coarse-space additive-Schwarz preconditioner should \
3012 make this n-independent; this indicates a degenerate design)"
3013 ))
3014 }
3015
3016 /// Expand the cached dense upper Gram + λD into a full symmetric matrix.
3017 fn dense_system(&self, lambda: f64) -> Option<Vec<f64>> {
3018 let gram = self.dense_gram.as_ref()?;
3019 let m = self.m;
3020 let mut a = vec![0.0; m * m];
3021 for i in 0..m {
3022 for j in i..m {
3023 let mut v = gram[i * m + j];
3024 if i == j {
3025 v += lambda * self.pen_diag[i];
3026 }
3027 a[i * m + j] = v;
3028 a[j * m + i] = v;
3029 }
3030 }
3031 Some(a)
3032 }
3033
3034 /// `A = X'WX + λD` as canonical symmetric-UPPER CSC, assembled from the CSR
3035 /// design.
3036 ///
3037 /// A sparse accumulator, not a triplet list. `X` is transposed once
3038 /// (`nnz(X)` entries), then column `j` of `A` is gathered by walking the rows
3039 /// that touch column `j` and accumulating each such row's entries with
3040 /// column index `≤ j` into a dense scratch of length `m`. Peak extra memory
3041 /// is `O(m + nnz(X) + nnz(A))`. A triplet list would instead materialize
3042 /// `Σ_i k_i(k_i+1)/2` entries — the full upper outer product of every row,
3043 /// duplicates included — which scales with the ROW count rather than with
3044 /// the design and is an order of magnitude past `nnz(A)` on a wide cascade.
3045 ///
3046 /// CSR rows are built polynomial-block-first and then level by level with
3047 /// ascending `col_offset`, so a row's column indices ascend; that is what
3048 /// lets the inner scan stop at the first column past `j`.
3049 fn sparse_upper_system(&self, lambda: f64) -> Result<SparseColMat<usize, f64>, String> {
3050 let m = self.m;
3051 let n = self.w.len();
3052 let mut x_col_ptr = vec![0_usize; m + 1];
3053 for &c in &self.col_idx {
3054 x_col_ptr[c as usize + 1] += 1;
3055 }
3056 for j in 0..m {
3057 x_col_ptr[j + 1] += x_col_ptr[j];
3058 }
3059 let nnz_x = x_col_ptr[m];
3060 let mut x_rows = vec![0_u32; nnz_x];
3061 let mut x_vals = vec![0.0_f64; nnz_x];
3062 {
3063 let mut cursor = x_col_ptr.clone();
3064 for i in 0..n {
3065 for e in self.row_ptr[i]..self.row_ptr[i + 1] {
3066 let c = self.col_idx[e] as usize;
3067 let slot = cursor[c];
3068 x_rows[slot] = i as u32;
3069 x_vals[slot] = self.vals[e];
3070 cursor[c] = slot + 1;
3071 }
3072 }
3073 }
3074 let mut acc = vec![0.0_f64; m];
3075 let mut marked = vec![false; m];
3076 let mut touched: Vec<usize> = Vec::new();
3077 let mut col_ptr: Vec<usize> = Vec::with_capacity(m + 1);
3078 col_ptr.push(0);
3079 let mut row_idx: Vec<usize> = Vec::new();
3080 let mut values: Vec<f64> = Vec::new();
3081 for j in 0..m {
3082 touched.clear();
3083 for e in x_col_ptr[j]..x_col_ptr[j + 1] {
3084 let row = x_rows[e] as usize;
3085 let weighted = self.w[row] * x_vals[e];
3086 for f in self.row_ptr[row]..self.row_ptr[row + 1] {
3087 let c = self.col_idx[f] as usize;
3088 if c > j {
3089 break;
3090 }
3091 if !marked[c] {
3092 marked[c] = true;
3093 touched.push(c);
3094 }
3095 acc[c] += weighted * self.vals[f];
3096 }
3097 }
3098 // The prior precision is diagonal, so it only ever lands on (j, j).
3099 // The diagonal is stored unconditionally: a column the data never
3100 // touches still carries `λ d_j`, and an all-zero column would make
3101 // the symbolic factorization see a structurally singular matrix.
3102 if !marked[j] {
3103 marked[j] = true;
3104 touched.push(j);
3105 }
3106 acc[j] += lambda * self.pen_diag[j];
3107 touched.sort_unstable();
3108 for &c in &touched {
3109 let value = acc[c];
3110 acc[c] = 0.0;
3111 marked[c] = false;
3112 if !value.is_finite() {
3113 return Err(format!(
3114 "residual cascade: non-finite sparse normal-equation entry ({c}, {j}) = {value}"
3115 ));
3116 }
3117 if value != 0.0 || c == j {
3118 row_idx.push(c);
3119 values.push(value);
3120 }
3121 }
3122 col_ptr.push(row_idx.len());
3123 }
3124 let symbolic = SymbolicSparseColMat::<usize>::new_checked(m, m, col_ptr, None, row_idx);
3125 Ok(SparseColMat::<usize, f64>::new(symbolic, values))
3126 }
3127
3128 /// Exact sparse-direct factor of `A = X'WX + λD` at this λ, or `None` when
3129 /// the AMD ordering's MEASURED fill-in exceeds [`SPARSE_FACTOR_MAX_NNZ`].
3130 ///
3131 /// The symbolic phase is run twice — once here to price the fill before
3132 /// committing, once inside the factorization — because the decision has to
3133 /// be made from `nnz(L)` and only the symbolic phase can supply it. That is
3134 /// `O(nnz(A))` against the numeric phase's `O(Σ_j nnz(L_{:,j})²)`, so it is
3135 /// not the cost being controlled.
3136 fn sparse_exact_factor(&self, lambda: f64) -> Result<Option<SparseExactFactor>, String> {
3137 let a = self.sparse_upper_system(lambda)?;
3138 let nnz_a = a.compute_nnz();
3139 let nnz_l = sparse_spd_factor_nnz(&a).map_err(|error| {
3140 format!("residual cascade: sparse normal-equation symbolic analysis failed: {error}")
3141 })?;
3142 log::info!(
3143 "[2546-FILL] m={} nnz(A)={nnz_a} nnz(L)={nnz_l} dense_upper={} \
3144 fill_vs_A={:.2} fraction_of_dense={:.4}",
3145 self.m,
3146 self.m * (self.m + 1) / 2,
3147 nnz_l as f64 / nnz_a.max(1) as f64,
3148 2.0 * nnz_l as f64 / (self.m as f64 * (self.m as f64 + 1.0))
3149 );
3150 if nnz_l > SPARSE_FACTOR_MAX_NNZ {
3151 return Ok(None);
3152 }
3153 factorize_sparse_spd_strict(&a).map(Some).map_err(|error| {
3154 format!(
3155 "residual cascade: exact sparse factorization of X'WX + {lambda} D failed \
3156 (m = {}, nnz(A) = {nnz_a}, nnz(L) = {nnz_l}): {error}",
3157 self.m
3158 )
3159 })
3160 }
3161
3162 /// Exact log-determinant of `X'WX + λD` by dense Cholesky. Errors when
3163 /// the design is past the dense sizing cap.
3164 fn logdet_dense(&self, lambda: f64) -> Result<f64, String> {
3165 let mut a = self.dense_system(lambda).ok_or_else(|| {
3166 format!(
3167 "residual cascade: dense logdet requested past the sizing cap \
3168 (m = {} > {DENSE_GRAM_MAX})",
3169 self.m
3170 )
3171 })?;
3172 cholesky_logdet(&mut a, self.m)
3173 }
3174
3175 /// SLQ log-determinant: exact control variate `log|P|` (the coarse-space
3176 /// additive-Schwarz preconditioner's own log-determinant — `log|A_CC|` plus
3177 /// the fine Jacobi `Σ_F log A_jj`) plus stochastic Lanczos quadrature for
3178 /// `tr log(R⁻¹ A R⁻ᵀ)`, `P = R Rᵀ`, on fixed deterministic Rademacher probes
3179 /// shared across every λ (common random numbers ⇒ the REML criterion is a
3180 /// smooth deterministic function of λ). The same coarse deflation that makes
3181 /// the PCG iteration count n-independent makes `R⁻¹ A R⁻ᵀ` uniformly
3182 /// conditioned, so the Lanczos quadrature converges in a depth-independent
3183 /// number of steps too.
3184 fn logdet_slq(&self, lambda: f64) -> Result<f64, String> {
3185 let m = self.m;
3186 let prec = self.build_preconditioner(lambda)?;
3187 let logdet = prec.logdet();
3188 // M·v = R⁻¹ A R⁻ᵀ v (eigenvalues of P^{−1/2} A P^{−1/2}) without forming M.
3189 let mut scratch_in = vec![0.0; m];
3190 let mut scratch_out = vec![0.0; m];
3191 let mut vbuf = vec![0.0; m];
3192 let mut trace_est = 0.0;
3193 let steps = SLQ_LANCZOS_STEPS.min(m);
3194 let mut basis: Vec<Vec<f64>> = Vec::with_capacity(steps);
3195 for probe in 0..SLQ_PROBES {
3196 let mut rng =
3197 SplitMix64::new(RNG_SEED ^ (probe as u64).wrapping_mul(0xD134_2543_DE82_EF95));
3198 let mut q = vec![0.0; m];
3199 for qj in q.iter_mut() {
3200 *qj = rng.next_sign();
3201 }
3202 let z_norm2 = m as f64;
3203 let inv_norm = 1.0 / (m as f64).sqrt();
3204 for qj in q.iter_mut() {
3205 *qj *= inv_norm;
3206 }
3207 // Lanczos with full reorthogonalization.
3208 basis.clear();
3209 let mut alpha = Vec::with_capacity(steps);
3210 let mut beta: Vec<f64> = Vec::with_capacity(steps);
3211 let mut q_prev: Option<Vec<f64>> = None;
3212 for _step in 0..steps {
3213 // v = R⁻¹ A R⁻ᵀ q.
3214 prec.apply_r_inv_t(&q, &mut scratch_in);
3215 self.matvec(lambda, &scratch_in, &mut scratch_out);
3216 prec.apply_r_inv(&scratch_out, &mut vbuf);
3217 let mut v: Vec<f64> = vbuf.clone();
3218 let a: f64 = v.iter().zip(q.iter()).map(|(&x, &y)| x * y).sum();
3219 alpha.push(a);
3220 for j in 0..m {
3221 v[j] -= a * q[j];
3222 }
3223 if let Some(prev) = &q_prev {
3224 let b_prev = beta.last().copied().unwrap_or(0.0);
3225 for j in 0..m {
3226 v[j] -= b_prev * prev[j];
3227 }
3228 }
3229 // Full reorthogonalization against the stored basis.
3230 basis.push(q.clone());
3231 for qb in &basis {
3232 let proj: f64 = v.iter().zip(qb.iter()).map(|(&x, &y)| x * y).sum();
3233 for j in 0..m {
3234 v[j] -= proj * qb[j];
3235 }
3236 }
3237 let b: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
3238 if !(b.is_finite()) {
3239 return Err("residual cascade: Lanczos breakdown (non-finite norm)".into());
3240 }
3241 if b < 1e-13 {
3242 break;
3243 }
3244 beta.push(b);
3245 q_prev = Some(std::mem::replace(&mut q, v));
3246 for qj in q.iter_mut() {
3247 *qj /= b;
3248 }
3249 }
3250 beta.truncate(alpha.len().saturating_sub(1));
3251 let (theta, tau) = symmetric_tridiagonal_eigen(&alpha, &beta)?;
3252 let mut quad = 0.0;
3253 for (&t, &w0) in theta.iter().zip(tau.iter()) {
3254 // A Ritz value of an SPD operator is positive; a non-positive one
3255 // says the system is not PD, and that is the whole test.
3256 if !(t.is_finite() && t > 0.0) {
3257 return Err(format!(
3258 "residual cascade: non-positive Ritz value {t} in SLQ (system not PD)"
3259 ));
3260 }
3261 quad += w0 * w0 * t.ln();
3262 }
3263 trace_est += z_norm2 * quad;
3264 }
3265 Ok(logdet + trace_est / SLQ_PROBES as f64)
3266 }
3267
3268 /// Log-determinant through the most exact route available, with the route
3269 /// it took.
3270 ///
3271 /// `sparse` is the factor the caller has already built at this λ (see
3272 /// [`Self::sparse_exact_factor`]); one factorization serves both this
3273 /// determinant and every subsequent prediction solve, so it is threaded in
3274 /// rather than rebuilt here.
3275 fn logdet_with(
3276 &self,
3277 lambda: f64,
3278 sparse: Option<&SparseExactFactor>,
3279 ) -> Result<(f64, LogdetMethod), String> {
3280 if self.dense_gram.is_some() {
3281 return Ok((self.logdet_dense(lambda)?, LogdetMethod::DenseExact));
3282 }
3283 if let Some(factor) = sparse {
3284 // `2·Σ log L_jj` from an exact factorization of the very matrix
3285 // whose determinant is asked for. Not iterative, not stochastic.
3286 let logdet = logdet_from_factor(factor).map_err(|error| {
3287 format!("residual cascade: sparse log-determinant unavailable: {error}")
3288 })?;
3289 return Ok((logdet, LogdetMethod::SparseExact));
3290 }
3291 // The one route left that is not exact, and the only place a stochastic
3292 // determinant survives: the AMD ordering's fill-in on this design does
3293 // not fit `SPARSE_FACTOR_MAX_NNZ`, so no exact factorization exists to
3294 // read a diagonal off. It is REPORTED as `Slq` on the fit's certificate
3295 // and it underwrites nothing — `fit_reml` refuses at a far smaller
3296 // width, so no score sign, KKT root, or candidate ordering can ever
3297 // rest on this value.
3298 Ok((self.logdet_slq(lambda)?, LogdetMethod::Slq))
3299 }
3300
3301 /// Coefficient solve at λ: dense Cholesky when cached, else certified PCG.
3302 fn solve_coeff(
3303 &self,
3304 lambda: f64,
3305 b: &[f64],
3306 warm: Option<&[f64]>,
3307 ) -> Result<(Vec<f64>, f64, usize), String> {
3308 // A core rebuilt from a persisted state carries no training design, only
3309 // the factored precision `L` of `A = X'WX + λD` at the fit's λ. Replay
3310 // the solve through it (exact — predict always solves at that same λ).
3311 if let Some(l) = &self.predict_chol {
3312 return Ok((chol_solve(l, self.m, b), 0.0, 0));
3313 }
3314 if let Some(mut a) = self.dense_system(lambda) {
3315 cholesky_logdet(&mut a, self.m)?;
3316 return Ok((chol_solve(&a, self.m, b), 0.0, 0));
3317 }
3318 self.pcg(lambda, b, warm)
3319 }
3320
3321 /// Assemble the lower Cholesky factor `L` of `A = X'WX + λD` as a dense
3322 /// `m × m` row-major matrix — the factored precision a persisted predict
3323 /// replays its posterior-variance solve through. Uses the cached dense Gram
3324 /// when present; otherwise scatters the CSR row outer products into the
3325 /// upper triangle (one O(nnz·q) pass), the same assembly `build` uses under
3326 /// the sizing cap, just without the cap. Factoring is O(m³) — paid once at
3327 /// snapshot time, not per predict.
3328 fn assemble_predict_factor(&self, lambda: f64) -> Result<Vec<f64>, String> {
3329 let m = self.m;
3330 let mut a = vec![0.0_f64; m * m];
3331 if let Some(gram) = &self.dense_gram {
3332 for i in 0..m {
3333 for j in i..m {
3334 let v = gram[i * m + j];
3335 a[i * m + j] = v;
3336 a[j * m + i] = v;
3337 }
3338 }
3339 } else {
3340 for i in 0..self.w.len() {
3341 let lo = self.row_ptr[i];
3342 let hi = self.row_ptr[i + 1];
3343 for ea in lo..hi {
3344 let ca = self.col_idx[ea] as usize;
3345 let va = self.w[i] * self.vals[ea];
3346 for eb in ea..hi {
3347 let cb = self.col_idx[eb] as usize;
3348 a[ca * m + cb] += va * self.vals[eb];
3349 }
3350 }
3351 }
3352 // Mirror the upper triangle into the lower.
3353 for i in 0..m {
3354 for j in i + 1..m {
3355 a[j * m + i] = a[i * m + j];
3356 }
3357 }
3358 }
3359 for (i, d) in self.pen_diag.iter().enumerate() {
3360 a[i * m + i] += lambda * d;
3361 }
3362 cholesky_logdet(&mut a, m)?;
3363 Ok(a)
3364 }
3365
3366 /// Penalized residual quadratic at a solution: `y'Wy − c'X'Wy`.
3367 fn rss_pen(&self, coeff: &[f64]) -> f64 {
3368 let mut quad = 0.0;
3369 for (c, r) in coeff.iter().zip(self.rhs.iter()) {
3370 quad += c * r;
3371 }
3372 self.ytwy - quad
3373 }
3374
3375 /// Number of unpenalized (polynomial) columns.
3376 fn nullity(&self) -> usize {
3377 self.dim + 1
3378 }
3379
3380 /// Working residual `r_i = y_i − (Xc)_i`.
3381 fn residuals(&self, coeff: &[f64]) -> Vec<f64> {
3382 let n = self.y.len();
3383 let mut r = Vec::with_capacity(n);
3384 for i in 0..n {
3385 let mut fit = 0.0;
3386 for e in self.row_ptr[i]..self.row_ptr[i + 1] {
3387 fit += self.vals[e] * coeff[self.col_idx[e] as usize];
3388 }
3389 r.push(self.y[i] - fit);
3390 }
3391 r
3392 }
3393}
3394
3395// ──────────────────── symmetric tridiagonal eigensolver ─────────────────────
3396
3397/// Eigenvalues and FIRST eigenvector components of a symmetric tridiagonal
3398/// matrix (diag `d`, off-diagonal `e`), by implicit-shift QL with the
3399/// first-row vector carried through the rotations — exactly what Lanczos
3400/// quadrature needs.
3401fn symmetric_tridiagonal_eigen(d: &[f64], e: &[f64]) -> Result<(Vec<f64>, Vec<f64>), String> {
3402 let n = d.len();
3403 if n == 0 {
3404 return Ok((Vec::new(), Vec::new()));
3405 }
3406 let mut diag = d.to_vec();
3407 let mut off = vec![0.0; n];
3408 off[..n - 1].copy_from_slice(&e[..n - 1]);
3409 let mut first = vec![0.0; n];
3410 first[0] = 1.0;
3411 for l in 0..n {
3412 let mut iter = 0;
3413 loop {
3414 // Find a negligible off-diagonal to split at.
3415 let mut msplit = n - 1;
3416 for mm in l..n - 1 {
3417 let dd = diag[mm].abs() + diag[mm + 1].abs();
3418 if off[mm].abs() <= f64::EPSILON * dd {
3419 msplit = mm;
3420 break;
3421 }
3422 }
3423 if msplit == l {
3424 break;
3425 }
3426 iter += 1;
3427 if iter > 60 {
3428 return Err("residual cascade: tridiagonal QL failed to converge".into());
3429 }
3430 let mut g = (diag[l + 1] - diag[l]) / (2.0 * off[l]);
3431 let mut r = g.hypot(1.0);
3432 g = diag[msplit] - diag[l] + off[l] / (g + r.copysign(g));
3433 let (mut s, mut c) = (1.0, 1.0);
3434 let mut p = 0.0;
3435 let mut broke_early = false;
3436 for i in (l..msplit).rev() {
3437 let mut f = s * off[i];
3438 let b = c * off[i];
3439 r = f.hypot(g);
3440 off[i + 1] = r;
3441 if r == 0.0 {
3442 diag[i + 1] -= p;
3443 off[msplit] = 0.0;
3444 broke_early = true;
3445 break;
3446 }
3447 s = f / r;
3448 c = g / r;
3449 g = diag[i + 1] - p;
3450 r = (diag[i] - g) * s + 2.0 * c * b;
3451 p = s * r;
3452 diag[i + 1] = g + p;
3453 g = c * r - b;
3454 // Carry the first-row eigenvector components.
3455 f = first[i + 1];
3456 first[i + 1] = s * first[i] + c * f;
3457 first[i] = c * first[i] - s * f;
3458 }
3459 if broke_early {
3460 continue;
3461 }
3462 diag[l] -= p;
3463 off[l] = g;
3464 off[msplit] = 0.0;
3465 }
3466 }
3467 Ok((diag, first))
3468}
3469
3470// ───────────────────────────── net construction ─────────────────────────────
3471
3472/// Extend a nested net to covering radius `h` over the DOMAIN: first every data
3473/// point further than `h` from the (seeded) net becomes a new center, then every
3474/// cell of the `h`-grid over the bounding box `[0, box_hi]` whose centre is not
3475/// yet within `h` of the net is filled with a synthetic center. O((n + box
3476/// cells)·3^d). Returns the new centers.
3477///
3478/// Covering the box, not merely the data cloud, is what the multilevel Wendland
3479/// norm-equivalence (Narcowich–Ward inverse estimates + Le Gia–Wendland
3480/// multilevel stability) actually requires: the nested centres must be
3481/// quasi-uniform over the domain Ω. In data-dense regions every cell is already
3482/// covered by a data center, so the fill is a no-op there; in a data void it
3483/// plants the fine centres whose coefficients carry no data and revert to the
3484/// prior — the mechanism by which the posterior mean bridges a gap (coarse
3485/// data-pinned bumps) while the posterior variance GROWS into it (fine void
3486/// bumps the data cannot pin). The synthetic centres carry (almost) no data
3487/// rows, so their Gram diagonal is ~0 and they land in the penalty-dominated
3488/// fine block where the Jacobi preconditioner is exact — they neither perturb
3489/// the coarse factorization nor the n-independent iteration count.
3490fn extend_net(
3491 net: &mut Vec<[f64; 3]>,
3492 points: &[[f64; 3]],
3493 dim: usize,
3494 h: f64,
3495 box_hi: &[f64; 3],
3496) -> Vec<[f64; 3]> {
3497 let mut grid = HashGrid::new(h, dim);
3498 for (idx, c) in net.iter().enumerate() {
3499 grid.insert(idx as u32, c);
3500 }
3501 let h2 = h * h;
3502 let mut new_centers = Vec::new();
3503 let try_add = |net: &mut Vec<[f64; 3]>,
3504 grid: &mut HashGrid,
3505 new_centers: &mut Vec<[f64; 3]>,
3506 p: &[f64; 3]| {
3507 let mut covered = false;
3508 grid.for_neighbors(p, |j| {
3509 if !covered && dist2(p, &net[j as usize], dim) <= h2 {
3510 covered = true;
3511 }
3512 });
3513 if !covered {
3514 let idx = net.len() as u32;
3515 net.push(*p);
3516 grid.insert(idx, p);
3517 new_centers.push(*p);
3518 }
3519 };
3520 for p in points {
3521 try_add(net, &mut grid, &mut new_centers, p);
3522 if net.len() > MAX_CENTERS {
3523 return new_centers;
3524 }
3525 }
3526 // Fill the bounding box so the net covers the domain, not just the data.
3527 //
3528 // The box has ~`(box_hi/h)^dim` cells, so the fill cost grows like
3529 // `(2^l)^dim` as the covering radius `h = h₀·2^{-l}` shrinks with the
3530 // level `l`. At fine levels below the data spacing that is an explosion
3531 // (every sub-data-spacing cell of the whole domain becomes a synthetic
3532 // center), which is unbounded work the caller never needs: once the net
3533 // crosses `MAX_CENTERS` the build path errors and the auto-route's typed
3534 // next-level assessment reports center-capacity underresolution. So
3535 // cap the fill IN the loop — stop planting synthetic centers the moment
3536 // the net exceeds the cap rather than materializing the entire fine-level
3537 // box first. Coarse levels (few cells, never near the cap) keep the full
3538 // quasi-uniform domain fill and the polynomial-bridge gap behavior intact.
3539 let mut cells = [1_i64; 3];
3540 for a in 0..dim {
3541 cells[a] = (box_hi[a] / h).ceil() as i64 + 1;
3542 }
3543 let mut c = [0.0_f64; 3];
3544 'fill: for i0 in 0..cells[0] {
3545 c[0] = (i0 as f64 + 0.5) * h;
3546 for i1 in 0..cells[1] {
3547 if dim > 1 {
3548 c[1] = (i1 as f64 + 0.5) * h;
3549 }
3550 for i2 in 0..cells[2] {
3551 if dim > 2 {
3552 c[2] = (i2 as f64 + 0.5) * h;
3553 }
3554 try_add(net, &mut grid, &mut new_centers, &c);
3555 if net.len() > MAX_CENTERS {
3556 break 'fill;
3557 }
3558 }
3559 }
3560 }
3561 new_centers
3562}
3563
3564/// Bit-exact key of a scaled center, so a planned selection can be checked
3565/// against the candidates the nested net offers without a tolerance (both sides
3566/// are copies of the same `extend_net` output, never a recomputation).
3567fn center_key(center: &[f64; 3]) -> [u64; 3] {
3568 [
3569 center[0].to_bits(),
3570 center[1].to_bits(),
3571 center[2].to_bits(),
3572 ]
3573}
3574
3575/// One level of the cascade ladder: its resolution exponent `e` (radius
3576/// `h = h₀·2⁻ᵉ`) and, when a capacity budget forced the level to take only part
3577/// of what the net offered, the exact centers it carries.
3578#[derive(Clone, Debug)]
3579struct LevelPlan {
3580 exponent: f64,
3581 /// `None` is the complete dyadic level: every center the nested net plants
3582 /// at this radius.
3583 centers: Option<Vec<[f64; 3]>>,
3584}
3585
3586impl ResidualCascadeDesign {
3587 /// Build the cascade design: validate, scale by the metric, grow `levels`
3588 /// nested nets, and assemble the sparse design plus its sufficient
3589 /// statistics in O(n·(levels + 3^d)).
3590 ///
3591 /// `xs` holds one slice per axis (2 or 3 of them), `metric` the positive
3592 /// per-axis scaling of the learned metric, `sobolev_s` the Sobolev order
3593 /// of the equivalent (semi)norm — must satisfy `d/2 < s ≤ (d+3)/2` (the
3594 /// Wendland-(3,1) native smoothness).
3595 pub fn build(
3596 xs: &[&[f64]],
3597 y: &[f64],
3598 w: &[f64],
3599 metric: &[f64],
3600 sobolev_s: f64,
3601 levels: usize,
3602 ) -> Result<Self, String> {
3603 if levels == 0 || levels > MAX_LEVELS {
3604 return Err(format!(
3605 "residual cascade: levels must be in 1..={MAX_LEVELS}, got {levels}"
3606 ));
3607 }
3608 let level_exponents: Vec<f64> = (0..levels).map(|level| level as f64).collect();
3609 Self::build_at_exponents(xs, y, w, metric, sobolev_s, &level_exponents)
3610 }
3611
3612 /// Shared constructor for the dyadic ladder. An exponent `e` means
3613 /// `h = h₀·2⁻ᵉ`; [`Self::build`] supplies the one production ladder,
3614 /// `0, 1, …, levels−1`. The causal rank-boundary regression also evaluates
3615 /// fractional exponents through this same constructor, so its sub-level
3616 /// counterfactual cannot drift from the production basis construction.
3617 fn build_at_exponents(
3618 xs: &[&[f64]],
3619 y: &[f64],
3620 w: &[f64],
3621 metric: &[f64],
3622 sobolev_s: f64,
3623 level_exponents: &[f64],
3624 ) -> Result<Self, String> {
3625 let plan: Vec<LevelPlan> = level_exponents
3626 .iter()
3627 .map(|&exponent| LevelPlan {
3628 exponent,
3629 centers: None,
3630 })
3631 .collect();
3632 Self::build_from_plan(xs, y, w, metric, sobolev_s, &plan)
3633 }
3634
3635 /// Constructor the whole crate builds through: every level names its
3636 /// resolution exponent and, when a capacity budget forced it to take only
3637 /// part of the net's candidates, the EXACT centers it carries. Holding the
3638 /// selection in the plan rather than re-deriving it is what makes a partial
3639 /// level reproducible: the design is a pure function of the plan, so a
3640 /// re-build at the same plan is the same basis.
3641 fn build_from_plan(
3642 xs: &[&[f64]],
3643 y: &[f64],
3644 w: &[f64],
3645 metric: &[f64],
3646 sobolev_s: f64,
3647 plan: &[LevelPlan],
3648 ) -> Result<Self, String> {
3649 let levels = plan.len();
3650 let level_exponents: Vec<f64> = plan.iter().map(|level| level.exponent).collect();
3651 let level_exponents = level_exponents.as_slice();
3652 if levels == 0 || levels > MAX_LEVELS {
3653 return Err(format!(
3654 "residual cascade: levels must be in 1..={MAX_LEVELS}, got {levels}"
3655 ));
3656 }
3657 if level_exponents[0] != 0.0
3658 || level_exponents
3659 .iter()
3660 .any(|exponent| !exponent.is_finite() || *exponent < 0.0)
3661 || level_exponents.windows(2).any(|pair| pair[0] >= pair[1])
3662 {
3663 return Err(format!(
3664 "residual cascade: resolution exponents must start at zero and increase \
3665 strictly, got {level_exponents:?}"
3666 ));
3667 }
3668 let dim = xs.len();
3669 if !(dim == 2 || dim == 3) {
3670 return Err(format!(
3671 "residual cascade: built for scattered 2-3D smooths, got {dim} axes"
3672 ));
3673 }
3674 let n = y.len();
3675 if w.len() != n || xs.iter().any(|x| x.len() != n) {
3676 return Err(format!(
3677 "residual cascade: length mismatch (y={n}, w={}, axes={:?})",
3678 w.len(),
3679 xs.iter().map(|x| x.len()).collect::<Vec<_>>()
3680 ));
3681 }
3682 if n <= dim + 1 {
3683 return Err(format!(
3684 "residual cascade: needs more than {} rows for the profiled REML degrees of \
3685 freedom, got {n}",
3686 dim + 1
3687 ));
3688 }
3689 if metric.len() != dim || metric.iter().any(|&s| !(s.is_finite() && s > 0.0)) {
3690 return Err(format!(
3691 "residual cascade: metric must be {dim} finite positive scales, got {metric:?}"
3692 ));
3693 }
3694 if !(sobolev_s > dim as f64 / 2.0 && sobolev_s <= (dim as f64 + 3.0) / 2.0) {
3695 return Err(format!(
3696 "residual cascade: sobolev_s must lie in (d/2, (d+3)/2] = ({}, {}] for the \
3697 Wendland-(3,1) bump, got {sobolev_s}",
3698 dim as f64 / 2.0,
3699 (dim as f64 + 3.0) / 2.0
3700 ));
3701 }
3702 for i in 0..n {
3703 if !(y[i].is_finite() && w[i].is_finite() && w[i] > 0.0)
3704 || xs.iter().any(|x| !x[i].is_finite())
3705 {
3706 return Err(format!(
3707 "residual cascade: non-finite or non-positive input at row {i}"
3708 ));
3709 }
3710 }
3711 // Scaled, corner-shifted coordinates.
3712 let mut z_lo = [f64::INFINITY; 3];
3713 let mut z_hi = [f64::NEG_INFINITY; 3];
3714 for a in 0..dim {
3715 for &v in xs[a] {
3716 let s = metric[a] * v;
3717 z_lo[a] = z_lo[a].min(s);
3718 z_hi[a] = z_hi[a].max(s);
3719 }
3720 }
3721 let mut z_range = [1.0_f64; 3];
3722 let mut max_range = 0.0_f64;
3723 for a in 0..dim {
3724 if !(z_hi[a] > z_lo[a]) {
3725 return Err(format!(
3726 "residual cascade: degenerate axis {a} bounding box [{}, {}]",
3727 z_lo[a], z_hi[a]
3728 ));
3729 }
3730 z_range[a] = z_hi[a] - z_lo[a];
3731 max_range = max_range.max(z_range[a]);
3732 }
3733 for a in dim..3 {
3734 z_lo[a] = 0.0;
3735 }
3736 let z: Vec<[f64; 3]> = (0..n)
3737 .map(|i| {
3738 let mut p = [0.0_f64; 3];
3739 for a in 0..dim {
3740 p[a] = metric[a] * xs[a][i] - z_lo[a];
3741 }
3742 p
3743 })
3744 .collect();
3745 let mut metric3 = [1.0_f64; 3];
3746 metric3[..dim].copy_from_slice(metric);
3747
3748 let h0 = H0_FRACTION * max_range;
3749 let mut net: Vec<[f64; 3]> = Vec::new();
3750 let mut level_specs = Vec::with_capacity(levels);
3751 let mut col = dim + 1;
3752 let mut pen_logdet_const = 0.0;
3753 for (l, planned) in plan.iter().enumerate() {
3754 let exponent = planned.exponent;
3755 let h = h0 * 0.5_f64.powf(exponent);
3756 // The candidate set is derived the SAME way for a complete and a
3757 // partial level — one `extend_net` against the net built so far —
3758 // so a selection can only ever name centers the nested net would
3759 // have planted anyway. A partial level then plants exactly its
3760 // selection, leaving the rest of the candidates for a later level
3761 // to cover at a finer radius.
3762 let mut probe = net.clone();
3763 let candidates = extend_net(&mut probe, &z, dim, h, &z_range);
3764 let new_centers = match &planned.centers {
3765 None => {
3766 net = probe;
3767 candidates
3768 }
3769 Some(selection) => {
3770 let admissible: std::collections::HashSet<[u64; 3]> =
3771 candidates.iter().map(center_key).collect();
3772 if selection.is_empty() {
3773 return Err(format!(
3774 "residual cascade: level {l} selects no centers at exponent {exponent}"
3775 ));
3776 }
3777 let mut seen: std::collections::HashSet<[u64; 3]> =
3778 std::collections::HashSet::with_capacity(selection.len());
3779 for center in selection {
3780 let key = center_key(center);
3781 if !admissible.contains(&key) || !seen.insert(key) {
3782 return Err(format!(
3783 "residual cascade: level {l} selects {center:?}, which the nested \
3784 net does not offer as a distinct candidate at exponent {exponent}"
3785 ));
3786 }
3787 }
3788 net.extend_from_slice(selection);
3789 selection.clone()
3790 }
3791 };
3792 if net.len() > MAX_CENTERS {
3793 return Err(format!(
3794 "residual cascade: center cap {MAX_CENTERS} exceeded at level {l}"
3795 ));
3796 }
3797 let weight = level_weight(exponent, sobolev_s, dim);
3798 pen_logdet_const += new_centers.len() as f64 * weight.ln();
3799 let delta = OVERLAP * h;
3800 let mut grid = HashGrid::new(delta, dim);
3801 for (j, c) in new_centers.iter().enumerate() {
3802 grid.insert(j as u32, c);
3803 }
3804 let col_offset = col;
3805 col += new_centers.len();
3806 level_specs.push(Level {
3807 h,
3808 delta,
3809 weight,
3810 centers: new_centers,
3811 col_offset,
3812 grid,
3813 });
3814 }
3815 let m = col;
3816
3817 // CSR assembly + sufficient statistics in one pass.
3818 let mut row_ptr = Vec::with_capacity(n + 1);
3819 row_ptr.push(0_usize);
3820 let mut col_idx: Vec<u32> = Vec::new();
3821 let mut vals: Vec<f64> = Vec::new();
3822 let mut rhs = vec![0.0_f64; m];
3823 let mut gram_diag = vec![0.0_f64; m];
3824 let mut ytwy = 0.0_f64;
3825 let probe_core = CoreScaffold {
3826 dim,
3827 z_range,
3828 levels: &level_specs,
3829 };
3830 for i in 0..n {
3831 let row = probe_core.basis_row(&z[i]);
3832 for &(c, v) in &row {
3833 col_idx.push(c as u32);
3834 vals.push(v);
3835 rhs[c] += w[i] * y[i] * v;
3836 gram_diag[c] += w[i] * v * v;
3837 }
3838 ytwy += w[i] * y[i] * y[i];
3839 row_ptr.push(col_idx.len());
3840 }
3841 let mut pen_diag = vec![0.0_f64; m];
3842 for level in &level_specs {
3843 for j in 0..level.centers.len() {
3844 pen_diag[level.col_offset + j] = level.weight;
3845 }
3846 }
3847
3848 // Dense Gram cache under the sizing cap: O(n·q²) scatter of row outer
3849 // products into the upper triangle.
3850 let dense_gram = if m <= DENSE_GRAM_MAX {
3851 let mut gram = vec![0.0_f64; m * m];
3852 for i in 0..n {
3853 let lo = row_ptr[i];
3854 let hi = row_ptr[i + 1];
3855 for ea in lo..hi {
3856 let ca = col_idx[ea] as usize;
3857 let va = w[i] * vals[ea];
3858 for eb in ea..hi {
3859 gram[ca * m + col_idx[eb] as usize] += va * vals[eb];
3860 }
3861 }
3862 }
3863 Some(gram)
3864 } else {
3865 None
3866 };
3867
3868 Ok(ResidualCascadeDesign {
3869 core: Arc::new(Core {
3870 dim,
3871 metric: metric3,
3872 z_lo,
3873 z_range,
3874 sobolev_s,
3875 levels: level_specs,
3876 net,
3877 m,
3878 row_ptr,
3879 col_idx,
3880 vals,
3881 w: w.to_vec(),
3882 y: y.to_vec(),
3883 z,
3884 rhs,
3885 ytwy,
3886 gram_diag,
3887 pen_diag,
3888 pen_logdet_const,
3889 dense_gram,
3890 predict_chol: None,
3891 }),
3892 })
3893 }
3894
3895 /// Number of resolution levels.
3896 pub fn num_levels(&self) -> usize {
3897 self.core.levels.len()
3898 }
3899
3900 /// Aspect ratio of the metric-scaled point cloud: the ratio of the largest
3901 /// to smallest per-axis standard deviation of the scaled coordinates `z`.
3902 /// This is the metric-condition measure the quasi-uniformity guard (issue
3903 /// #1032, caveat 2) keys on — see `QUASI_UNIFORMITY_MAX_ASPECT`. A value
3904 /// near 1 is an isotropic (benign) cloud; a large value means the metric
3905 /// has collapsed the data onto a lower-dimensional sheet in `z`, breaking
3906 /// the BPX n-independent iteration bound.
3907 pub fn metric_scaled_aspect_ratio(&self) -> f64 {
3908 let dim = self.core.dim;
3909 let n = self.core.z.len();
3910 if dim == 0 || n == 0 {
3911 return 1.0;
3912 }
3913 let mut mean = [0.0_f64; 3];
3914 for p in &self.core.z {
3915 for a in 0..dim {
3916 mean[a] += p[a];
3917 }
3918 }
3919 for m in mean.iter_mut().take(dim) {
3920 *m /= n as f64;
3921 }
3922 let mut var = [0.0_f64; 3];
3923 for p in &self.core.z {
3924 for a in 0..dim {
3925 let d = p[a] - mean[a];
3926 var[a] += d * d;
3927 }
3928 }
3929 let mut sd_lo = f64::INFINITY;
3930 let mut sd_hi = 0.0_f64;
3931 for v in var.iter().take(dim) {
3932 let sd = (v / n as f64).sqrt();
3933 sd_lo = sd_lo.min(sd);
3934 sd_hi = sd_hi.max(sd);
3935 }
3936 if !(sd_lo > 0.0 && sd_lo.is_finite()) {
3937 // A collapsed axis (zero scaled spread) is maximally degenerate.
3938 return f64::INFINITY;
3939 }
3940 sd_hi / sd_lo
3941 }
3942
3943 /// Quasi-uniformity certificate (issue #1032, caveat 2): `true` iff the
3944 /// metric-scaled cloud is isotropic enough that the BPX n-independent CG
3945 /// iteration bound is trustworthy. When this returns `false`, automatic
3946 /// fitting must return a typed refusal rather than pay an iterative solve
3947 /// whose iteration count is no longer n-independent. The CG residual
3948 /// certificate would still *catch* a mis-solve at `CG_MAX_ITERS`, but
3949 /// the guard prevents the silent O(n·iters) blow-up up front.
3950 pub fn quasi_uniformity_certified(&self) -> bool {
3951 self.metric_scaled_aspect_ratio() <= QUASI_UNIFORMITY_MAX_ASPECT
3952 }
3953
3954 /// Total centers across all levels.
3955 pub fn num_centers(&self) -> usize {
3956 self.core.m - self.core.nullity()
3957 }
3958
3959 /// NEW centers of one level in ORIGINAL (unscaled) coordinates.
3960 pub fn centers(&self, level: usize) -> Vec<Vec<f64>> {
3961 let lv = &self.core.levels[level];
3962 lv.centers
3963 .iter()
3964 .map(|c| {
3965 (0..self.core.dim)
3966 .map(|a| (c[a] + self.core.z_lo[a]) / self.core.metric[a])
3967 .collect()
3968 })
3969 .collect()
3970 }
3971
3972 /// Sparse basis row at a raw point, as (column, value) pairs sorted by
3973 /// column within each block — the exact row the fit used for training
3974 /// rows, exposed so oracles can assemble the dense system independently.
3975 pub fn basis_row(&self, x: &[f64]) -> Result<Vec<(usize, f64)>, String> {
3976 self.check_point(x)?;
3977 Ok(self.core.basis_row_scaled(&self.core.scale_point(x)))
3978 }
3979
3980 fn check_point(&self, x: &[f64]) -> Result<(), String> {
3981 if x.len() != self.core.dim || x.iter().any(|v| !v.is_finite()) {
3982 return Err(format!(
3983 "residual cascade: point must be {} finite coordinates, got {x:?}",
3984 self.core.dim
3985 ));
3986 }
3987 Ok(())
3988 }
3989
3990 /// Exact penalty quadratic `c'Dc` (unit-λ multilevel prior energy).
3991 pub fn penalty_value(&self, coeff: &[f64]) -> Result<f64, String> {
3992 if coeff.len() != self.core.m {
3993 return Err(format!(
3994 "residual cascade: coefficient length {} != {}",
3995 coeff.len(),
3996 self.core.m
3997 ));
3998 }
3999 Ok(coeff
4000 .iter()
4001 .zip(self.core.pen_diag.iter())
4002 .map(|(&c, &d)| d * c * c)
4003 .sum())
4004 }
4005
4006 /// Profiled-σ² REML criterion at `log λ` (differences across λ are
4007 /// exact-real certifiable on the dense route; one fixed numerical spectral
4008 /// quadrature is used for diagnostic evaluation past the cap).
4009 pub fn criterion(&self, log_lambda: f64) -> Result<f64, String> {
4010 Ok(self.core.reml_profile()?.evaluate(log_lambda)?.jet.value)
4011 }
4012
4013 /// Fit at a FIXED `log λ`, with σ² either supplied or profiled.
4014 pub fn fit_at(
4015 &self,
4016 log_lambda: f64,
4017 sigma2: Option<f64>,
4018 ) -> Result<ResidualCascadeFit, String> {
4019 self.fit_at_with_warm(log_lambda, sigma2, None, None)
4020 }
4021
4022 fn fit_at_with_warm(
4023 &self,
4024 log_lambda: f64,
4025 sigma2: Option<f64>,
4026 warm: Option<&[f64]>,
4027 profile_normalized_logdet: Option<f64>,
4028 ) -> Result<ResidualCascadeFit, String> {
4029 let core = &self.core;
4030 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
4031 .map_err(|error| format!("residual cascade: {error}"))?;
4032 // Exact sparse-direct factor at this λ, past the dense Gram cache. One
4033 // factorization serves the log-determinant AND every prediction solve
4034 // this fit will later perform, so it is built once here. A core rebuilt
4035 // from a persisted state has no CSR design to assemble it from and
4036 // carries its own dense factor instead.
4037 let sparse_factor = if core.dense_gram.is_none() && core.predict_chol.is_none() {
4038 core.sparse_exact_factor(lambda)?.map(Arc::new)
4039 } else {
4040 None
4041 };
4042 let (coeff, rel_res, iters) = core.solve_coeff(lambda, &core.rhs, warm)?;
4043 let rss_pen = core.rss_pen(&coeff);
4044 let dof = (core.y.len() - core.nullity()) as f64;
4045 let sigma2 = match sigma2 {
4046 Some(s) => {
4047 if !(s.is_finite() && s > 0.0) {
4048 return Err(format!("residual cascade: invalid sigma2 {s}"));
4049 }
4050 s
4051 }
4052 None => {
4053 if !(rss_pen > 0.0) {
4054 return Err(format!(
4055 "residual cascade: degenerate penalized residual {rss_pen}"
4056 ));
4057 }
4058 rss_pen / dof
4059 }
4060 };
4061 let r = (core.m - core.nullity()) as f64;
4062 let (logdet, logdet_method) = match profile_normalized_logdet {
4063 // Supplied only by `fit_reml`, which refuses unless the exact
4064 // lambda-independent Schur spectrum was formed — so a normalized
4065 // logdet arriving here is exact dense linear algebra by
4066 // construction, at every width the certified route admits.
4067 Some(normalized) => (
4068 normalized + r * log_lambda + core.pen_logdet_const,
4069 LogdetMethod::DenseExact,
4070 ),
4071 None => core.logdet_with(lambda, sparse_factor.as_deref())?,
4072 };
4073 // Full restricted log-likelihood at this (λ, σ²) up to λ- and σ-free
4074 // constants; at the profiled σ̂² the quadratic collapses to `dof`.
4075 let restricted_loglik = -0.5
4076 * (logdet - r * log_lambda - core.pen_logdet_const
4077 + dof * sigma2.ln()
4078 + rss_pen / sigma2);
4079 let predict_chol = if core.dense_gram.is_some() {
4080 Some(core.assemble_predict_factor(lambda)?)
4081 } else {
4082 None
4083 };
4084 Ok(ResidualCascadeFit {
4085 core: Arc::clone(&self.core),
4086 training_sample_size: std::num::NonZeroUsize::new(core.y.len())
4087 .expect("ResidualCascadeDesign requires training rows"),
4088 predict_chol,
4089 predict_sparse: sparse_factor,
4090 coeff,
4091 log_lambda,
4092 sigma2,
4093 restricted_loglik,
4094 rss_pen,
4095 certificate: CascadeCertificate {
4096 solve_rel_residual: rel_res,
4097 solve_iters: iters,
4098 logdet_method,
4099 },
4100 refinement: None,
4101 })
4102 }
4103
4104 /// Fit with `log λ` selected by the profiled REML criterion. Every
4105 /// stationary interval in the bounded domain is isolated from analytic
4106 /// derivative enclosures, refined by safeguarded Newton/bisection, and
4107 /// compared with both exact boundary candidates.
4108 ///
4109 /// Automatic selection is limited to designs whose λ-independent Schur
4110 /// spectrum can be formed, i.e. inside `CERTIFIED_SPECTRUM_MAX` — NOT to
4111 /// designs that carry a dense Gram cache. The two used to be the same gate,
4112 /// which meant a cascade whose refinement legitimately crossed
4113 /// `DENSE_GRAM_MAX` could be fitted but never certified, and so could not
4114 /// finish at all (#2546). The Gram is a cache; the spectrum is the proof.
4115 ///
4116 /// Past the spectrum budget there is no exact-real enclosure of the score,
4117 /// even when the separate β-seeded residual Krylov space closes: a pointwise
4118 /// solve, factorization, or quadrature value cannot certify a score sign, a
4119 /// stationary point, or a global ordering. Returning a typed refusal is the
4120 /// only sound result there; [`Self::fit_at`] remains available when the user
4121 /// explicitly fixes the smoothing parameter.
4122 pub fn fit_reml(&self) -> Result<ResidualCascadeFit, ResidualCascadeError> {
4123 if self.core.m > CERTIFIED_SPECTRUM_MAX {
4124 return Err(ResidualCascadeError::RemlScoreProofUnavailable {
4125 columns: self.core.m,
4126 certified_spectrum_max: CERTIFIED_SPECTRUM_MAX,
4127 });
4128 }
4129 let profile = self.core.reml_profile()?;
4130 let (log_lambda_lo, log_lambda_hi) = profile.log_lambda_domain()?;
4131 let resolution = f64::EPSILON.sqrt();
4132 let failed = |error: &dyn std::fmt::Display| {
4133 format!("residual cascade: REML stationary isolation failed: {error}")
4134 };
4135 let affine = profile.affine_view()?.ok_or(
4136 ResidualCascadeError::RemlScoreProofUnavailable {
4137 columns: self.core.m,
4138 certified_spectrum_max: CERTIFIED_SPECTRUM_MAX,
4139 },
4140 )?;
4141 // The budget refusal is re-typed here rather than formatted into the
4142 // generic computation failure: a nameless refusal at a derived depth
4143 // costs a day of triage, and the two numbers that explain it —
4144 // `rank` against `n - nullity` — are only available at this seam
4145 // (#2546).
4146 let search = match affine.maximize_value_ordered(
4147 log_lambda_lo,
4148 log_lambda_hi,
4149 resolution,
4150 ) {
4151 Ok(search) => search,
4152 Err(gam_math::score_opt::ScoreSearchError::SubdivisionBudget {
4153 subdivisions,
4154 budget,
4155 ..
4156 }) => {
4157 let nullity = self.core.nullity();
4158 return Err(ResidualCascadeError::RemlScoreSearchUndecomposable {
4159 columns: self.core.m,
4160 rank: self.core.m - nullity,
4161 identifiable: self.core.y.len().saturating_sub(nullity),
4162 subdivisions,
4163 budget,
4164 log_lambda_lo,
4165 log_lambda_hi,
4166 });
4167 }
4168 Err(error) => return Err(ResidualCascadeError::Computation(failed(&error))),
4169 };
4170 if search.value_certificate.maximum_excess
4171 > search.value_certificate.comparison_resolution
4172 {
4173 return Err(ResidualCascadeError::RemlValueOrderingUnresolved {
4174 maximum_excess: search.value_certificate.maximum_excess,
4175 comparison_resolution: search.value_certificate.comparison_resolution,
4176 });
4177 }
4178 enum KktKind {
4179 LowerBoundary,
4180 UpperBoundary,
4181 Stationary,
4182 }
4183 let (bracket, kkt_kind) = match search.location {
4184 gam_math::score_opt::ScoreOptimumLocation::LowerBoundary => (
4185 gam_math::score_opt::ClosedInterval::point(search.lower_boundary.x),
4186 KktKind::LowerBoundary,
4187 ),
4188 gam_math::score_opt::ScoreOptimumLocation::UpperBoundary => (
4189 gam_math::score_opt::ClosedInterval::point(search.upper_boundary.x),
4190 KktKind::UpperBoundary,
4191 ),
4192 gam_math::score_opt::ScoreOptimumLocation::Stationary(index) => (
4193 search
4194 .stationary_points
4195 .get(index)
4196 .ok_or_else(|| {
4197 ResidualCascadeError::Computation(
4198 "residual cascade: optimizer returned an invalid stationary index"
4199 .to_string(),
4200 )
4201 })?
4202 .bracket,
4203 KktKind::Stationary,
4204 ),
4205 gam_math::score_opt::ScoreOptimumLocation::ResolutionFlat(index) => {
4206 let flat = search.resolution_flat_regions.get(index).ok_or_else(|| {
4207 ResidualCascadeError::Computation(
4208 "residual cascade: optimizer returned an invalid resolution-flat index"
4209 .to_string(),
4210 )
4211 })?;
4212 return Err(ResidualCascadeError::RemlOptimumResolutionFlat {
4213 lo: flat.bracket.lo,
4214 hi: flat.bracket.hi,
4215 max_score_gap: flat.max_score_gap,
4216 score_resolution: flat.score_resolution,
4217 });
4218 }
4219 };
4220 let kkt = affine
4221 .enclose(bracket.lo, bracket.hi)
4222 .map_err(|error| ResidualCascadeError::Computation(failed(&error)))?;
4223 let kkt_holds = match kkt_kind {
4224 KktKind::LowerBoundary => kkt.derivative.hi <= 0.0,
4225 KktKind::UpperBoundary => kkt.derivative.lo >= 0.0,
4226 KktKind::Stationary => {
4227 kkt.derivative.contains_zero() && kkt.curvature.hi < 0.0
4228 }
4229 };
4230 if !kkt_holds {
4231 return Err(ResidualCascadeError::Computation(format!(
4232 "residual cascade: exact-real REML KKT certificate failed on \
4233 {bracket:?}: {kkt:?}"
4234 )));
4235 }
4236 let selected_log_lambda = search.optimum.x;
4237 let selected = profile.evaluate(selected_log_lambda)?;
4238 Ok(self.fit_at_with_warm(
4239 selected_log_lambda,
4240 None,
4241 None,
4242 Some(selected.normalized_logdet),
4243 )?)
4244 }
4245
4246 /// Assess the candidate level at `exponent` AND decide what the refinement
4247 /// may actually take from it.
4248 ///
4249 /// The assessment is always over the COMPLETE candidate set, because that is
4250 /// the quantity the refinement decision has to be taken on: a bound over a
4251 /// subset would certify nothing about the candidates left out, and the
4252 /// Occam factor it is compared against is the complete set's too. The SELECTION is a different question,
4253 /// and it is the one the capacity budgets answer — how many more penalized
4254 /// modes this design may carry before automatic REML loses the rank it
4255 /// needs (`n − nullity` identifiable directions) or the certified spectrum
4256 /// outruns its memory ([`CERTIFIED_SPECTRUM_MAX`]).
4257 ///
4258 /// Those two questions used to share one answer: a candidate level wider
4259 /// than the budget was refused whole, so a refinement whose gain is carried
4260 /// by a handful of centers was blocked by the CARDINALITY of the proposal
4261 /// rather than by anything about the gain (#2700). A capacity limit is now
4262 /// a refusal only when the budget is exhausted — when nothing at all can be
4263 /// added. Otherwise the level is taken partially, largest `|g_j|` first,
4264 /// which is the ordering that maximizes the captured share of the same
4265 /// `Σ_j g_j²` the bound is made of.
4266 fn plan_level_at_exponent(
4267 &self,
4268 fit: &ResidualCascadeFit,
4269 exponent: f64,
4270 screen: Option<EvidenceScale>,
4271 ) -> Result<NextLevelPlan, String> {
4272 let core = &self.core;
4273 if !Arc::ptr_eq(core, &fit.core) {
4274 return Err("residual cascade: fit does not belong to this design".into());
4275 }
4276 let next_l = core.levels.len();
4277 let h = core.levels[0].h * 0.5_f64.powf(exponent);
4278 // `h == h_L` is the RE-assessment of a level that capacity forced to be
4279 // partial: the candidates it had to leave behind are exactly what
4280 // `extend_net` still offers at that radius, and their gain is what
4281 // decides whether the cascade has converged there.
4282 if !(exponent.is_finite() && h > 0.0 && h <= core.levels[next_l - 1].h) {
4283 return Err(format!(
4284 "residual cascade: next resolution exponent {exponent} does not refine the \
4285 current radius {}",
4286 core.levels[next_l - 1].h
4287 ));
4288 }
4289 // A level finer than the current one is a NEW level; re-assessing the
4290 // finest radius extends the level already there and cannot exhaust the
4291 // level count. Decided from the RADIUS alone, before any candidate set
4292 // exists, because it describes where the set would go and not what is
4293 // in it — every outcome below has to carry it, including the exhausted
4294 // ones, or a caller that materializes the candidate set from the plan
4295 // plants a second level at a radius that already has one.
4296 let extends_last = h == core.levels[next_l - 1].h;
4297 let mut net = core.net.clone();
4298 let candidates = extend_net(&mut net, &core.z, core.dim, h, &core.z_range);
4299 if candidates.is_empty() {
4300 return Ok(NextLevelPlan::exhausted(
4301 NextLevelAssessment::EmptyNet,
4302 extends_last,
4303 ));
4304 }
4305 if net.len() > MAX_CENTERS {
4306 return Ok(NextLevelPlan::exhausted(
4307 NextLevelAssessment::CapacityExceeded {
4308 obstruction: RefinementObstruction::CenterCapacity {
4309 centers: net.len(),
4310 maximum_centers: MAX_CENTERS,
4311 },
4312 // The cap stopped candidate construction before every column
4313 // could contribute to ‖X₂'Wr̂‖². Infinity is the honest
4314 // conservative upper bound; a finite partial sum would not
4315 // certify the omitted columns.
4316 gain_bound: f64::INFINITY,
4317 },
4318 extends_last,
4319 ));
4320 }
4321 let delta = OVERLAP * h;
4322 let mut grid = HashGrid::new(delta, core.dim);
4323 for (j, c) in candidates.iter().enumerate() {
4324 grid.insert(j as u32, c);
4325 }
4326 let r = core.residuals(&fit.coeff);
4327 let mut g = vec![0.0_f64; candidates.len()];
4328 // Which candidates carry a row at all. A candidate whose bump covers no
4329 // observation is an exactly zero column: it contributes nothing to `g`,
4330 // nothing to the Schur complement, and — because its `λd` diagonal
4331 // appears identically in `log|A|` and in `log|λD|₊` — nothing to the
4332 // restricted likelihood either. Dropping it from the design the
4333 // comparison is taken on is therefore an identity, not an
4334 // approximation, and it is worth taking: at the radii this decision is
4335 // made at, most of a dyadic level's centers sit between data points.
4336 let mut supported = vec![false; candidates.len()];
4337 for (i, zi) in core.z.iter().enumerate() {
4338 let wr = core.w[i] * r[i];
4339 grid.for_neighbors(zi, |j| {
4340 let j = j as usize;
4341 let rad = dist2(zi, &candidates[j], core.dim).sqrt() / delta;
4342 let value = wendland(rad);
4343 if value != 0.0 {
4344 supported[j] = true;
4345 }
4346 g[j] += wr * value;
4347 });
4348 }
4349 let supported: Vec<[f64; 3]> = candidates
4350 .iter()
4351 .zip(supported.iter())
4352 .filter_map(|(center, &carries)| carries.then_some(*center))
4353 .collect();
4354 let d_next = level_weight(exponent, core.sobolev_s, core.dim);
4355 let lambda = gam_problem::checked_exp_log_strength(fit.log_lambda)
4356 .map_err(|error| format!("residual cascade refinement: {error}"))?;
4357 let bracket = certified_refinement_gain(
4358 core,
4359 &CandidateLevel {
4360 centers: &candidates,
4361 grid: &grid,
4362 delta,
4363 ridge: lambda * d_next,
4364 },
4365 &g,
4366 lambda,
4367 screen,
4368 )?;
4369 let gain_bound = bracket.upper;
4370 let candidate_penalized_modes = net.len();
4371 let candidate_columns = core.nullity() + candidate_penalized_modes;
4372 let identifiable_directions = core.y.len().saturating_sub(core.nullity());
4373 if !extends_last && next_l >= MAX_LEVELS {
4374 return Ok(NextLevelPlan::exhausted_with(
4375 NextLevelAssessment::CapacityExceeded {
4376 obstruction: RefinementObstruction::LevelCapacity {
4377 levels: next_l,
4378 maximum_levels: MAX_LEVELS,
4379 },
4380 gain_bound,
4381 },
4382 bracket,
4383 supported,
4384 extends_last,
4385 ));
4386 }
4387 // Penalized modes this design may still carry with the certified
4388 // automatic route intact. Both bounds are structural, neither is a
4389 // tuning parameter: past `identifiable_directions = n − nullity` the
4390 // REML score is flat by rank deficiency, and past
4391 // `CERTIFIED_SPECTRUM_MAX` columns the exact Schur eigendecomposition
4392 // the score enclosure is built from exceeds its memory budget.
4393 let spectrum_modes = CERTIFIED_SPECTRUM_MAX.saturating_sub(core.nullity());
4394 let capacity_modes = identifiable_directions.min(spectrum_modes);
4395 let budget = capacity_modes
4396 .saturating_sub(core.net.len())
4397 .min(candidates.len());
4398 if budget == 0 {
4399 // Nothing may be added at all — the only shape in which a capacity
4400 // limit is a refusal. The obstruction names the bound that binds,
4401 // and the evidence stays the COMPLETE proposal: that is what the
4402 // cascade still has to add and cannot.
4403 let obstruction = if identifiable_directions <= spectrum_modes {
4404 RefinementObstruction::IdentifiabilityCapacity {
4405 candidate_columns,
4406 candidate_penalized_modes,
4407 identifiable_directions,
4408 }
4409 } else {
4410 RefinementObstruction::CertifiedSpectrumCapacity {
4411 candidate_columns,
4412 certified_spectrum_max: CERTIFIED_SPECTRUM_MAX,
4413 }
4414 };
4415 return Ok(NextLevelPlan::exhausted_with(
4416 NextLevelAssessment::CapacityExceeded {
4417 obstruction,
4418 gain_bound,
4419 },
4420 bracket,
4421 supported,
4422 extends_last,
4423 ));
4424 }
4425 let candidate_count = candidates.len();
4426 let selection = if budget == candidate_count {
4427 candidates
4428 } else {
4429 // Largest |g_j| first. The ordering is on the very terms of
4430 // `Σ_j g_j²`, so the retained subset carries the largest share of
4431 // the bound any subset of this size can; ties break on candidate
4432 // index, which `extend_net` fixes deterministically.
4433 let mut order: Vec<usize> = (0..candidates.len()).collect();
4434 order.sort_by(|&a, &b| {
4435 g[b].abs()
4436 .partial_cmp(&g[a].abs())
4437 .unwrap_or(std::cmp::Ordering::Equal)
4438 .then(a.cmp(&b))
4439 });
4440 order.truncate(budget);
4441 order.sort_unstable();
4442 order.into_iter().map(|j| candidates[j]).collect()
4443 };
4444 let complete = selection.len() == candidate_count;
4445 Ok(NextLevelPlan {
4446 assessment: NextLevelAssessment::GainBound(gain_bound),
4447 gain: Some(bracket),
4448 selection,
4449 supported,
4450 complete,
4451 extends_last,
4452 })
4453 }
4454}
4455
4456/// The two scalars a gain is turned into evidence by: the incumbent's penalized
4457/// residual and the restricted degrees of freedom, both of which are properties
4458/// of the FIT rather than of the candidate set.
4459///
4460/// Passing one to a gain bracket ARMS the free screen. Hadamard on
4461/// `S ⪯ diag(X₂ᵀWX₂) + λd` bounds the Occam factor from above for nothing, so
4462/// the gain at which THAT bound breaks even is an upper bound on the true
4463/// break-even gain: a bracket whose lower end clears it proves one more level
4464/// warranted without building the refined design, and a bracket that falls
4465/// below it proves nothing — which is exactly when the exact comparison has to
4466/// run, and therefore exactly when further bracket iterations are waste.
4467/// Without one, the bracket closes as far as the Krylov space allows, because
4468/// no comparison is pending and only the number is wanted.
4469#[derive(Clone, Copy, Debug)]
4470struct EvidenceScale {
4471 rss_pen: f64,
4472 dof: f64,
4473}
4474
4475impl EvidenceScale {
4476 /// The gain at which a candidate set whose Occam factor is `occam` breaks
4477 /// even: `rss_pen·(1 − e^{−occam/dof})`, the objective decrease that set's
4478 /// own dimension already pays for. Below it the restricted likelihood
4479 /// falls, above it the likelihood rises, and at it they are equal — which
4480 /// is why this is a derivation and not a tolerance.
4481 fn break_even_gain(&self, occam: f64) -> f64 {
4482 -self.rss_pen * (-occam / self.dof).exp_m1()
4483 }
4484
4485 /// The restricted log-likelihood change a candidate set with this `gain` and
4486 /// this `occam` produces: `[dof·log(rss/(rss − gain)) − occam]/2`. Increasing
4487 /// in `gain`, decreasing in `occam` — which is what lets a lower bound on
4488 /// one and an upper bound on the other CERTIFY a positive value.
4489 fn evidence(&self, gain: f64, occam: f64) -> f64 {
4490 // A gain cannot exceed the residual it decreases — the objective is
4491 // bounded below by zero — so the ratio is clamped into `[0, 1]` rather
4492 // than allowed to hand `ln_1p` an argument below −1 and return NaN when
4493 // a certified LOWER bound on the gain lands a rounding step past it.
4494 let spent = (gain / self.rss_pen).clamp(0.0, 1.0);
4495 0.5 * (-self.dof * (-spent).ln_1p() - occam)
4496 }
4497}
4498
4499/// The candidate level a refinement gain is being certified for: its centers,
4500/// the hash grid that finds the rows each one supports, the bump radius, and the
4501/// exact ridge `λ·d_{L+1}` its columns would carry.
4502struct CandidateLevel<'a> {
4503 centers: &'a [[f64; 3]],
4504 grid: &'a HashGrid,
4505 delta: f64,
4506 ridge: f64,
4507}
4508
4509impl CandidateLevel<'_> {
4510 /// `X₂ v`, the candidate design applied to a coefficient vector.
4511 fn apply(&self, core: &Core, v: &[f64], out: &mut [f64]) {
4512 for (row, z) in core.z.iter().enumerate() {
4513 let mut value = 0.0;
4514 self.grid.for_neighbors(z, |j| {
4515 let j = j as usize;
4516 let radius = dist2(z, &self.centers[j], core.dim).sqrt() / self.delta;
4517 value += wendland(radius) * v[j];
4518 });
4519 out[row] = value;
4520 }
4521 }
4522
4523 /// `X₂ᵀ u`, the candidate design's transpose applied to a row vector.
4524 fn apply_transpose(&self, core: &Core, u: &[f64], out: &mut [f64]) {
4525 out.fill(0.0);
4526 for (row, z) in core.z.iter().enumerate() {
4527 let value = u[row];
4528 if value == 0.0 {
4529 continue;
4530 }
4531 self.grid.for_neighbors(z, |j| {
4532 let j = j as usize;
4533 let radius = dist2(z, &self.centers[j], core.dim).sqrt() / self.delta;
4534 out[j] += wendland(radius) * value;
4535 });
4536 }
4537 }
4538
4539 /// `diag(X₂ᵀWX₂) + λ·d`, the Jacobi preconditioner for the Schur operator.
4540 ///
4541 /// It is the diagonal of an UPPER bound on the operator — `I − H ⪯ I`, so
4542 /// `diag(S) ⩽ diag(X₂ᵀWX₂) + λd` — which is what a preconditioner is
4543 /// allowed to be. Forming the true `diag(S)` would cost one cascade solve
4544 /// per candidate.
4545 fn jacobi_preconditioner(&self, core: &Core) -> Vec<f64> {
4546 let mut diagonal = vec![self.ridge; self.centers.len()];
4547 for (row, z) in core.z.iter().enumerate() {
4548 let weight = core.w[row];
4549 self.grid.for_neighbors(z, |j| {
4550 let j = j as usize;
4551 let radius = dist2(z, &self.centers[j], core.dim).sqrt() / self.delta;
4552 let value = wendland(radius);
4553 diagonal[j] += weight * value * value;
4554 });
4555 }
4556 diagonal
4557 }
4558}
4559
4560/// Reusable buffers for one `S v`, so the conjugate-gradient loop allocates
4561/// nothing per iteration.
4562struct SchurWorkspace {
4563 row: Vec<f64>,
4564 fitted: Vec<f64>,
4565 column: Vec<f64>,
4566 /// Previous cascade solve, handed to the next as a warm start. The systems
4567 /// differ only in their right-hand side, so this is free accuracy per
4568 /// iteration and changes nothing about the solve's certified residual.
4569 warm: Option<Vec<f64>>,
4570}
4571
4572/// `S v = X2' W (I - H) X2 v + lambda*d*v`, with nothing dense formed.
4573///
4574/// One apply of the candidate design, ONE cascade solve for the hat matrix, one
4575/// apply of the transpose back. The solve carries its own backward-error
4576/// certificate (`CG_RTOL`), so the operator is exact to that relative accuracy
4577/// and the bracket below inherits it.
4578fn apply_candidate_schur(
4579 core: &Core,
4580 level: &CandidateLevel<'_>,
4581 lambda: f64,
4582 v: &[f64],
4583 out: &mut [f64],
4584 workspace: &mut SchurWorkspace,
4585) -> Result<(), String> {
4586 let rows = core.z.len();
4587 level.apply(core, v, &mut workspace.row);
4588 workspace.column.fill(0.0);
4589 for row in 0..rows {
4590 let weighted = core.w[row] * workspace.row[row];
4591 for entry in core.row_ptr[row]..core.row_ptr[row + 1] {
4592 workspace.column[core.col_idx[entry] as usize] += core.vals[entry] * weighted;
4593 }
4594 }
4595 let (coeff, _, _) = core.solve_coeff(lambda, &workspace.column, workspace.warm.as_deref())?;
4596 for row in 0..rows {
4597 let mut value = 0.0;
4598 for entry in core.row_ptr[row]..core.row_ptr[row + 1] {
4599 value += core.vals[entry] * coeff[core.col_idx[entry] as usize];
4600 }
4601 workspace.fitted[row] = value;
4602 }
4603 workspace.warm = Some(coeff);
4604 for row in 0..rows {
4605 workspace.row[row] = core.w[row] * (workspace.row[row] - workspace.fitted[row]);
4606 }
4607 level.apply_transpose(core, &workspace.row, out);
4608 for (target, &value) in out.iter_mut().zip(v.iter()) {
4609 *target += level.ridge * value;
4610 }
4611 Ok(())
4612}
4613
4614/// A rigorous two-sided bracket on the exact level-`(L+1)` gain, and the
4615/// evidence for it.
4616struct RefinementGainBracket {
4617 /// `2xᵀg − xᵀSx` — a lower bound for EVERY `x`, exact at `x = S⁻¹g`.
4618 lower: f64,
4619 /// The certified upper bound the refinement decision is taken on.
4620 upper: f64,
4621 /// Conjugate-gradient steps spent closing the bracket.
4622 iterations: usize,
4623 /// `Σ_j log(diag(S)_j / λd) ⩾ log det(S/λd)`, the Hadamard bound on the
4624 /// candidate level's Occam factor. `I − H ⪯ I` makes `diag(S) ⩽
4625 /// diag(X₂ᵀWX₂) + λd`, which is the Jacobi preconditioner this routine
4626 /// already forms, and Hadamard's inequality bounds a PSD determinant by its
4627 /// diagonal product — so this costs one pass over the candidate supports
4628 /// and no solve at all.
4629 hadamard_occam: f64,
4630}
4631
4632impl RefinementGainBracket {
4633 /// The comparison this bracket CERTIFIES on its own, without building
4634 /// anything: the gain read from its LOWER end and the Occam factor from its
4635 /// Hadamard upper bound. Both readings are taken in the direction that can
4636 /// only understate the evidence, so a positive `evidence` here is a proof
4637 /// that one more level earns its own Occam factor — and a non-positive one
4638 /// is no information at all, because both readings were taken against the
4639 /// level. It is the same comparison the exact route makes, evaluated on
4640 /// certified bounds instead of on a design.
4641 fn screened_comparison(&self, scale: EvidenceScale) -> RefinementCertificate {
4642 RefinementCertificate {
4643 gain: self.lower,
4644 occam: self.hadamard_occam,
4645 tolerance: scale.break_even_gain(self.hadamard_occam),
4646 evidence: scale.evidence(self.lower, self.hadamard_occam),
4647 }
4648 }
4649}
4650
4651/// The exact level-`(L+1)` gain `gᵀS⁻¹g`, bracketed.
4652///
4653/// # What the shipped bound was, and why it is the `x = 0` member of this family
4654///
4655/// Appending the candidate columns `X₂` with penalty `λd` decreases the
4656/// penalized objective by exactly `gᵀS⁻¹g`, with
4657///
4658/// ```text
4659/// g = X₂ᵀW r̂, S = X₂ᵀW(I − H)X₂ + λd·I, H = W^{1/2}X₁A⁻¹X₁ᵀW^{1/2}
4660/// ```
4661///
4662/// The certificate bounded that by discarding the ENTIRE data term — `S ⪰ λd·I`
4663/// gives `gᵀS⁻¹g ⩽ ‖g‖²/(λd)` — which is exactly this routine at `x = 0`. That
4664/// step is not a small conservatism where it matters most: when the candidate
4665/// level is redundant against the design already fitted, which is what the
4666/// rank-maximal regime IS, `X₂ᵀW(I − H)X₂` is the dominant term (#2759).
4667///
4668/// # The bracket
4669///
4670/// For ANY `x`, writing `r = g − Sx`,
4671///
4672/// ```text
4673/// 2xᵀg − xᵀSx ⩽ gᵀS⁻¹g ⩽ 2xᵀg − xᵀSx + ‖r‖²/(λd)
4674/// ```
4675///
4676/// The left inequality is `(x − S⁻¹g)ᵀS(x − S⁻¹g) ⩾ 0`; the right one adds
4677/// `rᵀS⁻¹r ⩽ ‖r‖²/λ_min(S)` and `λ_min(S) ⩾ λd` — the SAME structural fact the
4678/// shipped bound rests on, and the only inequality used. Both ends are computed
4679/// from an explicit `Sx`, never from a conjugate-gradient recurrence, so no
4680/// statement here depends on the iteration having behaved.
4681///
4682/// The returned upper bound is additionally floored by `‖g‖²/(λd)`, so this
4683/// certificate can never be LOOSER than the one it replaces, whatever the
4684/// iteration does.
4685///
4686/// # The stopping rule is the screen, not a tolerance
4687///
4688/// With a `screen`, iteration stops as soon as the bracket lands entirely on
4689/// one side of the break-even gain of the HADAMARD Occam bound: above it, one
4690/// more level provably earns marginal likelihood and no refined design has to
4691/// be built; below it, nothing is decided and the exact comparison has to run,
4692/// so further iterations are waste. There is no accuracy constant to pick,
4693/// because accuracy is not what is being asked for — a comparison is. The
4694/// structural ceiling is the Krylov dimension, past which the answer is exact
4695/// by construction; a stalled bracket (the gap not shrinking) is exactness
4696/// reached early and stops too.
4697fn certified_refinement_gain(
4698 core: &Core,
4699 level: &CandidateLevel<'_>,
4700 g: &[f64],
4701 lambda: f64,
4702 screen: Option<EvidenceScale>,
4703) -> Result<RefinementGainBracket, String> {
4704 let candidates = level.centers.len();
4705 let ridge = level.ridge;
4706 let energy: f64 = g.iter().map(|value| value * value).sum();
4707 let preconditioner = level.jacobi_preconditioner(core);
4708 // Hadamard on `S/λd ⪯ (diag(X₂ᵀWX₂) + λd)/λd`. Formed here because the
4709 // preconditioner IS that diagonal, so the bound is a reduction over a
4710 // vector this routine already has.
4711 let hadamard_occam: f64 = preconditioner
4712 .iter()
4713 .map(|diagonal| (diagonal / ridge).max(1.0).ln())
4714 .sum();
4715 // The `x = 0` member: rigorous on its own, and the floor every later
4716 // iterate is compared against.
4717 let zeroth = energy / ridge;
4718 if !(zeroth.is_finite() && zeroth > 0.0) || candidates == 0 {
4719 return Ok(RefinementGainBracket {
4720 lower: 0.0,
4721 upper: zeroth.max(0.0),
4722 iterations: 0,
4723 hadamard_occam,
4724 });
4725 }
4726
4727 let rows = core.z.len();
4728 let mut workspace = SchurWorkspace {
4729 row: vec![0.0_f64; rows],
4730 fitted: vec![0.0_f64; rows],
4731 column: vec![0.0_f64; core.m],
4732 warm: None,
4733 };
4734 let mut x = vec![0.0_f64; candidates];
4735 let mut residual = g.to_vec();
4736 let mut preconditioned = vec![0.0_f64; candidates];
4737 let mut direction = vec![0.0_f64; candidates];
4738 let mut operated = vec![0.0_f64; candidates];
4739 let mut certify = vec![0.0_f64; candidates];
4740 let mut rho = 0.0_f64;
4741
4742 // The Krylov space cannot exceed the rank of the operator's data term plus
4743 // one, and that term factors through the `n` rows: `X₂ᵀW(I−H)X₂` has rank at
4744 // most `n`. Past `min(candidates, n) + 1` steps the solution is exact and
4745 // the bracket has closed by construction, so this is a structural ceiling
4746 // rather than a budget — the same reading `residual_krylov_ceiling` takes.
4747 let ceiling = candidates.min(rows) + 1;
4748 let mut best = RefinementGainBracket {
4749 lower: 0.0,
4750 upper: zeroth,
4751 iterations: 0,
4752 hadamard_occam,
4753 };
4754 for iteration in 0..ceiling {
4755 for ((target, &value), &diagonal) in preconditioned
4756 .iter_mut()
4757 .zip(residual.iter())
4758 .zip(preconditioner.iter())
4759 {
4760 *target = if diagonal > 0.0 { value / diagonal } else { value };
4761 }
4762 let rho_next: f64 = residual
4763 .iter()
4764 .zip(preconditioned.iter())
4765 .map(|(&a, &b)| a * b)
4766 .sum();
4767 if !(rho_next.is_finite() && rho_next > 0.0) {
4768 break;
4769 }
4770 if iteration == 0 {
4771 direction.copy_from_slice(&preconditioned);
4772 } else {
4773 let beta = rho_next / rho;
4774 for (target, &value) in direction.iter_mut().zip(preconditioned.iter()) {
4775 *target = value + beta * *target;
4776 }
4777 }
4778 rho = rho_next;
4779 apply_candidate_schur(
4780 core,
4781 level,
4782 lambda,
4783 &direction,
4784 &mut operated,
4785 &mut workspace,
4786 )?;
4787 let curvature: f64 = direction
4788 .iter()
4789 .zip(operated.iter())
4790 .map(|(&a, &b)| a * b)
4791 .sum();
4792 if !(curvature.is_finite() && curvature > 0.0) {
4793 break;
4794 }
4795 let alpha = rho / curvature;
4796 for (target, &value) in x.iter_mut().zip(direction.iter()) {
4797 *target += alpha * value;
4798 }
4799 for (target, &value) in residual.iter_mut().zip(operated.iter()) {
4800 *target -= alpha * value;
4801 }
4802
4803 // CERTIFY FROM AN EXPLICIT `Sx`. The recurrence above is the search; it
4804 // is not evidence, and a bound recurred through `alpha`/`beta` would
4805 // inherit whatever the iteration drifted by.
4806 apply_candidate_schur(core, level, lambda, &x, &mut certify, &mut workspace)?;
4807 let mut linear = 0.0_f64;
4808 let mut quadratic = 0.0_f64;
4809 let mut defect = 0.0_f64;
4810 for ((&xi, &gi), &si) in x.iter().zip(g.iter()).zip(certify.iter()) {
4811 linear += xi * gi;
4812 quadratic += xi * si;
4813 let gap = gi - si;
4814 defect += gap * gap;
4815 }
4816 let lower = 2.0 * linear - quadratic;
4817 let upper = lower + defect / ridge;
4818 if !(lower.is_finite() && upper.is_finite() && upper >= lower) {
4819 break;
4820 }
4821 if upper < best.upper {
4822 best = RefinementGainBracket {
4823 lower: lower.max(0.0),
4824 upper,
4825 iterations: iteration + 1,
4826 hadamard_occam,
4827 };
4828 }
4829 // The screen is decided: either the whole bracket clears the Hadamard
4830 // break-even gain, or none of it does.
4831 if let Some(threshold) = screen.map(|scale| scale.break_even_gain(hadamard_occam))
4832 && (upper <= threshold || lower > threshold)
4833 {
4834 best.iterations = iteration + 1;
4835 best.lower = lower.max(0.0);
4836 best.upper = upper.min(zeroth);
4837 return Ok(best);
4838 }
4839 // Exactness reached early: the residual has stopped shrinking, so no
4840 // further step can move either end.
4841 if defect <= f64::EPSILON * energy {
4842 break;
4843 }
4844 }
4845 best.upper = best.upper.min(zeroth);
4846 Ok(best)
4847}
4848
4849/// The next level's assessment together with the centers the refinement may
4850/// take from it under the capacity budgets.
4851struct NextLevelPlan {
4852 assessment: NextLevelAssessment,
4853 /// The two-sided evidence behind the assessment's bound, when there was a
4854 /// candidate set to certify at all. `None` for an empty net or a proposal
4855 /// the center cap stopped before it was complete.
4856 gain: Option<RefinementGainBracket>,
4857 /// Centers the refinement may add; empty exactly when nothing may be added.
4858 selection: Vec<[f64; 3]>,
4859 /// The complete candidate set MINUS its exactly-zero columns — the centers
4860 /// whose bumps cover no observation. Those columns cannot move the fit or
4861 /// the evidence (their `λd` diagonal cancels between `log|A|` and
4862 /// `log|λD|₊`), so this is the set the comparison is taken on, and it is the
4863 /// complete set for every purpose the comparison has.
4864 supported: Vec<[f64; 3]>,
4865 /// Whether the selection is the complete candidate set. A partial level is
4866 /// reproducible only from its explicit centers, so the plan carries them.
4867 complete: bool,
4868 /// Whether these centers extend the current finest level rather than
4869 /// starting a new one.
4870 extends_last: bool,
4871}
4872
4873impl NextLevelPlan {
4874 /// An assessment that admits no refinement: an empty net, or a capacity
4875 /// with no room left in it.
4876 fn exhausted(assessment: NextLevelAssessment, extends_last: bool) -> Self {
4877 Self {
4878 assessment,
4879 gain: None,
4880 selection: Vec::new(),
4881 supported: Vec::new(),
4882 complete: false,
4883 extends_last,
4884 }
4885 }
4886
4887 /// An exhausted plan that still carries the evidence its bound came from.
4888 /// An exhausted plan that still carries the evidence its bound came from
4889 /// AND the candidate set that bound is over. A capacity with no room left
4890 /// is exactly where the exact comparison matters most, so it is exactly
4891 /// where the set must survive the plan.
4892 fn exhausted_with(
4893 assessment: NextLevelAssessment,
4894 gain: RefinementGainBracket,
4895 supported: Vec<[f64; 3]>,
4896 extends_last: bool,
4897 ) -> Self {
4898 Self {
4899 gain: Some(gain),
4900 supported,
4901 ..Self::exhausted(assessment, extends_last)
4902 }
4903 }
4904}
4905
4906/// Prior precision at resolution `h = h₀·2⁻ᵉ`:
4907/// `(h₀/h)^(2s−d) = 4^{e(s−d/2)}`.
4908fn level_weight(exponent: f64, sobolev_s: f64, dim: usize) -> f64 {
4909 (4.0_f64).powf(exponent * (sobolev_s - dim as f64 / 2.0))
4910}
4911
4912/// Lightweight view used during assembly, before the Core exists: shares the
4913/// exact basis-row logic with [`Core::basis_row_scaled`] so the assembled CSR
4914/// and later prediction rows cannot drift apart.
4915struct CoreScaffold<'a> {
4916 dim: usize,
4917 z_range: [f64; 3],
4918 levels: &'a [Level],
4919}
4920
4921impl CoreScaffold<'_> {
4922 fn basis_row(&self, z: &[f64; 3]) -> Vec<(usize, f64)> {
4923 let mut row = Vec::with_capacity(self.dim + 1 + self.levels.len() * 8);
4924 row.push((0, 1.0));
4925 for a in 0..self.dim {
4926 row.push((a + 1, 2.0 * z[a] / self.z_range[a] - 1.0));
4927 }
4928 for level in self.levels {
4929 let start = row.len();
4930 level.grid.for_neighbors(z, |j| {
4931 let c = &level.centers[j as usize];
4932 let r = dist2(z, c, self.dim).sqrt() / level.delta;
4933 let v = wendland(r);
4934 if v > 0.0 {
4935 row.push((level.col_offset + j as usize, v));
4936 }
4937 });
4938 row[start..].sort_unstable_by_key(|&(col, _)| col);
4939 }
4940 row
4941 }
4942}
4943
4944impl ResidualCascadeFit {
4945 pub fn log_lambda(&self) -> f64 {
4946 self.log_lambda
4947 }
4948
4949 pub fn lambda(&self) -> f64 {
4950 gam_problem::checked_exp_log_strength(self.log_lambda)
4951 .expect("ResidualCascadeFit construction validates its private log strength")
4952 }
4953
4954 /// Number of original training rows / experimental units.
4955 pub fn training_sample_size(&self) -> usize {
4956 self.training_sample_size.get()
4957 }
4958
4959 /// Posterior `(mean, variance)` at a raw point: the sparse basis row
4960 /// dotted with the coefficients, and `σ̂²·x'(X'WX+λD)^{−1}x` through one
4961 /// certified solve.
4962 pub fn predict(&self, x: &[f64]) -> Result<(f64, f64), String> {
4963 let core = &self.core;
4964 if x.len() != core.dim || x.iter().any(|v| !v.is_finite()) {
4965 return Err(format!(
4966 "residual cascade: prediction point must be {} finite coordinates, got {x:?}",
4967 core.dim
4968 ));
4969 }
4970 let row = core.basis_row_scaled(&core.scale_point(x));
4971 let mut mean = 0.0;
4972 let mut dense_row = vec![0.0_f64; core.m];
4973 for &(c, v) in &row {
4974 mean += v * self.coeff[c];
4975 dense_row[c] += v;
4976 }
4977 let lambda = gam_problem::checked_exp_log_strength(self.log_lambda)
4978 .map_err(|error| format!("residual cascade fit: {error}"))?;
4979 let zsol = if let Some(l) = &self.predict_chol {
4980 chol_solve(l, core.m, &dense_row)
4981 } else if let Some(factor) = &self.predict_sparse {
4982 // Exact, through the same factorization the fit's log-determinant
4983 // was read off — so the posterior variance carries no iterative
4984 // backward error at all past the dense Gram cache.
4985 solve_sparse_spd(factor, &Array1::from(dense_row.clone()))
4986 .map_err(|error| {
4987 format!("residual cascade: sparse posterior-variance solve failed: {error}")
4988 })?
4989 .to_vec()
4990 } else {
4991 core.solve_coeff(lambda, &dense_row, None)?.0
4992 };
4993 let mut quad = 0.0;
4994 for (a, b) in dense_row.iter().zip(zsol.iter()) {
4995 quad += a * b;
4996 }
4997 Ok((mean, self.sigma2 * quad))
4998 }
4999
5000 /// Number of resolution levels in the fitted cascade.
5001 pub fn num_levels(&self) -> usize {
5002 self.core.levels.len()
5003 }
5004
5005 /// Total coefficient count.
5006 pub fn num_coeffs(&self) -> usize {
5007 self.core.m
5008 }
5009
5010 /// Total centers across all fitted resolution levels.
5011 pub fn num_centers(&self) -> usize {
5012 self.core.m - self.core.nullity()
5013 }
5014
5015 /// Snapshot the fit for persistence (#1032). Assembles the factored
5016 /// precision `L` of `A = X'WX + λD` at the fit's λ (O(m³) once) and copies
5017 /// the nested geometry + coefficients, dropping all training rows. The
5018 /// resulting [`ResidualCascadeState`] is predict-complete: `from_state`
5019 /// replays the posterior mean+variance bit-for-bit.
5020 pub fn to_state(&self) -> Result<ResidualCascadeState, String> {
5021 let core = &self.core;
5022 let training_sample_size =
5023 u64::try_from(self.training_sample_size.get()).map_err(|_| {
5024 format!(
5025 "residual cascade fit: training_sample_size {} exceeds the persistence format",
5026 self.training_sample_size
5027 )
5028 })?;
5029 let training_sample_size =
5030 std::num::NonZeroU64::new(training_sample_size).ok_or_else(|| {
5031 "residual cascade fit: training_sample_size must be positive".to_string()
5032 })?;
5033 let lambda = gam_problem::checked_exp_log_strength(self.log_lambda)
5034 .map_err(|error| format!("residual cascade fit: {error}"))?;
5035 let predict_chol = if let Some(l) = &self.predict_chol {
5036 l.clone()
5037 } else if let Some(l) = &core.predict_chol {
5038 l.clone()
5039 } else {
5040 core.assemble_predict_factor(lambda)?
5041 };
5042 let dim = core.dim;
5043 let levels = core
5044 .levels
5045 .iter()
5046 .map(|level| {
5047 let mut centers = Vec::with_capacity(level.centers.len() * dim);
5048 for c in &level.centers {
5049 centers.extend_from_slice(&c[..dim]);
5050 }
5051 LevelState {
5052 h: level.h,
5053 delta: level.delta,
5054 weight: level.weight,
5055 col_offset: level.col_offset as u64,
5056 centers,
5057 }
5058 })
5059 .collect();
5060 Ok(ResidualCascadeState {
5061 training_sample_size,
5062 dim: dim as u64,
5063 metric: core.metric,
5064 z_lo: core.z_lo,
5065 z_range: core.z_range,
5066 sobolev_s: core.sobolev_s,
5067 levels,
5068 m: core.m as u64,
5069 pen_logdet_const: core.pen_logdet_const,
5070 coeff: self.coeff.clone(),
5071 log_lambda: self.log_lambda,
5072 sigma2: self.sigma2,
5073 restricted_loglik: self.restricted_loglik,
5074 rss_pen: self.rss_pen,
5075 predict_chol,
5076 })
5077 }
5078
5079 /// Rebuild a predict-capable fit from a snapshot (#1032). Validates shape,
5080 /// finiteness, the Sobolev/Wendland window, strictly-positive level weights
5081 /// and box ranges, the column accounting (`m = dim+1 + Σ centers`, matching
5082 /// `col_offset`s), positive σ², and that `predict_chol` is a valid `m × m`
5083 /// lower factor (positive pivots) — so a corrupt payload fails here, not in
5084 /// a later `predict`. The restored `Core` has empty training CSR and
5085 /// `predict_chol = Some(L)`; its `predict` reads only geometry (mean) and
5086 /// the factor (variance), replaying both exactly.
5087 pub fn from_state(state: &ResidualCascadeState) -> Result<Self, String> {
5088 let training_sample_size =
5089 usize::try_from(state.training_sample_size.get()).map_err(|_| {
5090 format!(
5091 "residual cascade state: training_sample_size {} exceeds this platform's usize",
5092 state.training_sample_size
5093 )
5094 })?;
5095 let dim = state.dim as usize;
5096 if !(dim == 2 || dim == 3) {
5097 return Err(format!(
5098 "residual cascade state: dim must be 2 or 3, got {dim}"
5099 ));
5100 }
5101 if !(state.sobolev_s > dim as f64 / 2.0 && state.sobolev_s <= (dim as f64 + 3.0) / 2.0) {
5102 return Err(format!(
5103 "residual cascade state: sobolev_s {} outside the Wendland window ({}, {}]",
5104 state.sobolev_s,
5105 dim as f64 / 2.0,
5106 (dim as f64 + 3.0) / 2.0
5107 ));
5108 }
5109 for a in 0..dim {
5110 if !(state.metric[a].is_finite() && state.metric[a] > 0.0) {
5111 return Err(format!(
5112 "residual cascade state: metric axis {a} must be finite positive, got {}",
5113 state.metric[a]
5114 ));
5115 }
5116 if !(state.z_range[a].is_finite()
5117 && state.z_range[a] > 0.0
5118 && state.z_lo[a].is_finite())
5119 {
5120 return Err(format!(
5121 "residual cascade state: degenerate box on axis {a} (lo={}, range={})",
5122 state.z_lo[a], state.z_range[a]
5123 ));
5124 }
5125 }
5126 let m = state.m as usize;
5127 let mut metric3 = [1.0_f64; 3];
5128 metric3[..dim].copy_from_slice(&state.metric[..dim]);
5129 let mut z_lo = [0.0_f64; 3];
5130 let mut z_range = [1.0_f64; 3];
5131 z_lo[..dim].copy_from_slice(&state.z_lo[..dim]);
5132 z_range[..dim].copy_from_slice(&state.z_range[..dim]);
5133
5134 // Rebuild the levels and their lookup grids from the flattened centers,
5135 // checking the column accounting matches the polynomial layer + blocks.
5136 let mut levels = Vec::with_capacity(state.levels.len());
5137 let mut net: Vec<[f64; 3]> = Vec::new();
5138 let mut pen_diag = vec![0.0_f64; m];
5139 let mut expected_offset = dim + 1;
5140 for (li, ls) in state.levels.iter().enumerate() {
5141 if !(ls.h.is_finite() && ls.h > 0.0 && ls.delta.is_finite() && ls.delta > 0.0) {
5142 return Err(format!(
5143 "residual cascade state: level {li} has non-positive h/delta ({}, {})",
5144 ls.h, ls.delta
5145 ));
5146 }
5147 if !(ls.weight.is_finite() && ls.weight > 0.0) {
5148 return Err(format!(
5149 "residual cascade state: level {li} has non-positive prior weight {}",
5150 ls.weight
5151 ));
5152 }
5153 if ls.centers.len() % dim != 0 {
5154 return Err(format!(
5155 "residual cascade state: level {li} centers length {} not a multiple of dim {dim}",
5156 ls.centers.len()
5157 ));
5158 }
5159 let n_centers = ls.centers.len() / dim;
5160 let col_offset = ls.col_offset as usize;
5161 if col_offset != expected_offset {
5162 return Err(format!(
5163 "residual cascade state: level {li} col_offset {col_offset} ≠ expected {expected_offset}"
5164 ));
5165 }
5166 let mut grid = HashGrid::new(ls.delta, dim);
5167 let mut centers = Vec::with_capacity(n_centers);
5168 for j in 0..n_centers {
5169 let mut c = [0.0_f64; 3];
5170 for a in 0..dim {
5171 let v = ls.centers[j * dim + a];
5172 if !v.is_finite() {
5173 return Err(format!(
5174 "residual cascade state: non-finite center coordinate at level {li}, center {j}"
5175 ));
5176 }
5177 c[a] = v;
5178 }
5179 grid.insert(j as u32, &c);
5180 centers.push(c);
5181 net.push(c);
5182 let col = col_offset + j;
5183 if col >= m {
5184 return Err(format!(
5185 "residual cascade state: level {li} column {col} exceeds m {m}"
5186 ));
5187 }
5188 pen_diag[col] = ls.weight;
5189 }
5190 expected_offset = col_offset + n_centers;
5191 levels.push(Level {
5192 h: ls.h,
5193 delta: ls.delta,
5194 weight: ls.weight,
5195 centers,
5196 col_offset,
5197 grid,
5198 });
5199 }
5200 if expected_offset != m {
5201 return Err(format!(
5202 "residual cascade state: column accounting mismatch (dim+1+Σcenters = {expected_offset} ≠ m {m})"
5203 ));
5204 }
5205 if state.coeff.len() != m {
5206 return Err(format!(
5207 "residual cascade state: coeff length {} ≠ m {m}",
5208 state.coeff.len()
5209 ));
5210 }
5211 if state.predict_chol.len() != m * m {
5212 return Err(format!(
5213 "residual cascade state: predict_chol must be m×m = {m}² = {}, got {}",
5214 m * m,
5215 state.predict_chol.len()
5216 ));
5217 }
5218 for (i, v) in state
5219 .coeff
5220 .iter()
5221 .chain(state.predict_chol.iter())
5222 .enumerate()
5223 {
5224 if !v.is_finite() {
5225 return Err(format!("residual cascade state: non-finite entry at {i}"));
5226 }
5227 }
5228 for g in 0..m {
5229 let piv = state.predict_chol[g * m + g];
5230 if !(piv.is_finite() && piv > 0.0) {
5231 return Err(format!(
5232 "residual cascade state: non-positive Cholesky pivot {piv} at index {g}"
5233 ));
5234 }
5235 }
5236 gam_problem::validate_log_strength(state.log_lambda)
5237 .map_err(|error| format!("residual cascade state: {error}"))?;
5238 if !(state.sigma2.is_finite()
5239 && state.sigma2 > 0.0
5240 && state.restricted_loglik.is_finite()
5241 && state.rss_pen.is_finite())
5242 {
5243 return Err(format!(
5244 "residual cascade state: invalid scalars (log_lambda={}, sigma2={}, restricted_loglik={}, rss_pen={})",
5245 state.log_lambda, state.sigma2, state.restricted_loglik, state.rss_pen
5246 ));
5247 }
5248 let core = Core {
5249 dim,
5250 metric: metric3,
5251 z_lo,
5252 z_range,
5253 sobolev_s: state.sobolev_s,
5254 levels,
5255 net,
5256 m,
5257 row_ptr: Vec::new(),
5258 col_idx: Vec::new(),
5259 vals: Vec::new(),
5260 w: Vec::new(),
5261 y: Vec::new(),
5262 z: Vec::new(),
5263 rhs: Vec::new(),
5264 ytwy: 0.0,
5265 gram_diag: Vec::new(),
5266 pen_diag,
5267 pen_logdet_const: state.pen_logdet_const,
5268 dense_gram: None,
5269 predict_chol: Some(state.predict_chol.clone()),
5270 };
5271 Ok(ResidualCascadeFit {
5272 core: Arc::new(core),
5273 training_sample_size: std::num::NonZeroUsize::new(training_sample_size)
5274 .expect("nonzero wire count remains nonzero after conversion"),
5275 predict_chol: None,
5276 // The restored core carries the dense factor itself, so
5277 // `solve_coeff` replays through it; there is no CSR design left to
5278 // assemble a sparse system from.
5279 predict_sparse: None,
5280 coeff: state.coeff.clone(),
5281 log_lambda: state.log_lambda,
5282 sigma2: state.sigma2,
5283 restricted_loglik: state.restricted_loglik,
5284 rss_pen: state.rss_pen,
5285 certificate: CascadeCertificate {
5286 solve_rel_residual: 0.0,
5287 solve_iters: 0,
5288 logdet_method: LogdetMethod::DenseExact,
5289 },
5290 refinement: None,
5291 })
5292 }
5293}
5294
5295#[derive(Clone, Copy, Debug, PartialEq)]
5296enum RefinementDecision {
5297 Converged(RefinementCertificate),
5298 Refine,
5299 Underresolved {
5300 evidence: Option<RefinementCertificate>,
5301 obstruction: RefinementObstruction,
5302 },
5303}
5304
5305/// Turn the typed next-level assessment and the comparison against it into the
5306/// only three legal refinement transitions.
5307///
5308/// `evidence` is `None` in exactly two situations, and they are opposite ones:
5309/// the screen already proved the level warranted (so the exact comparison was
5310/// skipped as redundant, and the assessment left room to take it), or a
5311/// structural cap stopped the candidate set from being formed at all (so no
5312/// comparison exists). The first can only reach a `GainBound` assessment and
5313/// the second only a `CapacityExceeded` one, which is what lets one `None`
5314/// serve both without ambiguity.
5315///
5316/// A capacity limit can still yield a fit — that is the point of computing the
5317/// comparison before consulting the budget: a level nobody should add does not
5318/// become a refusal because there was also no room for it.
5319fn decide_refinement(
5320 assessment: NextLevelAssessment,
5321 evidence: Option<RefinementCertificate>,
5322) -> RefinementDecision {
5323 match assessment {
5324 // Only an empty net certifies zero remaining gain outright: there is no
5325 // candidate set, so there is nothing to charge and nothing to buy.
5326 NextLevelAssessment::EmptyNet => {
5327 RefinementDecision::Converged(RefinementCertificate::EXHAUSTED)
5328 }
5329 NextLevelAssessment::GainBound(_) => match evidence {
5330 Some(evidence) if !evidence.warrants_refinement() => {
5331 RefinementDecision::Converged(evidence)
5332 }
5333 _ => RefinementDecision::Refine,
5334 },
5335 NextLevelAssessment::CapacityExceeded { obstruction, .. } => match evidence {
5336 Some(evidence) if !evidence.warrants_refinement() => {
5337 RefinementDecision::Converged(evidence)
5338 }
5339 evidence => RefinementDecision::Underresolved {
5340 evidence,
5341 obstruction,
5342 },
5343 },
5344 }
5345}
5346
5347/// The inputs one cascade fit is derived from, kept together so that a CANDIDATE
5348/// level can be materialized — built, solved, and compared — rather than only
5349/// bounded. The refinement decision needs a log-determinant of the candidate
5350/// Schur complement, and a log-determinant is not a quantity any matrix-free
5351/// bracket produces: it needs the whole spectrum, which is to say the design.
5352#[derive(Clone, Copy)]
5353struct CascadeRequest<'a> {
5354 xs: &'a [&'a [f64]],
5355 y: &'a [f64],
5356 w: &'a [f64],
5357 metric: &'a [f64],
5358 sobolev_s: f64,
5359}
5360
5361impl CascadeRequest<'_> {
5362 fn build(&self, plan: &[LevelPlan]) -> Result<ResidualCascadeDesign, String> {
5363 ResidualCascadeDesign::build_from_plan(
5364 self.xs,
5365 self.y,
5366 self.w,
5367 self.metric,
5368 self.sobolev_s,
5369 plan,
5370 )
5371 }
5372
5373 /// The exact nested-model comparison for ONE pending candidate set: build
5374 /// the design with the COMPLETE set appended, minimize at the incumbent's
5375 /// λ, and difference the two restricted log-likelihoods.
5376 ///
5377 /// This is available past every capacity budget the automatic route
5378 /// enforces, and that is the point. `CERTIFIED_SPECTRUM_MAX` bounds the
5379 /// λ-independent Schur eigendecomposition the score SEARCH is certified in,
5380 /// and `n − nullity` bounds the rank that search needs to have a stationary
5381 /// point at all; a single evaluation at a FIXED λ needs neither — only a
5382 /// factorization, which the sparse route supplies far wider. So the question
5383 /// "does one more level explain the data better?" has an exact answer
5384 /// exactly where the cascade used to have only a bound (#2759).
5385 ///
5386 /// At the profiled σ̂² the identity
5387 ///
5388 /// ```text
5389 /// 2·evidence = dof·log(rss_pen/rss_pen_refined) − occam
5390 /// ```
5391 ///
5392 /// holds term by term — the `rss_pen/σ̂² = dof` quadratic cancels — so the
5393 /// candidate set's Occam factor is READ OFF the two fits rather than formed
5394 /// a second time from the Schur determinant it equals. That identity is
5395 /// what fixes the comparison to ONE λ: the incumbent's, which is its own
5396 /// REML optimum. The refined design's optimum is weakly higher than its
5397 /// value there, so the comparison leans toward stopping, and
5398 /// `the_refinement_stops_where_the_evidence_turns_over_and_the_truth_agrees_2759`
5399 /// charges that lean directly — it sweeps six λ on a design strictly wider
5400 /// than the one that was minted and requires that none of them win the
5401 /// comparison back.
5402 fn candidate_level_evidence(
5403 &self,
5404 plan: &[LevelPlan],
5405 exponent: f64,
5406 extends_last: bool,
5407 supported: &[[f64; 3]],
5408 fit: &ResidualCascadeFit,
5409 ) -> Result<Option<RefinementCertificate>, String> {
5410 if supported.is_empty() {
5411 // Every candidate is an exactly zero column. There is no comparison
5412 // to make: such a set cannot move the objective and cannot charge an
5413 // Occam factor.
5414 return Ok(Some(RefinementCertificate::EXHAUSTED));
5415 }
5416 let mut refined_plan = plan.to_vec();
5417 if extends_last {
5418 // Re-assessing the finest radius: the COMPLETE set there is the
5419 // union of what capacity let the level take and what it left
5420 // behind, and asking for the whole level reproduces exactly that
5421 // union. `extend_net` is greedy over a fixed order and every center
5422 // it plants is more than `h` from every other, so no member of the
5423 // complete set can be covered by the partial selection, and no
5424 // non-member can escape being covered by one.
5425 let last = refined_plan
5426 .last_mut()
5427 .expect("the plan always carries a level");
5428 let mut centers = last.centers.take().unwrap_or_default();
5429 centers.extend_from_slice(supported);
5430 last.centers = Some(centers);
5431 } else {
5432 refined_plan.push(LevelPlan {
5433 exponent,
5434 centers: Some(supported.to_vec()),
5435 });
5436 }
5437 let refined_design = self.build(&refined_plan)?;
5438 let refined = refined_design.fit_at(fit.log_lambda, None)?;
5439 if refined.certificate.logdet_method == LogdetMethod::Slq {
5440 // Past the sparse factor's fill budget the candidate design's
5441 // log-determinant is a stochastic point estimate, and a point
5442 // estimate cannot underwrite a convergence certificate. That is not
5443 // a failure — it is the absence of a comparison, which is exactly
5444 // what `None` says, and it can only ever keep the cascade refining
5445 // or make a capacity refusal honest.
5446 log::debug!(
5447 "[cascade] candidate level at exponent={exponent} has no exact comparison: the \
5448 log-determinant fell back to the stochastic estimate at {} columns",
5449 refined_design.core.m
5450 );
5451 return Ok(None);
5452 }
5453 level_evidence(
5454 fit,
5455 &refined,
5456 (self.y.len() - refined_design.core.nullity()) as f64,
5457 )
5458 .map(Some)
5459 }
5460}
5461
5462/// Turn one incumbent fit and one refined fit AT THE SAME λ into the comparison
5463/// the refinement decides on.
5464///
5465/// The refined design must be the incumbent's plus a candidate set, and both
5466/// must carry their own profiled σ̂²; then `rss_pen/σ̂² = dof` on both sides and
5467/// the restricted log-likelihood difference is
5468///
5469/// ```text
5470/// 2·evidence = dof·log(rss_pen/rss_pen_refined) − occam
5471/// ```
5472///
5473/// with `occam = log det(S/(λd))` the candidate set's Occam factor. That is an
5474/// identity, not an approximation, so the Occam term is READ OFF the two fits
5475/// rather than formed a second time from the Schur determinant it equals.
5476fn level_evidence(
5477 fit: &ResidualCascadeFit,
5478 refined: &ResidualCascadeFit,
5479 dof: f64,
5480) -> Result<RefinementCertificate, String> {
5481 let scale = EvidenceScale {
5482 rss_pen: fit.rss_pen,
5483 dof,
5484 };
5485 // A superset design minimizes the same objective over a superset, so the
5486 // decrease is non-negative, and the Occam factor of a PSD Schur complement
5487 // is too; both are clamped against their own rounding rather than trusted
5488 // to stay on the right side of zero.
5489 let gain = (fit.rss_pen - refined.rss_pen).max(0.0);
5490 let evidence = refined.restricted_loglik - fit.restricted_loglik;
5491 let occam = (dof * (fit.rss_pen / refined.rss_pen).ln() - 2.0 * evidence).max(0.0);
5492 if !(gain.is_finite() && occam.is_finite() && evidence.is_finite() && dof > 0.0) {
5493 return Err(format!(
5494 "residual cascade refinement: the candidate level comparison is not finite \
5495 (gain {gain}, occam {occam}, evidence {evidence}, dof {dof})"
5496 ));
5497 }
5498 Ok(RefinementCertificate {
5499 gain,
5500 occam,
5501 tolerance: scale.break_even_gain(occam),
5502 evidence,
5503 })
5504}
5505
5506/// Fit the full magic-default cascade: start at `INITIAL_LEVELS`, REML-fit, and
5507/// refine (add a level, refit, re-select λ) until one more level no longer
5508/// earns its own Occam factor — until the marginal likelihood of the design
5509/// with the complete candidate set appended, at the same λ, stops rising. A
5510/// genuinely empty next-level net certifies zero remaining gain against a zero
5511/// charge; a structural capacity reached while the evidence is still rising is
5512/// a typed [`ResidualCascadeError::Underresolved`] carrying the retained work
5513/// and that comparison, never a fit.
5514pub fn fit_residual_cascade(
5515 xs: &[&[f64]],
5516 y: &[f64],
5517 w: &[f64],
5518 metric: &[f64],
5519 sobolev_s: f64,
5520) -> Result<ResidualCascadeFit, ResidualCascadeError> {
5521 let request = CascadeRequest {
5522 xs,
5523 y,
5524 w,
5525 metric,
5526 sobolev_s,
5527 };
5528 let mut plan: Vec<LevelPlan> = (0..INITIAL_LEVELS)
5529 .map(|level| LevelPlan {
5530 exponent: level as f64,
5531 centers: None,
5532 })
5533 .collect();
5534 loop {
5535 let design = request.build(&plan)?;
5536 let levels = plan.len();
5537 // Quasi-uniformity guard (issue #1032, caveat 2): if the metric has
5538 // collapsed the cloud onto a near-degenerate sheet in scaled
5539 // coordinates, the BPX iteration bound no longer holds. Refuse the
5540 // iterative solve up front with a typed signal before paying an
5541 // unbounded CG or grinding to CG_MAX_ITERS. (The guard is checked at
5542 // the root level only — refinement adds finer nets to the SAME scaled
5543 // cloud, so the aspect ratio is invariant under added levels.) The
5544 // typed computation refusal propagates through the selected cascade
5545 // route; callers must not silently replace this estimator with another
5546 // one.
5547 if levels == INITIAL_LEVELS && !design.quasi_uniformity_certified() {
5548 return Err(format!(
5549 "residual cascade: metric-scaled aspect ratio {:.3e} exceeds the \
5550 quasi-uniformity ceiling {QUASI_UNIFORMITY_MAX_ASPECT:.0e}; the BPX \
5551 iteration bound is not trustworthy on this (near-degenerate) metric",
5552 design.metric_scaled_aspect_ratio()
5553 )
5554 .into());
5555 }
5556 let mut fit = design.fit_reml()?;
5557 // The realized CG iteration count at this cascade depth is the runtime
5558 // tell of the BPX n-independence bound (issue #1032 caveat: a count
5559 // creeping toward CG_MAX_ITERS means the quasi-uniformity guard's static
5560 // aspect-ratio check was too lenient for this cloud). It is exposed
5561 // STRUCTURALLY rather than over stderr: the per-depth count and backward
5562 // error ride on `fit.certificate` (`solve_iters` — 0 on the dense route,
5563 // the PCG count on the iterative route — and `solve_rel_residual`), so a
5564 // caller that wants to watch the bound reads them off the returned fit
5565 // instead of scraping log lines. (A library solve never writes to
5566 // stderr.)
5567 let scale = EvidenceScale {
5568 rss_pen: fit.rss_pen,
5569 dof: (y.len() - design.core.nullity()) as f64,
5570 };
5571 // Everything the cascade could still add has to be certified, not just
5572 // the next dyadic level. When a capacity budget truncated the finest
5573 // level, the candidates it left behind at ITS radius are still
5574 // addable — so they are assessed first, and the next dyadic level only
5575 // after they pass. A fit is minted only when EVERY pending candidate
5576 // set fails to earn its own Occam factor, which is the same per-level
5577 // claim the complete-level ladder always made, asserted once per set.
5578 let last = plan.last().expect("the plan always carries a level");
5579 let mut pending: Vec<f64> = Vec::with_capacity(2);
5580 if last.centers.is_some() {
5581 pending.push(last.exponent);
5582 }
5583 pending.push(last.exponent + 1.0);
5584 let mut certified: Option<RefinementCertificate> = None;
5585 let mut refinement: Option<(f64, bool, bool, Vec<[f64; 3]>)> = None;
5586 for exponent in pending {
5587 let planned = design.plan_level_at_exponent(&fit, exponent, Some(scale))?;
5588 let (complete, extends_last) = (planned.complete, planned.extends_last);
5589 let room = !planned.selection.is_empty();
5590 // The free half of the comparison, read off the bracket the plan
5591 // already carries. When it PROVES the level warranted there is
5592 // nothing left to decide and the refined design is not built —
5593 // which matters most where building it is worst: a
5594 // certified-spectrum refusal is a memory boundary, and paying that
5595 // memory to confirm a conclusion already proved would be the exact
5596 // cost the boundary exists to avoid.
5597 let screened = planned
5598 .gain
5599 .as_ref()
5600 .map(|bracket| bracket.screened_comparison(scale))
5601 .filter(RefinementCertificate::warrants_refinement);
5602 // Two caps stop the candidate set from being FORMED rather than
5603 // merely from being taken, and both are caps on the shape of the
5604 // plan itself, so no design carrying that set exists to compare
5605 // against. Every other outcome admits the attempt.
5606 let constructible = !matches!(
5607 planned.assessment,
5608 NextLevelAssessment::EmptyNet
5609 | NextLevelAssessment::CapacityExceeded {
5610 obstruction: RefinementObstruction::LevelCapacity { .. }
5611 | RefinementObstruction::CenterCapacity { .. },
5612 ..
5613 }
5614 );
5615 // A level is never declared SPENT on a bound: the screen can only
5616 // ever prove the positive, so an inconclusive screen sends the
5617 // decision to the design itself. That is the whole of this issue's
5618 // remaining half — a refusal must not rest on a bound while the
5619 // number is one factorization away.
5620 let evidence = match (screened, constructible) {
5621 (Some(proved), _) => Some(proved),
5622 (None, true) => {
5623 request.candidate_level_evidence(
5624 &plan,
5625 exponent,
5626 extends_last,
5627 &planned.supported,
5628 &fit,
5629 )?
5630 }
5631 (None, false) => None,
5632 };
5633 // The comparison the decision below is taken on, plus the bracket
5634 // that screened it: a run record that shows only one of them cannot
5635 // say whether the exact route ran or the screen carried it.
5636 log::debug!(
5637 "[cascade] exponent={exponent} gain_bracket={} evidence={} complete={complete} \
5638 extends_last={extends_last} room={room}",
5639 planned.gain.as_ref().map_or_else(
5640 || "none (empty net, or a proposal a structural cap stopped)".to_string(),
5641 |bracket| format!(
5642 "[{:.6e}, {:.6e}] in {} cg steps, hadamard occam {:.6e}",
5643 bracket.lower, bracket.upper, bracket.iterations, bracket.hadamard_occam
5644 )
5645 ),
5646 evidence.map_or_else(
5647 || "none (the candidate set was never formed)".to_string(),
5648 |evidence| evidence.to_string()
5649 ),
5650 );
5651 match decide_refinement(planned.assessment, evidence) {
5652 RefinementDecision::Converged(spent) => {
5653 // The binding set is the one that came CLOSEST to earning a
5654 // level, which is the largest evidence and not the largest
5655 // gain: gains from candidate sets of different width are not
5656 // comparable, and comparing them is what this issue was.
5657 if certified.is_none_or(|best| spent.evidence > best.evidence) {
5658 certified = Some(spent);
5659 }
5660 }
5661 RefinementDecision::Refine => {
5662 refinement = Some((exponent, complete, extends_last, planned.selection));
5663 break;
5664 }
5665 RefinementDecision::Underresolved {
5666 evidence,
5667 obstruction,
5668 } => {
5669 return Err(ResidualCascadeError::Underresolved {
5670 checkpoint: ResidualCascadeCheckpoint::new(fit),
5671 evidence,
5672 obstruction,
5673 });
5674 }
5675 }
5676 }
5677 match refinement {
5678 None => {
5679 fit.refinement = Some(certified.unwrap_or(RefinementCertificate::EXHAUSTED));
5680 return Ok(fit);
5681 }
5682 Some((exponent, complete, extends_last, mut selection)) => {
5683 if extends_last {
5684 let last = plan.last_mut().expect("the plan always carries a level");
5685 let mut centers = last.centers.take().unwrap_or_default();
5686 centers.append(&mut selection);
5687 last.centers = Some(centers);
5688 } else {
5689 plan.push(LevelPlan {
5690 exponent,
5691 centers: if complete { None } else { Some(selection) },
5692 });
5693 }
5694 }
5695 }
5696 }
5697}
5698
5699#[cfg(test)]
5700mod refinement_decision_tests {
5701 use super::*;
5702
5703 const TOLERANCE: f64 = 0.25;
5704
5705 /// The refinement gain bracket is checked against the gain ITSELF, obtained
5706 /// by a route that shares no code with it: build the design with the
5707 /// candidate level appended, solve at the SAME fixed λ, and difference the
5708 /// two penalized objectives.
5709 ///
5710 /// This is the gate the whole of #2759 rests on. The shipped bound
5711 /// `‖g‖²/(λd)` is the `x = 0` member of the same family, so the three
5712 /// claims to establish are that the bracket CONTAINS the truth, that its
5713 /// upper end never exceeds the shipped number, and that it is materially
5714 /// tighter than it — a bound that merely reproduces `‖g‖²/(λd)` would leave
5715 /// every fixture in this issue exactly where it was.
5716 #[test]
5717 fn the_refinement_gain_bracket_contains_the_objective_decrease_it_bounds_2759() {
5718 let (x1, x2, y) = dense_fixture(24);
5719 let weights = vec![1.0; y.len()];
5720 let axes: [&[f64]; 2] = [&x1, &x2];
5721 let metric = [1.0, 1.0];
5722 let sobolev_s = 2.0;
5723 let plan: Vec<LevelPlan> = (0..3)
5724 .map(|level| LevelPlan {
5725 exponent: level as f64,
5726 centers: None,
5727 })
5728 .collect();
5729 let design =
5730 ResidualCascadeDesign::build_from_plan(&axes, &y, &weights, &metric, sobolev_s, &plan)
5731 .expect("cascade design");
5732 for log_lambda in [-4.0_f64, -1.0, 2.0] {
5733 let fit = design.fit_at(log_lambda, None).expect("fixed-lambda fit");
5734 let exponent = plan.len() as f64;
5735 let planned = design
5736 .plan_level_at_exponent(&fit, exponent, None)
5737 .expect("candidate level");
5738 let bracket = planned.gain.as_ref().expect("a complete candidate certifies");
5739 assert!(
5740 planned.complete,
5741 "the truth below is the COMPLETE candidate level; this fixture must not be \
5742 capacity-truncated"
5743 );
5744
5745 // The independent route: the same cascade with the level appended.
5746 let mut extended = plan.clone();
5747 extended.push(LevelPlan {
5748 exponent,
5749 centers: None,
5750 });
5751 let refined = ResidualCascadeDesign::build_from_plan(
5752 &axes,
5753 &y,
5754 &weights,
5755 &metric,
5756 sobolev_s,
5757 &extended,
5758 )
5759 .expect("refined design");
5760 let refined_fit = refined
5761 .fit_at(log_lambda, None)
5762 .expect("refined fixed-lambda fit");
5763 let truth = fit.rss_pen - refined_fit.rss_pen;
5764
5765 // The solves on both sides carry `CG_RTOL` backward error, and the
5766 // difference of two objectives near each other loses that
5767 // cancellation's worth of digits; charge it rather than demand an
5768 // exact inequality on a differenced quantity.
5769 let slack = CG_RTOL * fit.rss_pen.abs().max(refined_fit.rss_pen.abs());
5770 assert!(
5771 truth >= -slack,
5772 "adding a level cannot INCREASE the penalized objective: {truth} at \
5773 log lambda {log_lambda}"
5774 );
5775 assert!(
5776 truth <= bracket.upper + slack,
5777 "the certified bound is not an upper bound: truth {truth} exceeds \
5778 {} at log lambda {log_lambda}",
5779 bracket.upper
5780 );
5781 assert!(
5782 truth >= bracket.lower - slack,
5783 "the bracket's lower end is not a lower bound: truth {truth} below {} at \
5784 log lambda {log_lambda}",
5785 bracket.lower
5786 );
5787
5788 // The shipped bound, recomputed here so the comparison is against a
5789 // number and not against a memory.
5790 let shipped = shipped_zeroth_order_gain_bound(&design, &fit, exponent);
5791 assert!(
5792 bracket.upper <= shipped * (1.0 + f64::EPSILON.sqrt()),
5793 "the new certificate is looser than the one it replaces: {} against {shipped}",
5794 bracket.upper
5795 );
5796
5797 // Run to the closed bracket as well. The decision-driven bracket
5798 // above stops the moment the comparison is settled, so how tight it
5799 // happens to be there is a property of the threshold; what the
5800 // machinery can REACH is the separate claim, and the only one worth
5801 // a bar.
5802 let closed = design
5803 .plan_level_at_exponent(&fit, exponent, None)
5804 .expect("candidate level")
5805 .gain
5806 .expect("a complete candidate certifies");
5807 assert!(
5808 truth <= closed.upper + slack && truth >= closed.lower - slack,
5809 "the closed bracket [{}, {}] does not contain {truth} at log lambda \
5810 {log_lambda}",
5811 closed.lower,
5812 closed.upper
5813 );
5814 assert!(
5815 closed.upper <= bracket.upper * (1.0 + f64::EPSILON.sqrt()),
5816 "iterating further made the bound worse: {} against {}",
5817 closed.upper,
5818 bracket.upper
5819 );
5820 assert!(
5821 closed.upper < shipped,
5822 "the closed bracket did not tighten the shipped bound at log lambda \
5823 {log_lambda}: {} against {shipped} (truth {truth}, {} CG steps)",
5824 closed.upper,
5825 closed.iterations
5826 );
5827 // The closed bracket's own width is what says it converged; asking
5828 // it to be near the truth would be asking the same question twice.
5829 assert!(
5830 closed.upper - closed.lower <= 0.05 * closed.upper.max(f64::MIN_POSITIVE),
5831 "the bracket did not close at log lambda {log_lambda}: [{}, {}] after {} steps",
5832 closed.lower,
5833 closed.upper,
5834 closed.iterations
5835 );
5836 println!(
5837 "#2759 log_lambda={log_lambda} truth={truth:.6e} decided=[{:.6e}, {:.6e}]@{} \
5838 closed=[{:.6e}, {:.6e}]@{} shipped={shipped:.6e} tightening={:.2}x",
5839 bracket.lower,
5840 bracket.upper,
5841 bracket.iterations,
5842 closed.lower,
5843 closed.upper,
5844 closed.iterations,
5845 shipped / closed.upper.max(f64::MIN_POSITIVE),
5846 );
5847 }
5848 }
5849
5850 /// `‖X₂ᵀW r̂‖² / (λ·d)`: the bound this issue replaces, rebuilt from the
5851 /// design so the comparison above is against a computed number.
5852 fn shipped_zeroth_order_gain_bound(
5853 design: &ResidualCascadeDesign,
5854 fit: &ResidualCascadeFit,
5855 exponent: f64,
5856 ) -> f64 {
5857 let core = &design.core;
5858 let h = core.levels[0].h * 0.5_f64.powf(exponent);
5859 let mut net = core.net.clone();
5860 let candidates = extend_net(&mut net, &core.z, core.dim, h, &core.z_range);
5861 let delta = OVERLAP * h;
5862 let mut grid = HashGrid::new(delta, core.dim);
5863 for (j, c) in candidates.iter().enumerate() {
5864 grid.insert(j as u32, c);
5865 }
5866 let residual = core.residuals(&fit.coeff);
5867 let mut g = vec![0.0_f64; candidates.len()];
5868 for (i, zi) in core.z.iter().enumerate() {
5869 let weighted = core.w[i] * residual[i];
5870 grid.for_neighbors(zi, |j| {
5871 let radius = dist2(zi, &candidates[j as usize], core.dim).sqrt() / delta;
5872 g[j as usize] += weighted * wendland(radius);
5873 });
5874 }
5875 let energy: f64 = g.iter().map(|value| value * value).sum();
5876 let lambda = fit.log_lambda.exp();
5877 energy / (lambda * level_weight(exponent, core.sobolev_s, core.dim))
5878 }
5879
5880 /// A comparison that says a candidate set buys `evidence` nats.
5881 fn comparison(evidence: f64) -> RefinementCertificate {
5882 RefinementCertificate {
5883 gain: TOLERANCE + evidence,
5884 occam: 1.0,
5885 tolerance: TOLERANCE,
5886 evidence,
5887 }
5888 }
5889
5890 #[test]
5891 fn only_an_empty_net_or_a_losing_comparison_converges() {
5892 assert_eq!(
5893 decide_refinement(NextLevelAssessment::EmptyNet, None),
5894 RefinementDecision::Converged(RefinementCertificate::EXHAUSTED)
5895 );
5896 assert_eq!(
5897 decide_refinement(NextLevelAssessment::GainBound(0.2), Some(comparison(-1.0))),
5898 RefinementDecision::Converged(comparison(-1.0))
5899 );
5900 // Exactly break-even is NOT an improvement: the finer prior has to earn
5901 // its Occam factor, and matching it is not earning it.
5902 assert_eq!(
5903 decide_refinement(NextLevelAssessment::GainBound(0.2), Some(comparison(0.0))),
5904 RefinementDecision::Converged(comparison(0.0))
5905 );
5906 assert_eq!(
5907 decide_refinement(NextLevelAssessment::GainBound(0.3), Some(comparison(1.0))),
5908 RefinementDecision::Refine
5909 );
5910 // The screen skipped the exact comparison, which it may do only when it
5911 // proved the level warranted AND there was room to take it.
5912 assert_eq!(
5913 decide_refinement(NextLevelAssessment::GainBound(0.3), None),
5914 RefinementDecision::Refine
5915 );
5916 }
5917
5918 #[test]
5919 fn capacity_reached_while_the_evidence_rises_is_underresolved() {
5920 let obstruction = RefinementObstruction::LevelCapacity {
5921 levels: MAX_LEVELS,
5922 maximum_levels: MAX_LEVELS,
5923 };
5924 assert_eq!(
5925 decide_refinement(
5926 NextLevelAssessment::CapacityExceeded {
5927 obstruction,
5928 gain_bound: 0.3,
5929 },
5930 Some(comparison(1.0)),
5931 ),
5932 RefinementDecision::Underresolved {
5933 evidence: Some(comparison(1.0)),
5934 obstruction,
5935 }
5936 );
5937
5938 // A cap that stopped the candidate set from being FORMED leaves nothing
5939 // to compare against, and an absent comparison can never certify the
5940 // discretization spent.
5941 let center_obstruction = RefinementObstruction::CenterCapacity {
5942 centers: MAX_CENTERS + 1,
5943 maximum_centers: MAX_CENTERS,
5944 };
5945 assert_eq!(
5946 decide_refinement(
5947 NextLevelAssessment::CapacityExceeded {
5948 obstruction: center_obstruction,
5949 gain_bound: f64::INFINITY,
5950 },
5951 None,
5952 ),
5953 RefinementDecision::Underresolved {
5954 evidence: None,
5955 obstruction: center_obstruction,
5956 }
5957 );
5958 }
5959
5960 /// A level nobody should add does not become a refusal because there was
5961 /// also no room for it. This is why the exact comparison runs BEFORE the
5962 /// budget is consulted, and it is the transition the whole of #2759's
5963 /// second half turns on: the rank-maximal designs refuse only because the
5964 /// criterion they are compared against charges nothing for width.
5965 #[test]
5966 fn capacity_does_not_block_a_losing_comparison() {
5967 assert_eq!(
5968 decide_refinement(
5969 NextLevelAssessment::CapacityExceeded {
5970 obstruction: RefinementObstruction::LevelCapacity {
5971 levels: MAX_LEVELS,
5972 maximum_levels: MAX_LEVELS,
5973 },
5974 gain_bound: 0.2,
5975 },
5976 Some(comparison(-1.0)),
5977 ),
5978 RefinementDecision::Converged(comparison(-1.0))
5979 );
5980 }
5981
5982 /// A 2-D fixture small enough to stay under the dense sizing cap, with a
5983 /// response that is smooth plus a deterministic wobble so the profiled
5984 /// residual is not degenerate at any λ.
5985 fn dense_fixture(side: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
5986 let mut x1 = Vec::with_capacity(side * side);
5987 let mut x2 = Vec::with_capacity(side * side);
5988 let mut y = Vec::with_capacity(side * side);
5989 for i in 0..side {
5990 for j in 0..side {
5991 let a = i as f64 / (side - 1) as f64;
5992 let b = j as f64 / (side - 1) as f64;
5993 x1.push(a);
5994 x2.push(b);
5995 y.push((2.3 * a).sin() + (1.7 * b).cos() + 0.07 * ((3 * i + 5 * j) % 7) as f64);
5996 }
5997 }
5998 (x1, x2, y)
5999 }
6000
6001 /// Column count of a `dense_fixture(side)` cascade at each level count, so
6002 /// the two width regimes the certified route now distinguishes are read off
6003 /// the design rather than guessed from the net arithmetic.
6004 #[test]
6005 fn zz_measure_cascade_width_by_level_count_2546() {
6006 for levels in 4..=8 {
6007 let (x1, x2, y) = dense_fixture(6);
6008 let weights = vec![1.0; y.len()];
6009 let axes: [&[f64]; 2] = [&x1, &x2];
6010 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, levels)
6011 .expect("cascade design");
6012 let m = design.core.m;
6013 println!(
6014 "#2546 levels={levels} m={m} gram_cached={} certified={}",
6015 design.core.dense_gram.is_some(),
6016 m <= CERTIFIED_SPECTRUM_MAX
6017 );
6018 }
6019 // The net is GEOMETRIC, not data-subsampled: `dense_fixture(6)` above is
6020 // 36 rows and still refines to 1725 columns at `levels = 6`. So the
6021 // identifiability the certified search needs -- every Schur mode carried
6022 // by the data -- is a race between a net set by `levels` and a sample set
6023 // by `side`, and neither the level table above nor the net arithmetic
6024 // says where it is won. Two guesses at it were wrong by 13 and by 8
6025 // columns respectively, so it is measured here instead.
6026 //
6027 // The band is swept DENSELY (every side from 45 to 64) rather than sampled,
6028 // because a coarse sample of this curve is what produced both wrong guesses
6029 // and then a third wrong claim drawn from the sample itself -- that no side
6030 // below 64 can be identified, inferred from 45, 50 and 60 all being short
6031 // by a small margin. The MARGIN is not monotone in `side` either, so
6032 // neighbouring sides disagree and only every-side settles it. Sides above
6033 // the band stay coarse: past the net discontinuity `m` falls away from `n`
6034 // and the outcome is no longer close.
6035 let mut sides: Vec<usize> = (45..=64).collect();
6036 sides.extend([70, 80, 90]);
6037 let mut identified_in_band: Vec<(usize, usize, usize)> = Vec::new();
6038 for side in sides {
6039 let (x1, x2, y) = dense_fixture(side);
6040 let weights = vec![1.0; y.len()];
6041 let axes: [&[f64]; 2] = [&x1, &x2];
6042 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 6)
6043 .expect("cascade design");
6044 let m = design.core.m;
6045 let n = y.len();
6046 let nullity = design.core.nullity();
6047 let identified = m - nullity <= n - nullity;
6048 let past_cache = m > DENSE_GRAM_MAX && design.core.dense_gram.is_none();
6049 let certified = m <= CERTIFIED_SPECTRUM_MAX;
6050 println!(
6051 "#2546-IDENT side={side} n={n} m={m} nullity={nullity} margin={} \
6052 identified={identified} past_cache={past_cache} certified={certified}",
6053 m as i64 - n as i64
6054 );
6055 if side <= 64 && identified && past_cache && certified {
6056 identified_in_band.push((side, m, n));
6057 }
6058 }
6059 println!(
6060 "#2546-IDENT sides in 45..=64 that are past the cache, inside the budget \
6061 and identified: {identified_in_band:?}"
6062 );
6063 }
6064
6065 /// The width regime this issue existed to open: PAST the dense Gram cache,
6066 /// INSIDE the certified spectrum budget. Automatic REML must certify here.
6067 ///
6068 /// Before #2546 this design was fit-capable but never certifiable, because
6069 /// the proof was gated on the Gram CACHE rather than on the spectrum it is
6070 /// actually made of, so `fit_residual_cascade` could not finish at all once
6071 /// refinement crossed 1536 columns. The assertion is the certificate itself:
6072 /// a returned fit from `fit_reml` carries the KKT and ordering proofs, and
6073 /// its log-determinant route is exact.
6074 ///
6075 /// The fixture has MORE ROWS THAN COLUMNS on purpose, and that premise is
6076 /// now about keeping this gate on ITS subject rather than about a design
6077 /// nobody could fit. A cascade whose box-filling net outruns its sample —
6078 /// 36 rows against 1725 columns, say — used to be refused at every width,
6079 /// including widths under `DENSE_GRAM_MAX` where the route was always open;
6080 /// that was a first-order-loose score-value enclosure and not a property of
6081 /// the data, and such designs certify now
6082 /// (`auto_reml_certifies_a_design_the_data_cannot_identify`). Keeping this
6083 /// fixture rank-sufficient still matters: this gate is about the WIDTH
6084 /// regime between the Gram cache and the spectrum budget, and a fixture that
6085 /// also crossed the identifiability frontier would fold two claims into one
6086 /// green.
6087 #[test]
6088 fn auto_reml_certifies_past_the_dense_gram_cache() {
6089 // The grid side is SEARCHED for rather than pinned, because the two
6090 // premises pull against each other and neither is a property of the code
6091 // under test. The width has to land strictly between the Gram cache and
6092 // the spectrum budget, and the sample has to be at least as large as the
6093 // width: a design with FEWER rows than columns is a separate claim (see
6094 // `auto_reml_certifies_a_design_the_data_cannot_identify`, which is the
6095 // gate for it) and would be measured here instead of the capability. Six levels of box-filling net set a
6096 // floor on the width, and a finer data grid adds centres of its own, so
6097 // the admissible sides are a band.
6098 //
6099 // The band is NOT where counting upward from 45 suggests, because `m` is
6100 // not monotone in `side`. Measured by
6101 // `zz_measure_cascade_width_by_level_count_2546` at `levels = 6`:
6102 //
6103 // side=45 n=2025 m=2038 13 short
6104 // side=47 n=2209 m=2159 IDENTIFIED, 50 to spare
6105 // side=50 n=2500 m=2508 8 short
6106 // side=60 n=3600 m=3628 28 short
6107 // side=70 n=4900 m=1922 IDENTIFIED
6108 // side=80 n=6400 m=2667 identified
6109 // side=90 n=8100 m=3637 identified, past the spectrum budget
6110 //
6111 // `m` climbs to 3628 at side=60 and then FALLS to 1922 at side=70: once
6112 // the sample is finer than the level-6 net's own spacing the net stops
6113 // adding centres for it, so width and sample decouple and the data
6114 // overtake the spectrum. That discontinuity is why "64x64 gives 4117
6115 // columns, so the band ends below 64" was a trend argument and not a
6116 // measurement.
6117 //
6118 // Below the discontinuity, though, `m` does not track `n` at a fixed
6119 // offset, and the MARGIN `m - n` is not monotone in `side` either. Swept
6120 // exhaustively over 45..=64 by `zz_measure_cascade_width_by_level_count_2546`:
6121 //
6122 // 45:+13 46: +9 47:-50 48:-63 49:+27 50: +8 51:+22 52:+21
6123 // 53:+24 54:+19 55:+27 56:+14 57:+20 58:+18 59:+20 60:+28
6124 // 61:+20 62:+22 63:+24 64:+21
6125 //
6126 // Sides 47 and 48 are a two-point DIP of -50 and -63 in a band that is
6127 // otherwise +8 to +28, and both satisfy all three conditions. So a fine
6128 // search below 64 does succeed, and "45, 50 and 60 are all short, so no
6129 // side below 64 is identified" was a third extrapolation over the same
6130 // curve -- it samples straight across the only two sides that work.
6131 //
6132 // The reason to prefer the candidate list below is therefore COST, not
6133 // reachability: side=70 is identified by 2978 columns and hits on the first
6134 // design build, where stepping from 46 pays for two builds to reach 47 and
6135 // a reader cannot tell a deliberate choice from a lucky one.
6136 //
6137 // So the candidates are the measured ones, in cost order, with the band's
6138 // edges kept after them: the search still self-heals if DENSE_GRAM_MAX or
6139 // the spectrum budget moves, but it does not pay for two dozen design
6140 // builds to rediscover a curve that has already been sampled.
6141 let mut fixture = None;
6142 for side in [70_usize, 80, 75, 85, 90, 100, 60, 50] {
6143 let (x1, x2, y) = dense_fixture(side);
6144 let weights = vec![1.0; y.len()];
6145 let m = {
6146 let axes: [&[f64]; 2] = [&x1, &x2];
6147 ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 6)
6148 .expect("cascade design")
6149 .core
6150 .m
6151 };
6152 if m > DENSE_GRAM_MAX && m <= CERTIFIED_SPECTRUM_MAX && m <= y.len() {
6153 println!("#2546 certified-past-cache fixture: side={side} m={m} rows={}", y.len());
6154 fixture = Some((x1, x2, y, weights, m));
6155 break;
6156 }
6157 }
6158 let (x1, x2, y, weights, m) = fixture.expect(
6159 "no candidate grid side puts the width between DENSE_GRAM_MAX and CERTIFIED_SPECTRUM_MAX with at least as many rows as columns -- if the caps moved, re-run zz_measure_cascade_width_by_level_count_2546 and take the candidates from its sweep rather than extrapolating a trend, because m is not monotone in side",
6160 );
6161 let axes: [&[f64]; 2] = [&x1, &x2];
6162 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 6)
6163 .expect("cascade design");
6164 assert_eq!(design.core.m, m);
6165 assert!(
6166 design.core.dense_gram.is_none(),
6167 "premise: the fixture must be past the dense Gram cache, got {m} columns"
6168 );
6169 let fit = design
6170 .fit_reml()
6171 .expect("a design past the Gram cache but inside the spectrum budget must certify");
6172 assert_eq!(fit.certificate.logdet_method, LogdetMethod::DenseExact);
6173 assert!(
6174 fit.log_lambda().is_finite(),
6175 "certified selection must return a finite log lambda, got {}",
6176 fit.log_lambda()
6177 );
6178 }
6179
6180 /// The spectral residual handed to the interval extension carries only
6181 /// POSITIVE modes, and no more of them than the data can identify.
6182 ///
6183 /// `B = Z'WZ` for an `n × rank` whitened design, so `rank(B) ≤ n − nullity`;
6184 /// on a box-filling cascade over a small sample almost every column is a
6185 /// void-filling centre the data cannot pin, and the arithmetic returns
6186 /// roundoff for those directions. Carrying them as zeros costs exactness
6187 /// nothing on the scalar path and real enclosure width on the interval path,
6188 /// so the invariant is pinned here rather than left to the enclosure's
6189 /// behaviour.
6190 #[test]
6191 fn the_spectral_residual_carries_no_null_modes() {
6192 let (x1, x2, y) = dense_fixture(6);
6193 let weights = vec![1.0; y.len()];
6194 let axes: [&[f64]; 2] = [&x1, &x2];
6195 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 6)
6196 .expect("cascade design");
6197 let core = &design.core;
6198 let identifiable = core.y.len() - core.nullity();
6199 assert!(
6200 core.m - core.nullity() > identifiable,
6201 "premise: the fixture must be rank-deficient (Schur rank {} against {identifiable} \
6202 identifiable directions)",
6203 core.m - core.nullity()
6204 );
6205 let profile = core.reml_profile().expect("spectral profile");
6206 let CascadeResidualForm::Spectral(spectrum) = &profile.residual else {
6207 panic!("this width must carry the spectral residual form");
6208 };
6209 assert!(
6210 spectrum.eigenvalue.iter().all(|&theta| theta > 0.0),
6211 "the spectral residual kept a non-positive mode"
6212 );
6213 assert_eq!(
6214 spectrum.eigenvalue.len(),
6215 spectrum.projected_square.len(),
6216 "mode and response-energy lists must stay aligned"
6217 );
6218 assert_eq!(spectrum.penalty.len(), spectrum.eigenvalue.len());
6219 assert!(
6220 spectrum.eigenvalue.len() <= identifiable,
6221 "kept {} modes against {identifiable} directions the data can identify",
6222 spectrum.eigenvalue.len()
6223 );
6224 }
6225
6226 #[test]
6227 fn auto_reml_refuses_past_the_certified_spectrum_budget() {
6228 // Two levels finer than `auto_reml_certifies_past_the_dense_gram_cache`,
6229 // which quadruples the finest box net twice over and takes the design
6230 // past the decomposition's memory budget on the same tiny row set. It
6231 // was ONE level finer while the budget admitted 2896 columns; #2758 took
6232 // the residency from seven-plus `m²` blocks to one packed triangle, the
6233 // derived cap moved with it, and level 7 (`m = 6704`, measured) now
6234 // certifies. The width is not asserted against a literal — the premise
6235 // below compares it to the budget itself.
6236 let (x1, x2, y) = dense_fixture(6);
6237 let weights = vec![1.0; y.len()];
6238 let axes: [&[f64]; 2] = [&x1, &x2];
6239 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 8)
6240 .expect("iterative-route cascade design");
6241 assert!(
6242 design.core.m > CERTIFIED_SPECTRUM_MAX,
6243 "fixture must exercise the uncertifiable route, got {} columns",
6244 design.core.m
6245 );
6246
6247 let error = match design.fit_reml() {
6248 Ok(_) => panic!("auto-REML must not claim an enclosure it cannot form"),
6249 Err(error) => error,
6250 };
6251 assert!(matches!(
6252 error,
6253 ResidualCascadeError::RemlScoreProofUnavailable {
6254 columns,
6255 certified_spectrum_max: CERTIFIED_SPECTRUM_MAX,
6256 } if columns == design.core.m
6257 ));
6258
6259 let fixed = design
6260 .fit_at(0.0, None)
6261 .expect("the same iterative design remains fit-capable at fixed lambda");
6262 assert_eq!(fixed.log_lambda(), 0.0);
6263 // Refusing the PROOF is not the same as accepting a stochastic number.
6264 // The fixed-λ fit's log-determinant is still exact, from a sparse direct
6265 // Cholesky of the same normal equations.
6266 assert_eq!(fixed.certificate.logdet_method, LogdetMethod::SparseExact);
6267 }
6268
6269 /// Process high-water resident set size in bytes, or `None` where the
6270 /// kernel does not publish one.
6271 fn read_hwm_bytes() -> Option<f64> {
6272 let status = std::fs::read_to_string("/proc/self/status").ok()?;
6273 for line in status.lines() {
6274 if let Some(rest) = line.strip_prefix("VmHWM:") {
6275 let kb: f64 = rest.trim().trim_end_matches(" kB").trim().parse().ok()?;
6276 return Some(kb * 1024.0);
6277 }
6278 }
6279 None
6280 }
6281
6282 /// `(grid side, level count)` for the two widths the residency is
6283 /// differenced over. One fixture in both arms so the per-process baseline
6284 /// cancels; both designs PAST [`DENSE_GRAM_MAX`] so the persistent Gram
6285 /// cache is absent from both readings rather than from one.
6286 const PEAK_MEMORY_ARMS: [(usize, usize); 2] = [(70, 6), (70, 7)];
6287
6288 /// Marker the per-width child prints its reading behind, and the gate finds
6289 /// it by. One definition so the writer and the reader cannot drift.
6290 const CHILD_READING_MARKER: &str = "#2546-child ";
6291
6292 /// Build the certified spectral profile at one width and report the process
6293 /// high-water mark it reached, on stdout.
6294 ///
6295 /// Printed rather than returned because the gate reads it from a CHILD
6296 /// process, which is what makes the reading attributable.
6297 fn report_certified_spectrum_peak(arm: (usize, usize)) {
6298 let (side, levels) = arm;
6299 if read_hwm_bytes().is_none() {
6300 println!("{CHILD_READING_MARKER}levels={levels} vmhwm_unavailable");
6301 return;
6302 }
6303 let (x1, x2, y) = dense_fixture(side);
6304 let weights = vec![1.0; y.len()];
6305 let axes: [&[f64]; 2] = [&x1, &x2];
6306 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, levels)
6307 .expect("cascade design");
6308 let m = design.core.m;
6309 assert!(
6310 design.core.dense_gram.is_none(),
6311 "arm side={side} levels={levels} is m={m}, inside DENSE_GRAM_MAX={DENSE_GRAM_MAX}: \
6312 its persistent cache would enter one reading and not the other, and the difference \
6313 the gate takes would be biased downward by exactly the term it exists to bound"
6314 );
6315 let started = std::time::Instant::now();
6316 let profile = design.core.reml_profile().expect("spectral profile");
6317 let elapsed = started.elapsed().as_secs_f64();
6318 let modes = profile.modes.len();
6319 drop(profile);
6320 let hwm = read_hwm_bytes().expect("VmHWM was readable a moment ago");
6321 println!(
6322 "{CHILD_READING_MARKER}levels={levels} m={m} modes={modes} vmhwm_bytes={hwm} \
6323 profile_seconds={elapsed:.2}"
6324 );
6325 }
6326
6327 /// Narrow arm of the peak-memory measurement. Run on its own it certifies
6328 /// that the profile builds at this width; run as a child of the gate below
6329 /// it is one of the two readings the gate differences.
6330 #[test]
6331 fn zz_child_certified_spectrum_peak_memory_narrow_2546() {
6332 report_certified_spectrum_peak(PEAK_MEMORY_ARMS[0]);
6333 }
6334
6335 /// Wide arm of the peak-memory measurement; see the narrow arm.
6336 #[test]
6337 fn zz_child_certified_spectrum_peak_memory_wide_2546() {
6338 report_certified_spectrum_peak(PEAK_MEMORY_ARMS[1]);
6339 }
6340
6341 /// Peak resident memory of the certified spectral profile against the width
6342 /// it was built at, so [`CERTIFIED_SPECTRUM_BYTES_PER_COLUMN_SQUARED`] — the
6343 /// figure that converts a memory budget into a column cap — is checked
6344 /// against what the route REALIZES rather than against what this file
6345 /// believes it allocates.
6346 ///
6347 /// Under `eigh` that distinction was the whole test: the decomposition was
6348 /// `faer`'s self-adjoint EVD and its tridiagonalization allocated workspace
6349 /// this crate never named, so the realized 6.41-6.84 `m²` blocks stood
6350 /// against an inventory of three. The route now holds one packed `f64`
6351 /// triangle it reduces in place (#2758), which IS an inventory this file can
6352 /// state — and the measurement is kept precisely so that claim is audited
6353 /// rather than asserted.
6354 ///
6355 /// Each width is measured in its OWN CHILD PROCESS, and that is the subject
6356 /// of this comment rather than an implementation note. `VmHWM` is a
6357 /// PROCESS-WIDE high-water mark, so a difference of two readings taken in one
6358 /// process is this route's marginal growth only if nothing else allocated
6359 /// between them. Under `cargo test` the crate's ~1750 tests are threads in a
6360 /// SINGLE process and that condition does not hold. Measured at `b8745892a`:
6361 ///
6362 /// ```text
6363 /// exclusive process, host load 308-424, RAYON_NUM_THREADS 1/2/4/8
6364 /// blocks = 6.41 / 6.84 / 6.63 / 6.79 -> passes
6365 /// shared process (`cargo test`), host load 1403
6366 /// blocks = 15.00 -> fails
6367 /// shared process (`cargo test`), host load 68
6368 /// passes
6369 /// ```
6370 ///
6371 /// Load is not the variable and parallelism is not the variable: the
6372 /// exclusive arms ran at loads comparable to the failing shared arm and read
6373 /// 6.4-6.8 every time, flat across an 8x parallelism range. Process
6374 /// exclusivity is the variable. A shared-process reading over-attributes
6375 /// whatever else allocated between the two samples to this route, and the
6376 /// 15.00 it produced would have condemned a correct constant — doubling the
6377 /// declared residency cuts [`CERTIFIED_SPECTRUM_MAX`] by `√2` and narrows
6378 /// the exact width regime this budget exists to open. A child process that
6379 /// builds one width and exits is exclusive by construction, under either
6380 /// harness.
6381 ///
6382 /// The gate is that the constant is not an UNDER-estimate: a residency
6383 /// smaller than the realized one would let the cap admit a width that
6384 /// overruns the budget it was derived from.
6385 ///
6386 /// BOTH ARMS ARE PAST [`DENSE_GRAM_MAX`], and that is load-bearing rather
6387 /// than incidental. The narrow arm used to sit at `m = 891`, where the
6388 /// design carries a persistent `dense_gram` cache — an `m²·8` term present
6389 /// in one reading and absent in the other, which does not cancel in the
6390 /// difference and biases the marginal DOWNWARD, i.e. in the direction that
6391 /// makes an under-declared residency look fine. Both widths now assert they
6392 /// have no cache before reporting.
6393 #[test]
6394 fn zz_measure_certified_spectrum_peak_memory_2546() {
6395 if read_hwm_bytes().is_none() {
6396 println!("#2546 VmHWM unavailable on this platform; peak memory not measured");
6397 return;
6398 }
6399 let exe = match std::env::current_exe() {
6400 Ok(exe) => exe,
6401 Err(error) => {
6402 println!("#2546 test binary path unavailable ({error}); peak memory not measured");
6403 return;
6404 }
6405 };
6406 // Two widths, and the DIFFERENCE of their high-water marks over the
6407 // difference of their `m²`: the per-process baseline (test binary,
6408 // fixture, allocator arenas) is identical in the two children and
6409 // cancels, where a single absolute reading would attribute all of it to
6410 // the narrow width.
6411 let mut readings: Vec<(usize, f64)> = Vec::new();
6412 for child in [
6413 "zz_child_certified_spectrum_peak_memory_narrow_2546",
6414 "zz_child_certified_spectrum_peak_memory_wide_2546",
6415 ] {
6416 let path = format!("residual_cascade::refinement_decision_tests::{child}");
6417 let output = std::process::Command::new(&exe)
6418 .args(["--exact", path.as_str(), "--nocapture", "--test-threads=1"])
6419 .output()
6420 .unwrap_or_else(|error| panic!("spawn per-width child {child}: {error}"));
6421 let stdout = String::from_utf8_lossy(&output.stdout);
6422 let mut reading: Option<(usize, f64)> = None;
6423 for line in stdout.lines() {
6424 // The marker is searched for ANYWHERE in the line, not stripped
6425 // from its start. Under `--nocapture` libtest prints a test's
6426 // stdout INLINE after its own `test <name> ... ` prefix, so the
6427 // child's line arrives as
6428 //
6429 // test residual_cascade::…::…_narrow_2546 ... #2546-child m=891 …
6430 //
6431 // and a prefix match reports every child as silent while the
6432 // reading is sitting in the middle of the line.
6433 let Some(offset) = line.find(CHILD_READING_MARKER) else {
6434 continue;
6435 };
6436 let rest = &line[offset + CHILD_READING_MARKER.len()..];
6437 let mut width: Option<usize> = None;
6438 let mut hwm: Option<f64> = None;
6439 for field in rest.split_whitespace() {
6440 // Not `.ok()`. The scanner bans discarding an error here and
6441 // is right: a malformed `m=` or `vmhwm_bytes=` left the
6442 // field `None`, `reading` then stayed `None`, and the
6443 // `unwrap_or_else(|| panic!(..))` below fired with "no child
6444 // reading" -- blaming an ABSENT line for a line that was
6445 // present and unparseable. The reader is sent to look for a
6446 // missing marker that is right there.
6447 if let Some(value) = field.strip_prefix("m=") {
6448 width = Some(value.parse().unwrap_or_else(|error| {
6449 panic!(
6450 "child reading line carries an unparseable m={value:?}: \
6451 {error}; the marker was found, so this is a malformed \
6452 field, not a missing reading"
6453 )
6454 }));
6455 } else if let Some(value) = field.strip_prefix("vmhwm_bytes=") {
6456 hwm = Some(value.parse().unwrap_or_else(|error| {
6457 panic!(
6458 "child reading line carries an unparseable \
6459 vmhwm_bytes={value:?}: {error}; the marker was found, \
6460 so this is a malformed field, not a missing reading"
6461 )
6462 }));
6463 }
6464 }
6465 if let (Some(width), Some(hwm)) = (width, hwm) {
6466 reading = Some((width, hwm));
6467 }
6468 }
6469 let (m, hwm) = reading.unwrap_or_else(|| {
6470 panic!(
6471 "child {child} produced no reading (status {:?}).\nstdout:\n{stdout}\nstderr:\n{}",
6472 output.status,
6473 String::from_utf8_lossy(&output.stderr)
6474 )
6475 });
6476 println!(
6477 "#2546 m={m} vmhwm={:.1}MiB (own process)",
6478 hwm / (1024.0 * 1024.0)
6479 );
6480 readings.push((m, hwm));
6481 }
6482 let (narrow, narrow_hwm) = readings[0];
6483 let (wide, wide_hwm) = readings[1];
6484 assert!(
6485 wide > narrow,
6486 "the widths must increase for a high-water difference to mean anything"
6487 );
6488 let square_growth = wide as f64 * wide as f64 - narrow as f64 * narrow as f64;
6489 let bytes_per_square = (wide_hwm - narrow_hwm) / square_growth;
6490 println!(
6491 "#2546 marginal bytes_per_m2={bytes_per_square:.2} \
6492 declared={CERTIFIED_SPECTRUM_BYTES_PER_COLUMN_SQUARED} \
6493 cap={CERTIFIED_SPECTRUM_MAX} budget_MiB={}",
6494 CERTIFIED_SPECTRUM_BYTES / (1024 * 1024)
6495 );
6496 assert!(
6497 bytes_per_square <= CERTIFIED_SPECTRUM_BYTES_PER_COLUMN_SQUARED as f64,
6498 "the certified route grows by {bytes_per_square:.2} bytes per m-squared against a \
6499 declared {CERTIFIED_SPECTRUM_BYTES_PER_COLUMN_SQUARED} (m {narrow} -> {wide}); the \
6500 column cap derived from CERTIFIED_SPECTRUM_BYTES is therefore an under-estimate of \
6501 the memory it admits"
6502 );
6503 }
6504
6505 /// Fill-in of the sparse direct factor, against the dense triangle it
6506 /// replaces, on the past-cache widths this route exists to serve.
6507 ///
6508 /// The sparse route is worth taking only if the AMD ordering's realized
6509 /// `nnz(L)` is far below `m(m+1)/2` — the number a dense Cholesky would
6510 /// store. That is a property of the design's sparsity, not an assumption, so
6511 /// it is measured and asserted rather than argued: a multilevel Wendland row
6512 /// touches `O(1)` bumps per level, so `A` is sparse and its factor should be
6513 /// a small fraction of the dense triangle at every width here. If fill-in
6514 /// ever made the sparse factor comparable to dense storage, this gate fails
6515 /// and the route's premise is gone.
6516 #[test]
6517 fn zz_measure_sparse_factor_fill_in_2546() {
6518 for levels in [6usize, 7] {
6519 let (x1, x2, y) = dense_fixture(6);
6520 let weights = vec![1.0; y.len()];
6521 let axes: [&[f64]; 2] = [&x1, &x2];
6522 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, levels)
6523 .expect("cascade design");
6524 let core = &design.core;
6525 let system = core.sparse_upper_system(1.0).expect("sparse normal equations");
6526 let nnz_a = system.compute_nnz();
6527 let nnz_l = sparse_spd_factor_nnz(&system).expect("symbolic analysis");
6528 let dense_upper = core.m * (core.m + 1) / 2;
6529 println!(
6530 "#2546 levels={levels} m={} nnz(A)={nnz_a} nnz(L)={nnz_l} dense_upper={dense_upper} \
6531 fraction_of_dense={:.5} budget={SPARSE_FACTOR_MAX_NNZ}",
6532 core.m,
6533 nnz_l as f64 / dense_upper as f64
6534 );
6535 assert!(
6536 nnz_l * 4 < dense_upper,
6537 "sparse factor is not sparse at m={}: nnz(L)={nnz_l} against a dense triangle of \
6538 {dense_upper}; the sparse route's premise does not hold on this design",
6539 core.m
6540 );
6541 assert!(
6542 nnz_l <= SPARSE_FACTOR_MAX_NNZ,
6543 "fill-in {nnz_l} exceeds the factor budget {SPARSE_FACTOR_MAX_NNZ} at m={}",
6544 core.m
6545 );
6546 }
6547 }
6548
6549 /// The sparse direct log-determinant and the dense one are the same number.
6550 ///
6551 /// Both are exact factorizations of the same `X'WX + λD`, so they may differ
6552 /// only by floating-point summation order. The bound is the dense Cholesky's
6553 /// own forward error on a log-determinant — `O(m)·eps` per diagonal term over
6554 /// `m` terms — not a tuned tolerance, and it is charged on a design narrow
6555 /// enough to HAVE a dense route to compare against.
6556 #[test]
6557 fn sparse_and_dense_logdets_agree() {
6558 let (x1, x2, y) = dense_fixture(6);
6559 let weights = vec![1.0; y.len()];
6560 let axes: [&[f64]; 2] = [&x1, &x2];
6561 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 4)
6562 .expect("cascade design");
6563 let core = &design.core;
6564 assert!(
6565 core.dense_gram.is_some(),
6566 "premise: the comparator needs the dense route, got m = {}",
6567 core.m
6568 );
6569 for log_lambda in [-4.0_f64, 0.0, 4.0] {
6570 let lambda = log_lambda.exp();
6571 let dense = core.logdet_dense(lambda).expect("dense logdet");
6572 let factor = core
6573 .sparse_exact_factor(lambda)
6574 .expect("sparse factorization")
6575 .expect("fill-in is inside the budget on this fixture");
6576 let sparse = logdet_from_factor(&factor).expect("sparse logdet");
6577 let resolution = f64::EPSILON * core.m as f64 * dense.abs().max(1.0);
6578 assert!(
6579 (sparse - dense).abs() <= resolution,
6580 "sparse and dense log-determinants disagree at log lambda {log_lambda}: \
6581 {sparse} versus {dense} (resolution {resolution})"
6582 );
6583 }
6584 }
6585
6586 /// The residual spectral sum and a direct factorization compute the same
6587 /// function of λ.
6588 ///
6589 /// The spectral expression reads the profiled residual and its three
6590 /// quadratic forms off the Schur decomposition; the comparator re-derives them from a
6591 /// factorization of `A = X'WX + λD`. If they ever disagree the criterion is
6592 /// representation-dependent, which is the defect the spectral form exists to remove —
6593 /// so the agreement is asserted directly rather than inferred from the
6594 /// scores that consume it.
6595 ///
6596 /// The bound is the textbook forward-error of the comparator, not a tuned
6597 /// number: the comparator's Cholesky solve carries `O(m)·eps·cond(A)`, and
6598 /// `cond(A) = (θ_max + λ)/(θ_min + λ)` is available exactly from the same
6599 /// spectrum. Nothing here is free to be widened without changing that claim.
6600 #[test]
6601 fn spectral_and_solved_residual_forms_agree() {
6602 let (x1, x2, y) = dense_fixture(6);
6603 let weights = vec![1.0; y.len()];
6604 let axes: [&[f64]; 2] = [&x1, &x2];
6605 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
6606 .expect("cascade design");
6607 let core = &design.core;
6608 assert!(core.dense_gram.is_some(), "fixture must take the dense route");
6609 let profile = core.reml_profile().expect("spectral profile");
6610 let CascadeResidualForm::Spectral(spectrum) = &profile.residual else {
6611 panic!("the dense route must carry the spectral residual form");
6612 };
6613
6614 // `cond(A) = (θ_max + λ)/(θ_min + λ)` is taken over EVERY Schur mode,
6615 // including the certified-null ones the spectral residual now drops: a
6616 // null mode still sits at exactly λ in `A`'s spectrum, and it is what
6617 // sets the comparator's conditioning. Reading `spectrum.eigenvalue`
6618 // instead would silently tighten the comparator's own error budget.
6619 let smallest = profile
6620 .modes
6621 .iter()
6622 .map(|mode| mode.eigenvalue)
6623 .fold(f64::INFINITY, f64::min);
6624 let largest = profile
6625 .modes
6626 .iter()
6627 .map(|mode| mode.eigenvalue)
6628 .fold(0.0_f64, f64::max);
6629
6630 for log_lambda in [-6.0_f64, -2.0, 0.0, 2.0, 6.0] {
6631 let lambda = log_lambda.exp();
6632 let (rss, penalty_energy, inverse_penalty_energy, third_energy) =
6633 spectrum.moments(lambda);
6634
6635 let (coeff, _, _) = core
6636 .solve_coeff(lambda, &core.rhs, None)
6637 .expect("first solve");
6638 let dc: Vec<f64> = coeff
6639 .iter()
6640 .zip(core.pen_diag.iter())
6641 .map(|(&c, &d)| d * c)
6642 .collect();
6643 let (u, _, _) = core.solve_coeff(lambda, &dc, None).expect("second solve");
6644 let solved = [
6645 core.rss_pen(&coeff),
6646 coeff.iter().zip(dc.iter()).map(|(&c, &v)| c * v).sum(),
6647 dc.iter().zip(u.iter()).map(|(&a, &b)| a * b).sum(),
6648 u.iter()
6649 .zip(core.pen_diag.iter())
6650 .map(|(&v, &d)| d * v * v)
6651 .sum(),
6652 ];
6653 let spectral = [rss, penalty_energy, inverse_penalty_energy, third_energy];
6654 let names = ["R", "c'Dc", "(Dc)'A^-1(Dc)", "u'Du"];
6655
6656 let condition = (largest + lambda) / (smallest + lambda);
6657 // The three quadratic forms are sums of positive terms, so their
6658 // relative error is `O(m)·eps·cond(A)`. `R` is not: BOTH routes form
6659 // it by subtracting a fitted energy from an anchor energy, so its
6660 // relative error carries that cancellation's own condition number,
6661 // `anchor/|R|`. Charging the sum of the two is the honest bound.
6662 //
6663 // AND IT IS CHARGED TWICE, once per comparand. This is a comparison
6664 // of two INDEPENDENT computations of one quantity, so the gap it can
6665 // legitimately show is the sum of both forward errors — the solve's,
6666 // and the spectral route's own mode sum over the same spectrum with
6667 // the same condition number. Charging one of them treated the
6668 // spectral side as exact, which no decomposition is: at `m = 20` and
6669 // `cond(A) = 1.005` the one-sided bound is `4.46e-15` and the
6670 // measured gap `4.60e-15`, i.e. the gate was failing on the last
6671 // bit of a perfectly conditioned 20-column problem the moment the
6672 // decomposition's rounding differed. Two equal terms, not a factor
6673 // chosen to admit a number.
6674 let cancellation = spectrum.anchor_energy[0] / rss.abs().max(f64::MIN_POSITIVE);
6675 let comparands = 2.0;
6676 let bounds = [
6677 comparands * core.m as f64 * f64::EPSILON * (condition + cancellation),
6678 comparands * core.m as f64 * f64::EPSILON * condition,
6679 comparands * core.m as f64 * f64::EPSILON * condition,
6680 comparands * core.m as f64 * f64::EPSILON * condition,
6681 ];
6682 for (((&a, &b), name), bound) in
6683 spectral.iter().zip(solved.iter()).zip(names).zip(bounds)
6684 {
6685 let gap = (a - b).abs() / b.abs().max(f64::MIN_POSITIVE);
6686 assert!(
6687 gap <= bound,
6688 "{name} disagrees at log lambda {log_lambda}: spectral {a}, solved {b} \
6689 (relative {gap:e} exceeds the comparator's own forward error {bound:e} \
6690 at cond(A) = {condition:e}, cancellation = {cancellation:e})"
6691 );
6692 }
6693 }
6694 }
6695
6696 /// The cascade's own closed form and the [`AffineRemlProfile`] the search
6697 /// actually runs on are one score.
6698 ///
6699 /// `fit_reml` isolates the optimum with the affine profile (for its interval
6700 /// extension) while `criterion` and the selected `normalized_logdet` come
6701 /// from [`CascadeRemlProfile::evaluate`]. Two implementations of one
6702 /// quantity is exactly the arrangement that lets a criterion drift, so the
6703 /// two are held to agreement in value, slope and curvature here.
6704 ///
6705 /// The tolerance is the forward error of the arithmetic the two routes
6706 /// actually perform, read off THIS fixture's spectral moments at each
6707 /// evaluation point. It is not `rank·eps`, and the reason is measured:
6708 ///
6709 /// 1. THE TWO ROUTES DO NOT EVALUATE AT THE SAME LAMBDA. The cascade
6710 /// exponentiates `rho` through `checked_exp_log_strength`, i.e. the
6711 /// platform `exp` (sub-ulp); [`AffineRemlProfile::evaluate`] uses
6712 /// `certified_exp_representative`, the midpoint of an outward-rounded
6713 /// enclosure that is hundreds of ulps wide. Neither route may adopt the
6714 /// other's: the cascade's criterion has to describe the lambda the fit
6715 /// is actually solved at, and the affine profile's exponential has to be
6716 /// the one its own enclosure is stated in. Over this domain the measured
6717 /// `|lambda_affine - lambda_cascade| / lambda` runs to `2.59e-14`. A
6718 /// RELATIVE shift `delta` in lambda IS an absolute shift `delta` in
6719 /// `rho`, so every mode kernel moves by its own `d/drho` times `delta`,
6720 /// and each accumulator below is charged exactly that. At
6721 /// `rho = -2.6396` this term alone is `|curvature|·delta =
6722 /// 2.302 · 1.67e-14 = 3.8e-14`, which is the whole of the measured
6723 /// `3.9e-14` slope disagreement that `rank·eps = 3.77e-15` was being
6724 /// asked to cover.
6725 /// 2. The mode sums are sums of `rank` terms of SIZE, not of size one. The
6726 /// determinant slope is `sum_i t_i` with `t_i = theta_i/(theta_i +
6727 /// lambda)`, so its Wilkinson error is `rank·eps·sum_i t_i`; at that same
6728 /// `rho`, `sum_i t_i = 10.71`, i.e. `4.0e-14` and not `3.8e-15`. The
6729 /// profiled residual `R = anchor - S1` is the one subtraction of
6730 /// near-equal positives, so its error carries the cancellation factor
6731 /// `anchor/R` (up to `4.13` at the small-lambda end) — the same factor
6732 /// [`spectral_and_solved_residual_forms_agree`] charges — and the score
6733 /// multiplies it by `dof`.
6734 ///
6735 /// Every factor below is computed from the fixture at the evaluation point;
6736 /// nothing is a fitted constant, and the assertion is on ABSOLUTE
6737 /// disagreement so a normalization cannot quietly absorb a growing gap.
6738 #[test]
6739 fn affine_view_is_the_same_score_as_the_cascade_jet() {
6740 let (x1, x2, y) = dense_fixture(6);
6741 let weights = vec![1.0; y.len()];
6742 let axes: [&[f64]; 2] = [&x1, &x2];
6743 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
6744 .expect("cascade design");
6745 let profile = design.core.reml_profile().expect("spectral profile");
6746 let affine = profile
6747 .affine_view()
6748 .expect("affine view")
6749 .expect("the dense route must expose an affine view");
6750 let CascadeResidualForm::Spectral(spectrum) = &profile.residual else {
6751 panic!("the dense route must carry the spectral residual form");
6752 };
6753 let (lo, hi) = profile.log_lambda_domain().expect("domain");
6754 let rank = (design.core.m - design.core.nullity()) as f64;
6755 let dof = (design.core.y.len() - design.core.nullity()) as f64;
6756 let eps = f64::EPSILON;
6757
6758 for step in 0..=8 {
6759 let log_lambda = lo + (hi - lo) * step as f64 / 8.0;
6760 let cascade = profile.evaluate(log_lambda).expect("cascade jet").jet;
6761 let spectral = affine.evaluate(log_lambda).expect("affine jet");
6762
6763 // The two exponentials the two routes run, and the `rho` shift
6764 // between them.
6765 let lambda =
6766 gam_problem::checked_exp_log_strength(log_lambda).expect("cascade lambda");
6767 let affine_lambda = gam_math::score_opt::certified_exp_representative(log_lambda)
6768 .expect("affine lambda");
6769 let shift = (affine_lambda - lambda).abs() / lambda;
6770
6771 let (rss, s2, s3, s4) = spectrum.moments(lambda);
6772 let anchor = spectrum.anchor_energy[0];
6773 // The magnitudes the three determinant accumulators run over, and
6774 // the `d/drho` of each summand: `d/drho log(1 + theta/lambda) = -t`
6775 // and `d/drho t = -t(1-t)`.
6776 let mut logdet_magnitude = 0.0_f64;
6777 let mut slope_magnitude = 0.0_f64;
6778 let mut curvature_magnitude = 0.0_f64;
6779 for &theta in &spectrum.eigenvalue {
6780 let t = theta / (theta + lambda);
6781 logdet_magnitude += (1.0 + theta / lambda).ln().abs();
6782 slope_magnitude += t;
6783 curvature_magnitude += t * (1.0 - t);
6784 }
6785
6786 // Residual derivatives in `rho`, and the SUM OF MAGNITUDES of each
6787 // cancelling form — which is what a forward-error argument is
6788 // entitled to charge:
6789 // first = R' = lambda S2
6790 // second = R'' = R' - 2 lambda^2 S3
6791 // third = R''' = R' - 6 lambda^2 S3 + 6 lambda^3 S4.
6792 let lambda_squared = lambda * lambda;
6793 let lambda_cubed = lambda_squared * lambda;
6794 let first = lambda * s2;
6795 let second = first - 2.0 * lambda_squared * s3;
6796 let second_magnitude = first + 2.0 * lambda_squared * s3;
6797 let third_magnitude = first + 6.0 * lambda_squared * s3 + 6.0 * lambda_cubed * s4;
6798
6799 // Each route accumulates `rank` terms sequentially and rounds a
6800 // handful of elementary operations per term, so each carries
6801 // `(rank + 4)·eps` relative on its own sum; the comparison is
6802 // charged both.
6803 let sum_eps = 2.0 * (rank + 4.0) * eps;
6804 let residual_error = sum_eps * anchor + first * shift;
6805 let first_error = sum_eps * first + second_magnitude * shift;
6806 let second_error = sum_eps * second_magnitude + third_magnitude * shift;
6807 let log_first = first / rss;
6808 let log_first_error = first_error / rss + log_first.abs() * residual_error / rss;
6809 let log_second_error = second_error / rss
6810 + (second / rss).abs() * residual_error / rss
6811 + 2.0 * log_first.abs() * log_first_error;
6812
6813 let determinant_value_error = sum_eps * logdet_magnitude + slope_magnitude * shift;
6814 let determinant_slope_error = sum_eps * slope_magnitude + curvature_magnitude * shift;
6815 // The cascade forms the curvature summand as `t·(1-t)`, and `1-t`
6816 // cancels as `t -> 1`: one eps of `t` per mode. The affine route
6817 // carries the same complement as `lambda·s/h` and never subtracts.
6818 let determinant_curvature_error = sum_eps * curvature_magnitude
6819 + eps * slope_magnitude
6820 + curvature_magnitude * shift;
6821
6822 let bounds = [
6823 0.5 * (determinant_value_error
6824 + dof * (residual_error / rss + 2.0 * eps * (rss / dof).ln().abs())),
6825 0.5 * (determinant_slope_error + dof * log_first_error),
6826 0.5 * (determinant_curvature_error + dof * log_second_error),
6827 ];
6828 for ((name, a, b), bound) in [
6829 ("value", cascade.value, spectral.value),
6830 ("derivative", cascade.derivative, spectral.derivative),
6831 ("curvature", cascade.curvature, spectral.curvature),
6832 ]
6833 .into_iter()
6834 .zip(bounds)
6835 {
6836 let gap = (a - b).abs();
6837 assert!(
6838 gap <= bound,
6839 "{name} disagrees at log lambda {log_lambda}: cascade {a}, affine {b} \
6840 (absolute {gap:e} exceeds the two routes' own forward error {bound:e}, \
6841 whose terms are the lambda shift {shift:e}, the mode sums \
6842 {slope_magnitude:e} / {curvature_magnitude:e} / {logdet_magnitude:e}, \
6843 and the residual cancellation anchor/R {:e})",
6844 anchor / rss
6845 );
6846 }
6847 }
6848 }
6849
6850 #[test]
6851 fn dense_spectral_profile_matches_factorization_and_analytic_slope() {
6852 let (x1, x2, y) = dense_fixture(6);
6853 let weights = vec![1.0; y.len()];
6854 let axes: [&[f64]; 2] = [&x1, &x2];
6855 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
6856 .expect("cascade design");
6857 assert!(design.core.dense_gram.is_some());
6858 let profile = design.core.reml_profile().expect("spectral profile");
6859 let rank = (design.core.m - design.core.nullity()) as f64;
6860 let dof = (design.core.y.len() - design.core.nullity()) as f64;
6861
6862 for log_lambda in [-4.0, 0.0, 3.0] {
6863 let evaluation = profile.evaluate(log_lambda).expect("analytic score");
6864 let lambda = log_lambda.exp();
6865 let logdet = design.core.logdet_dense(lambda).expect("dense logdet");
6866 let coefficients = design
6867 .core
6868 .solve_coeff(lambda, &design.core.rhs, None)
6869 .expect("dense solve")
6870 .0;
6871 let rss = design.core.rss_pen(&coefficients);
6872 let direct = -0.5
6873 * (logdet - rank * log_lambda - design.core.pen_logdet_const
6874 + dof * (rss / dof).ln());
6875 assert!(
6876 (evaluation.jet.value - direct).abs() <= f64::EPSILON.sqrt() * (1.0 + direct.abs()),
6877 "spectral/direct score mismatch at {log_lambda}: {} versus {direct}",
6878 evaluation.jet.value,
6879 );
6880
6881 // Finite differences are confined to this oracle test. The
6882 // production optimizer consumes the hand-derived score jet above.
6883 //
6884 // The comparator has to be built for a NOISY evaluator, and this
6885 // one is: `profile.evaluate` runs a spectral solve, so its value
6886 // carries roughly 1e-12 of evaluation noise rather than being exact
6887 // to the last bit.
6888 //
6889 // That moves the optimal step. `h = eps^(1/3)` is optimal only when
6890 // the sole error is representation roundoff; against noise `v` the
6891 // central-difference error is `v/h + (h²/6)·S3`, minimized at
6892 // `h ~ (3v/S3)^(1/3) ~ 1e-4` — three orders ABOVE `eps^(1/3)`.
6893 // Measured at `eps^(1/3) = 6.06e-6`: D(h) = 5.025511346842242,
6894 // D(h/2) = 5.025511611442345. The two stencils disagree by 2.6e-7
6895 // and the FINER one is FARTHER from the analytic slope. Truncation
6896 // shrinks with h and cannot do that; noise amplified by `1/h` does
6897 // exactly that. The step was too small, not too crude.
6898 //
6899 // So: `h = 1e-4`, and Richardson there. The `h²` term cancels
6900 // exactly (leaving O(h⁴) ~ 1e-16, negligible whatever `S3` is) and
6901 // the noise floor is `~3v/h ~ 3e-8`, inside the unchanged
6902 // `sqrt(eps)·(1+|S'|) ~ 9e-8` bound. The bound is not relaxed; the
6903 // comparator is made accurate enough to be charged against it.
6904 let central = |step: f64| -> f64 {
6905 let right = profile
6906 .evaluate(log_lambda + step)
6907 .expect("right score")
6908 .jet
6909 .value;
6910 let left = profile
6911 .evaluate(log_lambda - step)
6912 .expect("left score")
6913 .jet
6914 .value;
6915 (right - left) / (2.0 * step)
6916 };
6917 let step = 1.0e-4;
6918 let coarse = central(step);
6919 let fine = central(0.5 * step);
6920 let numerical_slope = (4.0 * fine - coarse) / 3.0;
6921 assert!(
6922 (evaluation.jet.derivative - numerical_slope).abs()
6923 <= f64::EPSILON.sqrt() * (1.0 + numerical_slope.abs()),
6924 "analytic slope mismatch at {log_lambda}: {} versus {numerical_slope} \
6925 (Richardson of h={step:e} → {coarse}, h/2 → {fine})",
6926 evaluation.jet.derivative,
6927 );
6928 }
6929 }
6930 /// A fixture whose rank is far above the quadrature's accepted step count,
6931 /// so the truncated regime is what gets measured, while staying under the
6932 /// dense cap so an exact comparator survives.
6933 fn truncated_regime_fixture() -> (Vec<f64>, Vec<f64>, Vec<f64>, usize) {
6934 let (x1, x2, y) = dense_fixture(28);
6935 (x1, x2, y, 5)
6936 }
6937
6938 fn cascade_core(
6939 side_data: (&[f64], &[f64], &[f64]),
6940 levels: usize,
6941 ) -> ResidualCascadeDesign {
6942 let (x1, x2, y) = side_data;
6943 let weights = vec![1.0; y.len()];
6944 let axes: [&[f64]; 2] = [x1, x2];
6945 ResidualCascadeDesign::build(&axes, y, &weights, &[1.0, 1.0], 2.0, levels)
6946 .expect("cascade design")
6947 }
6948
6949 /// #2503 measurement — what the beta-seeded Golub–Meurant residual quadrature
6950 /// accepts, at what step count, and how far its moments then sit from the
6951 /// exact dense eigenbasis over the whole log-lambda domain.
6952 #[test]
6953 fn zz_measure_residual_quadrature_admission_ladder_2503() {
6954 for (side, levels) in [
6955 (6usize, 2usize),
6956 (10, 3),
6957 (14, 3),
6958 (22, 3),
6959 (14, 4),
6960 (20, 5),
6961 (28, 5),
6962 ] {
6963 let (x1, x2, y) = dense_fixture(side);
6964 let design = cascade_core((&x1, &x2, &y), levels);
6965 let core = &design.core;
6966 if core.dense_gram.is_none() {
6967 println!("#2503 side={side} levels={levels} m={}: past the dense cap", core.m);
6968 continue;
6969 }
6970 let (null_chol, _) = core.null_gram_factor().expect("null factor");
6971 let (modes, exact) = core
6972 .dense_cascade_spectrum(&null_chol)
6973 .expect("dense spectrum");
6974 let domain = certified_log_lambda_domain_from_modes(&modes).expect("domain");
6975 let (spectrum, certificate) = core
6976 .iterative_residual_spectrum(&null_chol, domain)
6977 .expect("quadrature");
6978 let rank = core.m - core.nullity();
6979 let anchor = exact.anchor_energy[0];
6980 let worst = spectrum.as_ref().map(|spectrum| {
6981 let mut worst = [0.0_f64; 4];
6982 for step in 0..=192 {
6983 let lambda =
6984 (domain.0 + (domain.1 - domain.0) * step as f64 / 192.0).exp();
6985 let t = exact.moments(lambda);
6986 let g = spectrum.moments(lambda);
6987 worst[0] = worst[0].max((g.0 - t.0).abs() / anchor.abs());
6988 for (k, (tv, gv)) in
6989 [(t.1, g.1), (t.2, g.2), (t.3, g.3)].into_iter().enumerate()
6990 {
6991 worst[k + 1] =
6992 worst[k + 1].max((gv - tv).abs() / tv.abs().max(f64::MIN_POSITIVE));
6993 }
6994 }
6995 worst
6996 });
6997 println!(
6998 "#2503 side={side} levels={levels} n={} m={} rank={rank} budget={} steps={} \
6999 coarse={} tail_estimate={:.3e} target={:.3e} accepted={} invariant={} tail={:.2e} \
7000 mass_defect={:.2e} dropped={:.2e}",
7001 y.len(),
7002 core.m,
7003 certificate.budget,
7004 certificate.steps,
7005 certificate.coarse_steps,
7006 certificate.tail_estimate,
7007 certificate.target,
7008 certificate.accepted_for_point_evaluation,
7009 certificate.invariant,
7010 certificate.relative_tail,
7011 certificate.mass_defect,
7012 certificate.dropped_mass_fraction,
7013 );
7014 match worst {
7015 Some(worst) => println!(
7016 "#2503 versus exact dense: dR/anchor={:.3e} S2={:.3e} S3={:.3e} S4={:.3e}",
7017 worst[0], worst[1], worst[2], worst[3]
7018 ),
7019 None => println!("#2503 no rule admitted; point evaluation is refused"),
7020 }
7021 }
7022 }
7023
7024 /// The admitted point quadrature matches the dense eigenbasis residual to the
7025 /// resolution its numerical evidence claims on this fixture.
7026 ///
7027 /// This is the substitution gate: past the dense cap the profiled residual and
7028 /// its three `log λ` derivative moments come from a Golub–Meurant rule instead
7029 /// of an exact projection, and if the two disagree anywhere in the domain the
7030 /// REML criterion is route-dependent — which is the defect the spectral form
7031 /// exists to remove.
7032 ///
7033 /// The fixture asserts its own premise. `rank ≫ steps` is what puts the rule
7034 /// in the TRUNCATED regime; at `steps == rank` the Krylov space is the whole
7035 /// space and the rule reproduces the spectral sum by dimension alone, so a gate
7036 /// that silently drifted into that case would measure arithmetic and not
7037 /// quadrature. #2503's first accuracy gate did exactly that.
7038 #[test]
7039 fn admitted_residual_quadrature_matches_the_dense_eigenbasis_2503() {
7040 let (x1, x2, y, levels) = truncated_regime_fixture();
7041 let design = cascade_core((&x1, &x2, &y), levels);
7042 let core = &design.core;
7043 assert!(
7044 core.dense_gram.is_some(),
7045 "the exact comparator needs the dense route"
7046 );
7047 let (null_chol, _) = core.null_gram_factor().expect("null factor");
7048 let (modes, exact) = core
7049 .dense_cascade_spectrum(&null_chol)
7050 .expect("dense spectrum");
7051 let domain = certified_log_lambda_domain_from_modes(&modes).expect("domain");
7052 let (spectrum, certificate) = core
7053 .iterative_residual_spectrum(&null_chol, domain)
7054 .expect("quadrature");
7055 let rank = core.m - core.nullity();
7056 assert!(
7057 certificate.accepted_for_point_evaluation,
7058 "the point quadrature must be admitted on this fixture: {certificate:?}"
7059 );
7060 assert!(
7061 certificate.steps * 2 < rank,
7062 "premise: the accepted rule must be TRUNCATED (steps {} against rank {rank}), else \
7063 this gate measures arithmetic and not quadrature",
7064 certificate.steps
7065 );
7066 assert!(
7067 certificate.tail_estimate <= certificate.target,
7068 "the extrapolated tail must reach the search resolution: {certificate:?}"
7069 );
7070 let spectrum = spectrum.expect("an admitted point rule is returned");
7071
7072 // The comparator is the exact projection, and the bound is the resolution
7073 // the evidence claims — not a widened number. `R` is charged
7074 // ABSOLUTELY against the anchor energy: `R = anchor − S₁` cancels to nine
7075 // digits at the bottom of an over-complete cascade's domain, so a relative
7076 // bound there would be a statement about cancellation.
7077 let anchor = exact.anchor_energy[0];
7078 let resolution = f64::EPSILON.sqrt();
7079 for step in 0..=192 {
7080 let log_lambda = domain.0 + (domain.1 - domain.0) * step as f64 / 192.0;
7081 let lambda = log_lambda.exp();
7082 let (r_exact, s2_exact, s3_exact, s4_exact) = exact.moments(lambda);
7083 let (r_gauss, s2_gauss, s3_gauss, s4_gauss) = spectrum.moments(lambda);
7084 assert!(
7085 (r_gauss - r_exact).abs() <= resolution * anchor.abs(),
7086 "profiled residual disagrees at log lambda {log_lambda}: {r_gauss} versus \
7087 {r_exact} (anchor {anchor})"
7088 );
7089 for (name, gauss, exact_value) in [
7090 ("S2", s2_gauss, s2_exact),
7091 ("S3", s3_gauss, s3_exact),
7092 ("S4", s4_gauss, s4_exact),
7093 ] {
7094 assert!(
7095 (gauss - exact_value).abs() <= resolution * exact_value.abs(),
7096 "{name} disagrees at log lambda {log_lambda}: {gauss} versus {exact_value} \
7097 ({certificate:?})"
7098 );
7099 }
7100 }
7101 }
7102
7103 /// The admitted quadrature and the SOLVE it replaces are the same function of
7104 /// λ, measured on the iterative route itself.
7105 ///
7106 /// Every other gate here charges the quadrature against the dense eigenbasis,
7107 /// which means charging it on designs small enough to HAVE one. This one runs
7108 /// past the dense sizing cap — where there is no eigenbasis, no `dense_gram`,
7109 /// and the only other way to obtain `S₁..S₄` is the pair of PCG solves the
7110 /// shipped route used to perform at every λ. If those two disagree, the REML
7111 /// criterion is route-dependent, which is the defect the spectral form exists
7112 /// to remove; and this is the only angle from which that can be checked where
7113 /// it actually matters.
7114 ///
7115 /// The λ is chosen where PCG is well conditioned, because the comparator is
7116 /// the thing with the error bar: `cond(B + λI) ≤ (θmax + λ)/λ`, and the solve
7117 /// carries `CG_RTOL` backward error, so its forward error on `S₁` is about
7118 /// `CG_RTOL · cond` and on `S₃, S₄` — which pass through a second solve —
7119 /// about its square. The bound below is that product with the measured
7120 /// conditioning substituted, not a widened number.
7121 #[test]
7122 fn the_quadrature_and_the_solve_it_replaces_agree_past_the_dense_cap_2503() {
7123 let (x1, x2, y) = dense_fixture(56);
7124 let design = cascade_core((&x1, &x2, &y), 6);
7125 let core = &design.core;
7126 assert!(
7127 core.dense_gram.is_none(),
7128 "premise: this fixture must be PAST the dense sizing cap (m = {}), or the solve is \
7129 not the comparator this gate is about",
7130 core.m
7131 );
7132 let (null_chol, _) = core.null_gram_factor().expect("null factor");
7133 let modes = core
7134 .iterative_cascade_spectrum(&null_chol)
7135 .expect("determinant modes");
7136 let domain = certified_log_lambda_domain_from_modes(&modes).expect("domain");
7137 let (spectrum, certificate) = core
7138 .iterative_residual_spectrum(&null_chol, domain)
7139 .expect("quadrature");
7140 let spectrum = spectrum.unwrap_or_else(|| {
7141 panic!("the past-cap quadrature must certify on this fixture: {certificate:?}")
7142 });
7143 let theta_max = spectrum.eigenvalue.iter().copied().fold(0.0, f64::max);
7144
7145 for log_lambda in [0.0_f64, 1.0, 2.0, 3.0] {
7146 let lambda = log_lambda.exp();
7147 let quadrature = spectrum.moment_sums(lambda);
7148
7149 // Exactly what `CascadeRemlProfile::evaluate` does on the solve route.
7150 let (coeff, _, _) = core
7151 .solve_coeff(lambda, &core.rhs, None)
7152 .expect("first certified solve");
7153 let dc: Vec<f64> = coeff
7154 .iter()
7155 .zip(core.pen_diag.iter())
7156 .map(|(&c, &d)| d * c)
7157 .collect();
7158 let (u, _, _) = core
7159 .solve_coeff(lambda, &dc, None)
7160 .expect("second certified solve");
7161 let anchor = spectrum.anchor_energy[0];
7162 let solved = [
7163 anchor - core.rss_pen(&coeff),
7164 coeff.iter().zip(dc.iter()).map(|(&c, &v)| c * v).sum(),
7165 dc.iter().zip(u.iter()).map(|(&a, &b)| a * b).sum(),
7166 u.iter()
7167 .zip(core.pen_diag.iter())
7168 .map(|(&v, &d)| d * v * v)
7169 .sum(),
7170 ];
7171
7172 let conditioning = (theta_max + lambda) / lambda;
7173 for (k, (&got, &comparator)) in quadrature.iter().zip(solved.iter()).enumerate() {
7174 // One solve for `S_1, S_2`; two for `S_3, S_4`.
7175 let solves = if k < 2 { 1u32 } else { 2 };
7176 let bound = CG_RTOL * conditioning.powi(solves as i32) * comparator.abs();
7177 assert!(
7178 (got - comparator).abs() <= bound,
7179 "S{} disagrees between the quadrature and the solve it replaces at log \
7180 lambda {log_lambda}: {got} versus {comparator} (bound {bound}, conditioning \
7181 {conditioning}, {certificate:?})",
7182 k + 1
7183 );
7184 }
7185 }
7186 }
7187
7188 #[test]
7189 fn state_round_trip_requires_and_preserves_training_sample_size() {
7190 let (x1, x2, y) = dense_fixture(4);
7191 let weights = vec![1.0; y.len()];
7192 let axes: [&[f64]; 2] = [&x1, &x2];
7193 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 2)
7194 .expect("cascade design");
7195 let fit = design.fit_at(0.0, None).expect("fixed-lambda fit");
7196 assert_eq!(fit.training_sample_size(), y.len());
7197
7198 let state = fit.to_state().expect("persist cascade fit");
7199 assert_eq!(state.training_sample_size.get(), y.len() as u64);
7200 let restored = ResidualCascadeFit::from_state(&state).expect("restore cascade fit");
7201 assert_eq!(
7202 restored.training_sample_size(),
7203 y.len(),
7204 "prediction-only materialization must retain the original row count"
7205 );
7206
7207 let mut encoded = serde_json::to_value(&state).expect("serialize cascade state");
7208 encoded
7209 .as_object_mut()
7210 .expect("cascade state serializes as an object")
7211 .remove("training_sample_size");
7212 assert!(
7213 serde_json::from_value::<ResidualCascadeState>(encoded).is_err(),
7214 "pre-training-size cascade state must not deserialize"
7215 );
7216
7217 let mut zero = serde_json::to_value(&state).expect("serialize cascade state");
7218 zero.as_object_mut()
7219 .expect("cascade state serializes as an object")
7220 .insert("training_sample_size".to_string(), serde_json::json!(0));
7221 let error = serde_json::from_value::<ResidualCascadeState>(zero)
7222 .expect_err("zero training rows must not deserialize");
7223 assert!(
7224 error.to_string().contains("nonzero"),
7225 "zero-row rejection reported an unrelated error: {error}"
7226 );
7227 }
7228
7229 /// A Krylov space that has reached `rank(B) <= n - nullity` is invariant even
7230 /// when its numerical `tail` says otherwise, and the rule there IS the exact
7231 /// spectrum.
7232 ///
7233 /// This is the ceiling that decides whether the iterative route can ever leave
7234 /// the solve. A bounding-box-filled cascade has far more columns than the data
7235 /// can pin — measured on #2503's own `n = 800` 2-D fixture at refinement level
7236 /// 6, `rank = 7387` against `n - nullity = 797`, so 89% of the whitened Schur
7237 /// spectrum is exactly zero. Reading the invariance ceiling as `rank` made the
7238 /// test unreachable and the route fell back to the solve at full budget, which
7239 /// is exactly the wall this issue is about.
7240 ///
7241 /// The claim being gated is that the ceiling is a THEOREM and not an
7242 /// optimism: `B = Z'WZ` with `W^{1/2}Z = (I − P)W^{1/2}X₁` and `P` of rank
7243 /// `nullity`, so `rank(B) ≤ n − nullity`; `β = Z'Wy ∈ range(B)`; and the
7244 /// Krylov space cannot leave `range(B)`. The fixture puts the ceiling strictly
7245 /// below `rank` and charges the rule at the ceiling against the exact dense
7246 /// eigenbasis over the whole domain.
7247 #[test]
7248 fn a_krylov_space_at_the_rank_ceiling_reproduces_the_exact_spectrum_2503() {
7249 let (x1, x2, y) = dense_fixture(20);
7250 let design = cascade_core((&x1, &x2, &y), 5);
7251 let core = &design.core;
7252 assert!(core.dense_gram.is_some());
7253 let rank = core.m - core.nullity();
7254 let ceiling = core.residual_krylov_ceiling();
7255 assert!(
7256 ceiling < rank,
7257 "premise: this fixture must have MORE penalized columns than the data can pin \
7258 (rank {rank}, ceiling {ceiling}), or the ceiling is not being exercised"
7259 );
7260 assert_eq!(
7261 ceiling,
7262 y.len() - core.nullity(),
7263 "the ceiling must be `n - nullity` when that is the binding bound"
7264 );
7265
7266 let (null_chol, _) = core.null_gram_factor().expect("null factor");
7267 let (modes, exact) = core
7268 .dense_cascade_spectrum(&null_chol)
7269 .expect("dense spectrum");
7270 let (lo, hi) = certified_log_lambda_domain_from_modes(&modes).expect("domain");
7271 let (beta, anchor) = core.whitened_residual_rhs(&null_chol);
7272 let mass = beta.iter().map(|value| value * value).sum::<f64>();
7273 let run = core
7274 .schur_lanczos(&null_chol, &beta, ceiling, ceiling)
7275 .expect("lanczos");
7276 assert_eq!(run.alpha.len(), ceiling, "the run must reach the ceiling");
7277 assert!(
7278 run.invariant,
7279 "a run that consumed the whole reachable dimension must report invariance \
7280 (tail/scale {})",
7281 run.tail / run.spectral_scale
7282 );
7283 let rule = core
7284 .residual_gauss_rule(&run, ceiling, anchor, mass)
7285 .expect("gauss rule");
7286
7287 let resolution = f64::EPSILON.sqrt();
7288 for step in 0..=192 {
7289 let lambda = (lo + (hi - lo) * step as f64 / 192.0).exp();
7290 let (r_exact, s2, s3, s4) = exact.moments(lambda);
7291 let (r_gauss, g2, g3, g4) = rule.spectrum.moments(lambda);
7292 assert!(
7293 (r_gauss - r_exact).abs() <= resolution * anchor.abs(),
7294 "profiled residual at the ceiling disagrees at lambda {lambda}: {r_gauss} \
7295 versus {r_exact}"
7296 );
7297 for (name, got, truth) in [("S2", g2, s2), ("S3", g3, s3), ("S4", g4, s4)] {
7298 assert!(
7299 (got - truth).abs() <= resolution * truth.abs(),
7300 "{name} at the ceiling disagrees at lambda {lambda}: {got} versus {truth}"
7301 );
7302 }
7303 }
7304 }
7305
7306 /// A Ritz node whose WEIGHT is roundoff must not reach the spectrum, because
7307 /// `(θ + λ)^{-k}` will amplify it by `λ^{-k}` at the bottom of the domain.
7308 ///
7309 /// The mechanism, measured: on this fixture at 96 steps one node lands at
7310 /// `θ = 2.6e-11` — above the eigenvalue roundoff floor, so that floor passes
7311 /// it — carrying weight `8.9e-27·‖β‖²`. At `λ ≈ 2.9e-11` it contributes
7312 /// `w/(θ+λ)⁴` and `S₄` comes out `3.6e7` RELATIVE off while `S₂` is still
7313 /// right to `6e-9`. #2503 read that as quadrature truncation and concluded the
7314 /// approach was refuted; it is one node of pure roundoff.
7315 ///
7316 /// The test builds BOTH rules from the SAME Lanczos run — the shipped one and
7317 /// one with the weight floor removed — so it names the mechanism rather than
7318 /// asserting a number that some other change could also produce.
7319 #[test]
7320 fn roundoff_weight_nodes_cannot_poison_the_derivative_moments_2503() {
7321 let (x1, x2, y) = dense_fixture(14);
7322 let design = cascade_core((&x1, &x2, &y), 4);
7323 let core = &design.core;
7324 assert!(core.dense_gram.is_some());
7325 let (null_chol, _) = core.null_gram_factor().expect("null factor");
7326 let (modes, exact) = core
7327 .dense_cascade_spectrum(&null_chol)
7328 .expect("dense spectrum");
7329 let (lo, hi) = certified_log_lambda_domain_from_modes(&modes).expect("domain");
7330 let (beta, anchor) = core.whitened_residual_rhs(&null_chol);
7331 let mass = beta.iter().map(|value| value * value).sum::<f64>();
7332 let steps = 96;
7333 let run = core
7334 .schur_lanczos(&null_chol, &beta, steps, core.residual_krylov_ceiling())
7335 .expect("lanczos");
7336 assert_eq!(run.alpha.len(), steps, "premise: the run must not close early");
7337 let shipped = core
7338 .residual_gauss_rule(&run, steps, anchor, mass)
7339 .expect("gauss rule");
7340
7341 // The same run, with ONLY the eigenvalue floor — what the weight floor is
7342 // being charged against.
7343 let (ritz, first) =
7344 symmetric_tridiagonal_eigen(&run.alpha, &run.beta[..steps - 1]).expect("eigen");
7345 let scale = ritz.iter().copied().map(f64::abs).fold(0.0, f64::max);
7346 let eigenvalue_floor = f64::EPSILON * steps as f64 * scale;
7347 let mut eigenvalue = Vec::with_capacity(steps);
7348 let mut projected_square = Vec::with_capacity(steps);
7349 let mut poison = None;
7350 for (&theta, &component) in ritz.iter().zip(first.iter()) {
7351 let weight = run.start_norm_sq * component * component;
7352 if theta <= eigenvalue_floor {
7353 eigenvalue.push(0.0);
7354 projected_square.push(0.0);
7355 continue;
7356 }
7357 if theta < lo.exp() {
7358 poison = Some((theta, weight / mass));
7359 }
7360 eigenvalue.push(theta);
7361 projected_square.push(weight);
7362 }
7363 let unfloored = CascadeResidualSpectrum {
7364 eigenvalue,
7365 penalty: vec![1.0; steps],
7366 projected_square,
7367 anchor_energy: [anchor],
7368 };
7369 let (theta, relative_weight) = poison.expect(
7370 "premise: the eigenvalue floor alone must leave a node below the domain's smallest \
7371 lambda, which is the node this test is about",
7372 );
7373 let component_roundoff = f64::EPSILON * steps as f64;
7374 assert!(
7375 relative_weight <= component_roundoff * component_roundoff,
7376 "premise: that node's weight must be the SQUARE of the roundoff in a Ritz vector's \
7377 first component, `(eps*m)^2 = {}` (theta {theta}, w/||beta||^2 {relative_weight})",
7378 component_roundoff * component_roundoff
7379 );
7380
7381 let bottom = lo.exp();
7382 let (_, _, _, s4_exact) = exact.moments(bottom);
7383 let (_, _, _, s4_unfloored) = unfloored.moments(bottom);
7384 let (_, _, _, s4_shipped) = shipped.spectrum.moments(bottom);
7385 let unfloored_error = (s4_unfloored - s4_exact).abs() / s4_exact.abs();
7386 let shipped_error = (s4_shipped - s4_exact).abs() / s4_exact.abs();
7387 assert!(
7388 unfloored_error > 1.0,
7389 "premise: without the weight floor S4 must be wrong by more than 100% at the domain \
7390 bottom (got {unfloored_error}); if it is not, this fixture no longer exercises the \
7391 mechanism"
7392 );
7393 assert!(
7394 shipped_error <= f64::EPSILON.sqrt(),
7395 "the weight floor must restore S4 at the domain bottom: relative error \
7396 {shipped_error} against {unfloored_error} unfloored"
7397 );
7398 assert!(
7399 shipped.dropped_mass_fraction <= f64::EPSILON,
7400 "the mass the floor dropped must itself be roundoff: {}",
7401 shipped.dropped_mass_fraction
7402 );
7403 // ...and dropping it must not disturb the moments the rule got right.
7404 for step in 0..=64 {
7405 let lambda = (lo + (hi - lo) * step as f64 / 64.0).exp();
7406 let (r_exact, s2_exact, ..) = exact.moments(lambda);
7407 let (r_gauss, s2_gauss, ..) = shipped.spectrum.moments(lambda);
7408 assert!(
7409 (r_gauss - r_exact).abs() <= f64::EPSILON.sqrt() * anchor.abs()
7410 && (s2_gauss - s2_exact).abs() <= f64::EPSILON.sqrt() * s2_exact.abs(),
7411 "the floored rule must keep the moments it already had right at lambda {lambda}"
7412 );
7413 }
7414 }
7415
7416 /// The admission rule's load-bearing claim: when the geometric tail estimate
7417 /// over three nested Gauss rules falls below `sqrt(eps)`, the finest rule
7418 /// really is that close to the EXACT spectrum.
7419 ///
7420 /// The admission evidence is a self-comparison — it never sees the truth — so the
7421 /// inference from "the ladder has contracted" to "the rule is right" is the
7422 /// thing that has to be measured. It rests on two facts and one model: every
7423 /// Gauss rule for a completely monotone kernel under-estimates its integral,
7424 /// so the ladder rises toward the truth; the gaps are therefore all of one
7425 /// sign and the remaining error is their tail; and the tail is extrapolated
7426 /// geometrically from the last two gaps, refusing outright when they do not
7427 /// contract. This charges that inference against the dense eigenbasis at every
7428 /// budget on the PRODUCTION ladder, over three designs — including the budgets
7429 /// the admission REFUSES, where it must be the refusal that is right.
7430 #[test]
7431 fn the_quadrature_tail_estimate_bounds_the_error_against_the_exact_spectrum_2503() {
7432 for (side, levels) in [(14usize, 4usize), (20, 5), (28, 5)] {
7433 let (x1, x2, y) = dense_fixture(side);
7434 let design = cascade_core((&x1, &x2, &y), levels);
7435 let core = &design.core;
7436 assert!(core.dense_gram.is_some());
7437 let (null_chol, _) = core.null_gram_factor().expect("null factor");
7438 let (modes, exact) = core
7439 .dense_cascade_spectrum(&null_chol)
7440 .expect("dense spectrum");
7441 let (lo, hi) = certified_log_lambda_domain_from_modes(&modes).expect("domain");
7442 let (beta, anchor) = core.whitened_residual_rhs(&null_chol);
7443 let mass = beta.iter().map(|value| value * value).sum::<f64>();
7444 let rank = core.m - core.nullity();
7445 let target = f64::EPSILON.sqrt();
7446 let ceiling = core.residual_krylov_ceiling();
7447 let budget = core.residual_quadrature_budget();
7448 let mut accepted_at_least_once = false;
7449 let mut refused_at_least_once = false;
7450
7451 // The same start and the same geometric growth
7452 // `iterative_residual_spectrum` walks, so what is charged here ships.
7453 let mut steps = SLQ_LANCZOS_STEPS.min(budget);
7454 loop {
7455 let run = core
7456 .schur_lanczos(&null_chol, &beta, steps, ceiling)
7457 .expect("lanczos");
7458 let taken = run.alpha.len();
7459 let rule = |nodes: usize| {
7460 core.residual_gauss_rule(&run, nodes, anchor, mass)
7461 .expect("gauss rule")
7462 .spectrum
7463 };
7464 let (fine, mid, coarse) = (rule(taken), rule(taken / 2), rule(taken / 4));
7465 let estimate =
7466 residual_quadrature_tail_estimate(&fine, &mid, &coarse, taken, (lo, hi))
7467 .expect("tail estimate");
7468
7469 let mut worst = 0.0_f64;
7470 for step in 0..=96 {
7471 let lambda = (lo + (hi - lo) * step as f64 / 96.0).exp();
7472 let truth = exact.moment_sums(lambda);
7473 let got = fine.moment_sums(lambda);
7474 for (truth, got) in truth.into_iter().zip(got) {
7475 worst = worst.max((got - truth).abs() / truth.abs().max(f64::MIN_POSITIVE));
7476 }
7477 }
7478
7479 if run.invariant || estimate <= target {
7480 accepted_at_least_once = true;
7481 assert!(
7482 worst <= target,
7483 "side={side} levels={levels} steps={taken}: the tail estimate was \
7484 {estimate} (target {target}, invariant {}) but the rule is {worst} from \
7485 the exact spectrum — the admission rule's inference is unsound",
7486 run.invariant
7487 );
7488 } else {
7489 refused_at_least_once = true;
7490 }
7491 if taken >= budget {
7492 break;
7493 }
7494 steps = (steps * 2).min(budget);
7495 }
7496 assert!(
7497 accepted_at_least_once,
7498 "side={side} levels={levels}: the growth ladder must reach an admitted point \
7499 rule inside the budget ({budget} of rank {rank}), or the diagnostic criterion \
7500 remains unavailable"
7501 );
7502 assert!(
7503 refused_at_least_once,
7504 "side={side} levels={levels}: the ladder must also contain a REFUSED budget, or \
7505 the criterion is not being exercised"
7506 );
7507 }
7508 }
7509
7510 /// The Occam term the refinement decides on is READ OFF two restricted
7511 /// log-likelihoods; this checks it against the object it claims to be, by a
7512 /// route that shares no code with it.
7513 ///
7514 /// `2·evidence = dof·log(rss_pen/rss_pen_refined) − occam` is an identity
7515 /// only if `occam` really is `log det(S/(λd))` for the candidate Schur
7516 /// complement `S = X₂ᵀW(I − H)X₂ + λd·I`. So: form that `S` DENSELY, one
7517 /// column at a time, through the same matrix-free operator the gain bracket
7518 /// iterates on, take its Cholesky log-determinant, and compare. One side
7519 /// comes from two profiled REML evaluations of two different designs; the
7520 /// other from `m₂` cascade solves. Nothing is shared but the arithmetic
7521 /// they must agree on.
7522 ///
7523 /// This is the gate the second half of #2759 rests on, the way
7524 /// `the_refinement_gain_bracket_contains_the_objective_decrease_it_bounds_2759`
7525 /// is the gate the first half rests on.
7526 #[test]
7527 fn the_occam_term_read_off_the_two_fits_is_the_schur_log_determinant_2759() {
7528 let (x1, x2, y) = dense_fixture(18);
7529 let weights = vec![1.0; y.len()];
7530 let axes: [&[f64]; 2] = [&x1, &x2];
7531 let metric = [1.0, 1.0];
7532 let sobolev_s = 2.0;
7533 let plan: Vec<LevelPlan> = (0..3)
7534 .map(|level| LevelPlan {
7535 exponent: level as f64,
7536 centers: None,
7537 })
7538 .collect();
7539 let design =
7540 ResidualCascadeDesign::build_from_plan(&axes, &y, &weights, &metric, sobolev_s, &plan)
7541 .expect("cascade design");
7542 let core = &design.core;
7543 let exponent = plan.len() as f64;
7544 let dof = (y.len() - core.nullity()) as f64;
7545 let mut checked = 0_usize;
7546 for log_lambda in [-4.0_f64, -1.0, 2.0] {
7547 let fit = design.fit_at(log_lambda, None).expect("fixed-lambda fit");
7548 let mut extended = plan.clone();
7549 extended.push(LevelPlan {
7550 exponent,
7551 centers: None,
7552 });
7553 let refined_design = ResidualCascadeDesign::build_from_plan(
7554 &axes,
7555 &y,
7556 &weights,
7557 &metric,
7558 sobolev_s,
7559 &extended,
7560 )
7561 .expect("refined design");
7562 let refined = refined_design
7563 .fit_at(log_lambda, None)
7564 .expect("refined fixed-lambda fit");
7565 let comparison =
7566 level_evidence(&fit, &refined, (y.len() - refined_design.core.nullity()) as f64)
7567 .expect("level comparison");
7568
7569 // The independent route: `S` column by column, through the same
7570 // matrix-free operator, then a dense Cholesky log-determinant.
7571 let h = core.levels[0].h * 0.5_f64.powf(exponent);
7572 let mut net = core.net.clone();
7573 let candidates = extend_net(&mut net, &core.z, core.dim, h, &core.z_range);
7574 assert!(
7575 !candidates.is_empty(),
7576 "premise: the fixture must offer a candidate level at exponent {exponent}"
7577 );
7578 let delta = OVERLAP * h;
7579 let mut grid = HashGrid::new(delta, core.dim);
7580 for (j, c) in candidates.iter().enumerate() {
7581 grid.insert(j as u32, c);
7582 }
7583 let lambda = log_lambda.exp();
7584 let ridge = lambda * level_weight(exponent, core.sobolev_s, core.dim);
7585 let level = CandidateLevel {
7586 centers: &candidates,
7587 grid: &grid,
7588 delta,
7589 ridge,
7590 };
7591 let width = candidates.len();
7592 let mut workspace = SchurWorkspace {
7593 row: vec![0.0_f64; core.z.len()],
7594 fitted: vec![0.0_f64; core.z.len()],
7595 column: vec![0.0_f64; core.m],
7596 warm: None,
7597 };
7598 let mut schur = vec![0.0_f64; width * width];
7599 let mut unit = vec![0.0_f64; width];
7600 let mut column = vec![0.0_f64; width];
7601 for j in 0..width {
7602 unit[j] = 1.0;
7603 apply_candidate_schur(core, &level, lambda, &unit, &mut column, &mut workspace)
7604 .expect("schur apply");
7605 for (i, &value) in column.iter().enumerate() {
7606 // `S/(λd)`: the determinant the Occam factor is of.
7607 schur[i * width + j] = value / ridge;
7608 }
7609 unit[j] = 0.0;
7610 }
7611 // The operator is symmetric in exact arithmetic; the cascade solve
7612 // inside it carries `CG_RTOL`, so symmetrize rather than assert.
7613 for i in 0..width {
7614 for j in (i + 1)..width {
7615 let mean = 0.5 * (schur[i * width + j] + schur[j * width + i]);
7616 schur[i * width + j] = mean;
7617 schur[j * width + i] = mean;
7618 }
7619 }
7620 let dense_occam = cholesky_logdet(&mut schur, width).expect("schur log-determinant");
7621
7622 // Both sides are sums of `width` logarithms of O(1) numbers built
7623 // from `CG_RTOL`-certified solves, so the agreement is charged per
7624 // mode rather than in absolute nats.
7625 let slack = 1e-6 * (width as f64) * dense_occam.abs().max(1.0);
7626 assert!(
7627 (comparison.occam - dense_occam).abs() <= slack,
7628 "the Occam term read off the two restricted likelihoods is {} but the candidate \
7629 Schur log-determinant is {dense_occam} at log lambda {log_lambda} (width \
7630 {width}, slack {slack})",
7631 comparison.occam
7632 );
7633
7634 // The identity the whole criterion is stated in, and the equivalence
7635 // the certificate's two readings rest on.
7636 let restated = 0.5
7637 * (dof * (fit.rss_pen / refined.rss_pen).ln() - comparison.occam);
7638 assert!(
7639 (restated - comparison.evidence).abs()
7640 <= 1e-9 * comparison.evidence.abs().max(1.0),
7641 "2·evidence = dof·log(rss/rss_refined) − occam failed: {restated} vs {}",
7642 comparison.evidence
7643 );
7644 assert_eq!(
7645 comparison.warrants_refinement(),
7646 comparison.gain > comparison.tolerance,
7647 "the evidence reading and the break-even reading disagree: {comparison}"
7648 );
7649 assert!(
7650 comparison.occam >= 0.0 && comparison.gain >= 0.0,
7651 "a PSD Schur complement cannot charge less than nothing, and a superset design \
7652 cannot minimize higher: {comparison}"
7653 );
7654 checked += 1;
7655 }
7656 assert_eq!(checked, 3, "every lambda in the sweep must have been charged");
7657 }
7658
7659 /// The comparison the refinement decides on differences two restricted
7660 /// log-likelihoods produced by DIFFERENT routes: the incumbent's comes from
7661 /// `fit_reml`, which normalizes the log-determinant through the certified
7662 /// λ-independent Schur eigenbasis, and the candidate's from `fit_at`, which
7663 /// factorizes `X'WX + λD` at that λ directly. The difference is a decision
7664 /// at O(1) nats while each side is O(10³), so the two routes agreeing is a
7665 /// premise of the criterion, not a nicety (#2759).
7666 ///
7667 /// Charged on both width regimes: under the dense Gram cache, where
7668 /// `fit_at` takes a dense Cholesky, and past it, where it takes the sparse
7669 /// exact factor — the routes the comparison actually meets.
7670 #[test]
7671 fn the_certified_and_fixed_lambda_routes_report_the_same_restricted_likelihood_2759() {
7672 for (side, levels, dense_arm) in [(18_usize, 3_usize, true), (44, 6, false)] {
7673 let (x1, x2, y) = dense_fixture(side);
7674 let weights = vec![1.0; y.len()];
7675 let axes: [&[f64]; 2] = [&x1, &x2];
7676 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, levels)
7677 .expect("cascade design");
7678 assert_eq!(
7679 design.core.dense_gram.is_some(),
7680 dense_arm,
7681 "premise: the two arms must straddle the dense Gram cache at \
7682 {DENSE_GRAM_MAX} columns (side {side}, {levels} levels, {} columns)",
7683 design.core.m
7684 );
7685 let selected = design.fit_reml().expect("certified REML fit");
7686 let replayed = design
7687 .fit_at(selected.log_lambda, None)
7688 .expect("fixed-lambda replay");
7689 // Both sides are sums over `m` modes of O(1) logarithms; charge the
7690 // agreement per mode rather than in absolute nats.
7691 let slack = 1e-9 * (design.core.m as f64);
7692 assert!(
7693 (selected.restricted_loglik - replayed.restricted_loglik).abs() <= slack,
7694 "the certified route reports restricted log-likelihood {} and the fixed-lambda \
7695 route {} at log lambda {} (side {side}, {} columns, slack {slack}) — the level \
7696 comparison differences these two",
7697 selected.restricted_loglik,
7698 replayed.restricted_loglik,
7699 selected.log_lambda,
7700 design.core.m
7701 );
7702 assert!(
7703 (selected.rss_pen - replayed.rss_pen).abs()
7704 <= CG_RTOL * selected.rss_pen.abs().max(1.0),
7705 "the two routes disagree on the penalized residual itself: {} vs {}",
7706 selected.rss_pen,
7707 replayed.rss_pen
7708 );
7709 }
7710 }
7711
7712 /// The refinement stops where the EVIDENCE turns over, and the held-out
7713 /// truth agrees with it (#2759).
7714 ///
7715 /// Two fixtures in the regime this issue is about — refined until the
7716 /// design is rank-maximal, where the candidate columns are redundant
7717 /// against the data's own row space and what they buy is penalty dilution,
7718 /// not discretization bias. The shipped `1e-3·rss_pen` bar demanded another
7719 /// level at both and refused the fit when capacity could not supply it.
7720 ///
7721 /// The claim charged here is the one that makes the criterion mean
7722 /// something, and it is charged the same way whichever way the cascade
7723 /// decides: **a strictly deeper design must not predict better on held-out
7724 /// truth when the criterion says stop, and must not predict worse when it
7725 /// says keep going.** The truth is not in the criterion, so this is an
7726 /// independent witness and not a restatement.
7727 #[test]
7728 fn the_refinement_stops_where_the_evidence_turns_over_and_the_truth_agrees_2759() {
7729 struct TestRng(u64);
7730 impl TestRng {
7731 fn uniform(&mut self) -> f64 {
7732 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
7733 let mut z = self.0;
7734 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
7735 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
7736 ((z ^ (z >> 31)) >> 11) as f64 / (1_u64 << 53) as f64
7737 }
7738 fn normal(&mut self) -> f64 {
7739 let u1 = (self.uniform() + f64::EPSILON).min(1.0 - f64::EPSILON);
7740 let u2 = self.uniform();
7741 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
7742 }
7743 }
7744 type Truth = fn(f64, f64) -> f64;
7745 let smoothness_ceiling: Truth = |a, b| {
7746 (4.0 * std::f64::consts::PI * a).sin() * (4.0 * std::f64::consts::PI * b).cos()
7747 };
7748 let planted_sine: Truth = |a, b| {
7749 (2.0 * std::f64::consts::PI * a).sin() * (2.0 * std::f64::consts::PI * b).sin()
7750 };
7751
7752 // (name, rows, noise, seed, sobolev_s, weighted, truth). The first is
7753 // `smoothness_ceiling_forces_refinement_and_certifies_residual_bias` at
7754 // a third of its rows; the second is the `#2628`/`wendland_fixture_...`
7755 // sample, whose 240 rows identify 237 directions.
7756 let fixtures: [(&str, usize, f64, u64, f64, bool, Truth); 2] = [
7757 (
7758 "smoothness-2000",
7759 2000,
7760 0.02,
7761 0x1032_000C,
7762 2.0,
7763 false,
7764 smoothness_ceiling,
7765 ),
7766 (
7767 "wendland-240",
7768 240,
7769 0.05,
7770 0x1032_0008,
7771 2.5,
7772 true,
7773 planted_sine,
7774 ),
7775 ];
7776
7777 for (name, n, noise, seed, sobolev_s, weighted, truth) in fixtures {
7778 let mut rng = TestRng(seed);
7779 let (mut x1, mut x2, mut y, mut w) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
7780 for row in 0..n {
7781 let a = rng.uniform();
7782 let b = rng.uniform();
7783 x1.push(a);
7784 x2.push(b);
7785 y.push(truth(a, b) + noise * rng.normal());
7786 w.push(if weighted && row % 7 == 0 { 0.5 } else { 1.0 });
7787 }
7788 let axes: [&[f64]; 2] = [&x1, &x2];
7789 let metric = [1.0, 1.0];
7790
7791 let held_out_rmse = |fit: &ResidualCascadeFit| -> f64 {
7792 let grid = 25_usize;
7793 let mut sse = 0.0;
7794 for i in 0..grid {
7795 for j in 0..grid {
7796 let px = (i as f64 + 0.5) / grid as f64;
7797 let py = (j as f64 + 0.5) / grid as f64;
7798 let (mean, _) = fit.predict(&[px, py]).expect("predict");
7799 let error = mean - truth(px, py);
7800 sse += error * error;
7801 }
7802 }
7803 (sse / (grid * grid) as f64).sqrt()
7804 };
7805
7806 match fit_residual_cascade(&axes, &y, &w, &metric, sobolev_s) {
7807 Ok(fit) => {
7808 let certificate =
7809 fit.refinement.expect("a minted fit carries its comparison");
7810 assert!(
7811 !certificate.warrants_refinement()
7812 && certificate.gain <= certificate.tolerance,
7813 "{name}: a minted fit's binding candidate set must not earn a level: \
7814 {certificate}"
7815 );
7816 assert!(
7817 fit.num_levels() > INITIAL_LEVELS,
7818 "{name}: premise — the truth must force refinement past the initial \
7819 depth, got {} levels",
7820 fit.num_levels()
7821 );
7822 assert_eq!(
7823 fit.num_centers(),
7824 n - fit.core.nullity(),
7825 "{name}: premise — this fixture must stop AT the rank-maximal design, \
7826 which is the regime #2759 is about"
7827 );
7828
7829 // The candidate set the FIXED relative bar would have read:
7830 // the complete next dyadic level. Its comparison is the one
7831 // that must show this fixture is still in the regime.
7832 let deeper = ResidualCascadeDesign::build(
7833 &axes,
7834 &y,
7835 &w,
7836 &metric,
7837 sobolev_s,
7838 fit.num_levels() + 1,
7839 )
7840 .expect("one level deeper, complete");
7841 let deeper_fit = deeper
7842 .fit_at(fit.log_lambda, None)
7843 .expect("deeper fixed-lambda fit");
7844 let deeper_comparison = level_evidence(
7845 &fit,
7846 &deeper_fit,
7847 (n - deeper.core.nullity()) as f64,
7848 )
7849 .expect("deeper level comparison");
7850 assert!(
7851 deeper_comparison.gain > 5.0 * 1e-3 * fit.rss_pen,
7852 "{name}: premise — the minted fit must be one the fixed relative bar \
7853 would have refused, got {deeper_comparison} against 1e-3·rss_pen {}",
7854 1e-3 * fit.rss_pen
7855 );
7856 assert!(
7857 !deeper_comparison.warrants_refinement(),
7858 "{name}: the cascade stopped, but a strictly deeper design EARNS its \
7859 Occam factor: {deeper_comparison}"
7860 );
7861
7862 // The comparison is taken at the INCUMBENT's λ, so the
7863 // standing objection is that the deeper design would win it
7864 // back at a λ of its own. Every λ tried is a valid witness
7865 // FOR the deeper design — it needs only one — so a sweep
7866 // that finds none is the strongest form this check can
7867 // take short of a global argument. (Structurally: at the
7868 // turnover the extra columns are redundant, so the score
7869 // surface barely moves and its optimum does not; a level
7870 // that mattered only through λ would have to matter and not
7871 // matter at once.)
7872 for step in [-3.0_f64, -2.0, -1.0, 1.0, 2.0, 3.0] {
7873 let Ok(other) = deeper.fit_at(fit.log_lambda + step, None) else {
7874 continue;
7875 };
7876 assert!(
7877 other.restricted_loglik <= fit.restricted_loglik,
7878 "{name}: the cascade stopped at log lambda {}, but the deeper design \
7879 wins the comparison back at {} ({} vs {})",
7880 fit.log_lambda,
7881 fit.log_lambda + step,
7882 other.restricted_loglik,
7883 fit.restricted_loglik
7884 );
7885 }
7886
7887 // The independent witness.
7888 let stopped = held_out_rmse(&fit);
7889 let deeper_rmse = held_out_rmse(&deeper_fit);
7890 eprintln!(
7891 "[2759] {name}: minted at {} levels / {} centers; {certificate}; one \
7892 level deeper: {deeper_comparison}; held-out rmse {stopped} -> \
7893 {deeper_rmse}",
7894 fit.num_levels(),
7895 fit.num_centers(),
7896 );
7897 assert!(
7898 stopped < 0.2,
7899 "{name}: premise — the cascade must resolve the planted truth before \
7900 this comparison means anything, got rmse {stopped}"
7901 );
7902 assert!(
7903 deeper_rmse >= stopped,
7904 "{name}: the criterion stopped, but one more level IMPROVES the \
7905 held-out error ({deeper_rmse} vs {stopped}) — then it stopped too \
7906 early and the charge is wrong"
7907 );
7908 }
7909 Err(ResidualCascadeError::Underresolved {
7910 checkpoint,
7911 evidence,
7912 obstruction,
7913 }) => {
7914 let evidence = evidence.expect(
7915 "a formable candidate set must be compared, not merely bounded",
7916 );
7917 assert!(
7918 evidence.warrants_refinement() && evidence.gain > evidence.tolerance,
7919 "{name}: a refusal must carry a candidate set that still earns its own \
7920 Occam factor: {evidence}"
7921 );
7922 eprintln!(
7923 "[2759] {name}: refused at {} levels / {} centers; {evidence}; \
7924 {obstruction}",
7925 checkpoint.num_levels(),
7926 checkpoint.num_centers(),
7927 );
7928 }
7929 Err(other) => panic!("{name}: unexpected cascade outcome: {other}"),
7930 }
7931 }
7932 }
7933
7934 /// A cascade design the data cannot identify is CERTIFIABLE, and the
7935 /// certified route is what certifies it.
7936 ///
7937 /// This is the end-to-end angle on the centred-form repair in
7938 /// `AffineRemlProfile::enclose`, and it is deliberately the design three
7939 /// places in this file used to describe as impossible: `dense_fixture(6)`
7940 /// at `levels = 6` is 36 rows against 1725 columns, a Schur rank of 1722
7941 /// against 33 identifiable directions. Nothing about it is pathological —
7942 /// it is what a geometric box-filling net produces on a small sample, which
7943 /// is to say the ordinary small-`n` case.
7944 ///
7945 /// What used to happen: the score's VALUE enclosure was a natural interval
7946 /// extension whose overestimation was first order in the cell width with
7947 /// constant `rank` (`33.0·w`, measured over six decades), while the exact
7948 /// score moved by `|f'|·w` with `|f'| = 1.15e-5`. `resolution_flat_region`
7949 /// reads that range, so no cell could be retired, no cell could be
7950 /// derivative-excluded, and `maximize_score_1d` refused at 8193/8192
7951 /// subdivisions. The failure was reported — correctly — as
7952 /// `RemlScoreSearchUndecomposable`, which named the design's rank and the
7953 /// sample's identifiability and looked for all the world like a statement
7954 /// about the data.
7955 ///
7956 /// It was a statement about the enclosure. Centring the value and derivative
7957 /// forms on the cell midpoint makes the overestimation second order, and the
7958 /// same design certifies in about a second.
7959 ///
7960 /// The premise is asserted rather than assumed, because a fixture that
7961 /// drifted out of the rank-deficient regime would pass this gate while
7962 /// proving nothing.
7963 #[test]
7964 fn auto_reml_certifies_a_design_the_data_cannot_identify() {
7965 let (x1, x2, y) = dense_fixture(6);
7966 let weights = vec![1.0; y.len()];
7967 let axes: [&[f64]; 2] = [&x1, &x2];
7968 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 6)
7969 .expect("cascade design");
7970 let core = &design.core;
7971 let nullity = core.nullity();
7972 let schur_rank = core.m - nullity;
7973 let identifiable = core.y.len() - nullity;
7974 assert!(
7975 schur_rank > identifiable,
7976 "premise: this fixture must be rank-deficient (Schur rank {schur_rank} against \
7977 {identifiable} identifiable directions)"
7978 );
7979 assert!(
7980 core.m <= CERTIFIED_SPECTRUM_MAX,
7981 "premise: the refusal under test must be the SEARCH's, not the spectrum budget's \
7982 ({} columns against {CERTIFIED_SPECTRUM_MAX})",
7983 core.m
7984 );
7985
7986 let fit = design.fit_reml().expect(
7987 "a rank-deficient cascade design must certify: the score is a genuine function of \
7988 log lambda on its POSITIVE modes, and refusing it was an artifact of a \
7989 first-order-loose value enclosure",
7990 );
7991 assert_eq!(fit.certificate.logdet_method, LogdetMethod::DenseExact);
7992 assert!(
7993 fit.log_lambda().is_finite(),
7994 "certified selection must return a finite log lambda, got {}",
7995 fit.log_lambda()
7996 );
7997 assert!(
7998 fit.rss_pen.is_finite() && fit.rss_pen > 0.0,
7999 "the minted fit must carry a positive penalized residual, got {}",
8000 fit.rss_pen
8001 );
8002
8003 // The certificate, not just the fit: the search must have reached a
8004 // decided location rather than been handed one, and the value ordering
8005 // must have closed. `fit_reml` already refuses
8006 // `RemlValueOrderingUnresolved` and a failed KKT, so reaching here is
8007 // that proof; this re-reads the search directly so a future change that
8008 // routes around `fit_reml` cannot make the gate vacuous.
8009 let profile = core.reml_profile().expect("spectral profile");
8010 let (lo, hi) = profile.log_lambda_domain().expect("domain");
8011 let affine = profile
8012 .affine_view()
8013 .expect("affine view")
8014 .expect("spectral residual form");
8015 let search = affine
8016 .maximize_value_ordered(lo, hi, f64::EPSILON.sqrt())
8017 .expect("the certified search must decompose this domain");
8018 assert!(
8019 !matches!(
8020 search.location,
8021 gam_math::score_opt::ScoreOptimumLocation::ResolutionFlat(_)
8022 ),
8023 "the optimum must be a decided location, not a resolution-flat region: {:?}",
8024 search.location
8025 );
8026 assert!(
8027 search.value_certificate.maximum_excess
8028 <= search.value_certificate.comparison_resolution,
8029 "the global value ordering must close: excess {} against comparison resolution {}",
8030 search.value_certificate.maximum_excess,
8031 search.value_certificate.comparison_resolution
8032 );
8033 }
8034
8035 /// Automatic REML across the identifiability frontier, both sides of it.
8036 ///
8037 /// The end-to-end gate above pins ONE rank-deficient design. This sweeps the
8038 /// frontier so a regression cannot hide in the shape of a single fixture:
8039 /// the same 36-row cloud at three net depths (rank-sufficient, then further
8040 /// and further past what 36 rows identify), plus one design at production
8041 /// row count. Every cell that is inside the certified spectrum budget must
8042 /// certify, whichever side of the frontier it sits on — the score is a
8043 /// genuine function of `log lambda` on its POSITIVE modes either way, and
8044 /// the number of columns the data cannot pin is not a reason to refuse it.
8045 ///
8046 /// The reported `deficiency` is `schur_rank − identifiable`: negative is
8047 /// rank-sufficient, positive is the regime the certified route used to
8048 /// refuse outright.
8049 #[test]
8050 fn zz_measure_auto_reml_across_the_identifiability_frontier() {
8051 for (side, levels) in [(6_usize, 4_usize), (6, 5), (6, 6), (45, 6)] {
8052 let (x1, x2, y) = dense_fixture(side);
8053 let weights = vec![1.0; y.len()];
8054 let axes: [&[f64]; 2] = [&x1, &x2];
8055 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, levels)
8056 .expect("cascade design");
8057 let core = &design.core;
8058 let nullity = core.nullity();
8059 let schur_rank = core.m - nullity;
8060 let identifiable = core.y.len() - nullity;
8061 let deficiency = schur_rank as i64 - identifiable as i64;
8062 if core.m > CERTIFIED_SPECTRUM_MAX {
8063 println!(
8064 "#FRONTIER side={side} levels={levels} n={} m={} PAST THE SPECTRUM BUDGET",
8065 core.y.len(),
8066 core.m
8067 );
8068 continue;
8069 }
8070 let started = std::time::Instant::now();
8071 let outcome = design.fit_reml();
8072 let elapsed = started.elapsed().as_secs_f64();
8073 match &outcome {
8074 Ok(fit) => println!(
8075 "#FRONTIER side={side} levels={levels} n={} m={} rank={schur_rank} \
8076 identifiable={identifiable} deficiency={deficiency:+} \
8077 CERTIFIED log_lambda={:.6} in {elapsed:.2}s",
8078 core.y.len(),
8079 core.m,
8080 fit.log_lambda()
8081 ),
8082 Err(error) => println!(
8083 "#FRONTIER side={side} levels={levels} n={} m={} rank={schur_rank} \
8084 identifiable={identifiable} deficiency={deficiency:+} \
8085 REFUSED in {elapsed:.2}s: {error}",
8086 core.y.len(),
8087 core.m
8088 ),
8089 }
8090 assert!(
8091 outcome.is_ok(),
8092 "side={side} levels={levels} (deficiency {deficiency:+}) is inside the certified \
8093 spectrum budget and must certify; refusing a design because its net outruns its \
8094 sample is what the first-order-loose value enclosure did"
8095 );
8096 }
8097 }
8098
8099 /// PROBE: what the certified REML search does on a cascade design the data
8100 /// cannot identify, and how loose its enclosure is while doing it.
8101 ///
8102 /// Named for what it measures rather than for the hypothesis it started
8103 /// from. `dense_cascade_spectrum` used to record that such a design "still
8104 /// spins in `AffineRemlProfile::enclose` under `maximize_score_1d` past
8105 /// 900 s", so this began as a hunt for the unbounded axis in
8106 /// `maximize_score_1d_value_ordered`'s retry loop. That loop is never
8107 /// entered: the FIRST traversal returned the typed `SubdivisionBudget`
8108 /// refusal in 5.6 s, #2546 having closed that axis. The second hypothesis —
8109 /// `subdivision_budget`'s own recommendation that "the request, not the
8110 /// budget, is what actually binds" — died on the resolution ladder below,
8111 /// which refused at every request from 1.49e-8 to 1e-3.
8112 ///
8113 /// What is left is what the probe now reports: the ladder, the end-to-end
8114 /// `fit_reml` outcome, and the cell-by-cell looseness of the enclosure
8115 /// against the bound its own derivative certifies. The last of those is
8116 /// where the defect was.
8117 #[test]
8118 fn zz_probe_rank_deficient_certified_search_and_enclosure_looseness() {
8119 let (x1, x2, y) = dense_fixture(6);
8120 let weights = vec![1.0; y.len()];
8121 let axes: [&[f64]; 2] = [&x1, &x2];
8122 let design = ResidualCascadeDesign::build(&axes, &y, &weights, &[1.0, 1.0], 2.0, 6)
8123 .expect("cascade design");
8124 let core = &design.core;
8125 let nullity = core.nullity();
8126 println!(
8127 "[PROBE] n={} m={} nullity={nullity} schur_rank={} identifiable={}",
8128 core.y.len(),
8129 core.m,
8130 core.m - nullity,
8131 core.y.len() - nullity
8132 );
8133 let profile = core.reml_profile().expect("spectral profile");
8134 let (lo, hi) = profile.log_lambda_domain().expect("domain");
8135 let affine = profile
8136 .affine_view()
8137 .expect("affine view")
8138 .expect("spectral residual form");
8139 let CascadeResidualForm::Spectral(spectrum) = &profile.residual else {
8140 panic!("expected the spectral residual form");
8141 };
8142 println!(
8143 "[PROBE] domain=[{lo:.6}, {hi:.6}] width={:.6} kept_modes={} det_modes={}",
8144 hi - lo,
8145 spectrum.eigenvalue.len(),
8146 profile.modes.len()
8147 );
8148
8149 // FALSIFIED, and the falsification points somewhere better. The ladder
8150 // below refuses at EVERY request from 1.49e-8 to 1e-3, and the failing
8151 // cell simply walks down the domain as the request coarsens. So the
8152 // request is not what binds and `subdivision_budget`'s recommendation is
8153 // not the repair here.
8154 //
8155 // What the failure record shows instead: on the terminal cell
8156 // `[-16.78595040183548, -16.784710795129012]` (width 1.2396e-3) the
8157 // enclosure reports
8158 // score = [36.611966585064685, 36.65287355922212] (width 4.0907e-2)
8159 // derivative = [-2.0454623868609193e-2, 2.045226959739566e-2]
8160 // and 4.0907e-2 / 1.2396e-3 = 33.0 EXACTLY — the number of kept modes —
8161 // while the derivative enclosure bounds the score's variation over that
8162 // same cell by 2.045e-2 * 1.2396e-3 = 2.535e-5, sixteen hundred times
8163 // smaller. Two enclosures of the same function disagree by 1600x, and
8164 // `resolution_flat_region` reads the loose one, so a cell that IS flat
8165 // at the evaluator's resolution is subdivided instead of retired.
8166 //
8167 // Only one of the two can be right, and which one it is decides whether
8168 // this is a tightness defect or a SOUNDNESS defect in a proof object.
8169 // That is what this measures, by finite differences of the same
8170 // evaluator, before anything is changed.
8171 println!("[PROBE] --- ladder: is the REQUEST what binds? ---");
8172 for request in [f64::EPSILON.sqrt(), 1.0e-6, 1.0e-4, 1.0e-3] {
8173 let started = std::time::Instant::now();
8174 let outcome = gam_math::score_opt::maximize_score_1d(
8175 lo,
8176 hi,
8177 request,
8178 |x| affine.evaluate(x),
8179 |a, b| affine.enclose(a.x, b.x),
8180 );
8181 match outcome {
8182 Ok(search) => println!(
8183 "[PROBE] request={request:.3e} OK in {:.2}s location={:?}",
8184 started.elapsed().as_secs_f64(),
8185 search.location
8186 ),
8187 Err(error) => println!(
8188 "[PROBE] request={request:.3e} ERR in {:.2}s {}",
8189 started.elapsed().as_secs_f64(),
8190 match &error {
8191 gam_math::score_opt::ScoreSearchError::SubdivisionBudget {
8192 cell_lo,
8193 cell_hi,
8194 subdivisions,
8195 budget,
8196 ..
8197 } => format!(
8198 "SubdivisionBudget {subdivisions}/{budget} at [{cell_lo:.9}, {cell_hi:.9}]"
8199 ),
8200 other => format!("{other:?}"),
8201 }
8202 ),
8203 }
8204 }
8205
8206 println!("[PROBE] --- the profile as literals, for the gam-math capability gate ---");
8207 println!("[PROBE] dof={} rank={} null_logdet={:?} anchor={:?}",
8208 core.y.len() - nullity,
8209 spectrum.penalty.len(),
8210 profile.null_logdet,
8211 spectrum.anchor_energy[0]);
8212 println!("[PROBE] eigenvalue={:?}", spectrum.eigenvalue);
8213 println!("[PROBE] projected_square={:?}", spectrum.projected_square);
8214
8215 println!("[PROBE] --- end to end: does fit_reml certify this design now? ---");
8216 {
8217 let started = std::time::Instant::now();
8218 match design.fit_reml() {
8219 Ok(fit) => println!(
8220 "[PROBE] fit_reml OK in {:.2}s log_lambda={} logdet={:?} rss_pen={}",
8221 started.elapsed().as_secs_f64(),
8222 fit.log_lambda(),
8223 fit.certificate.logdet_method,
8224 fit.rss_pen
8225 ),
8226 Err(error) => println!(
8227 "[PROBE] fit_reml ERR in {:.2}s {error}",
8228 started.elapsed().as_secs_f64()
8229 ),
8230 }
8231 }
8232
8233 println!("[PROBE] --- looseness: the enclosure against the function ---");
8234 // The terminal cell of the tightest rung, read at shrinking widths.
8235 // `evaluate` returns an ANALYTIC jet, so its derivative at an interior
8236 // point is the soundness check with signal in it; a finite difference
8237 // of two rounded values is not, because at these widths the numerator
8238 // is `2*eval_err` of noise over a `1e-5` base. The FD is printed with
8239 // its own noise bar next to it and asserted on nothing.
8240 let center = -16.785_330_598_482_25_f64;
8241 for exponent in [-1.0_f64, -2.0, -3.0, -4.0, -5.0, -6.0] {
8242 let half = 10.0_f64.powf(exponent);
8243 let (a, b) = (center - half, center + half);
8244 let width = b - a;
8245 let jet = affine.evaluate(center).expect("jet at the midpoint");
8246 let enclosure = affine.enclose(a, b).expect("enclosure");
8247 let score_width = enclosure.score.value.hi - enclosure.score.value.lo;
8248 let derivative_span = enclosure.derivative.hi.abs().max(enclosure.derivative.lo.abs());
8249 // What the derivative enclosure itself says the score can move by
8250 // across this cell. A VALUE enclosure wider than this is pure
8251 // overestimation: the two are enclosures of the same function.
8252 let mean_value_bound = derivative_span * width;
8253 let fd_noise = 2.0 * enclosure.score.evaluation_error / width;
8254 println!(
8255 "[PROBE] width={width:.3e} jet_d={:.9e} jet_dd={:.9e} \
8256 encl_d=[{:.6e}, {:.6e}] encl_dd=[{:.6e}, {:.6e}] \
8257 score_width={score_width:.6e} mvt_bound={mean_value_bound:.6e} \
8258 value_looseness={:.4e} derivative_looseness={:.4e} \
8259 flat_test={score_width:.3e}<=2eta={:.3e}? {} fd_noise={fd_noise:.3e}",
8260 jet.derivative,
8261 jet.curvature,
8262 enclosure.derivative.lo,
8263 enclosure.derivative.hi,
8264 enclosure.curvature.lo,
8265 enclosure.curvature.hi,
8266 score_width / mean_value_bound.max(f64::MIN_POSITIVE),
8267 derivative_span / jet.derivative.abs().max(f64::MIN_POSITIVE),
8268 2.0 * enclosure.score.evaluation_error,
8269 score_width <= 2.0 * enclosure.score.evaluation_error,
8270 );
8271 // SOUNDNESS, against a proof object rather than against a rounded
8272 // scalar. `evaluate`'s jet is documented as a proposal — its
8273 // residual is `energy - sum q_i/h_i`, a cancelling difference, so on
8274 // this design it carries about three digits of loss, which is
8275 // exactly why the search never treats it as evidence. The
8276 // certified statement is that the cell's range contains the
8277 // DEGENERATE-cell range at every interior point: both enclose the
8278 // same exact derivative, and inclusion is what
8279 // `certify_endpoint_derivative` relies on.
8280 let point = affine.enclose(center, center).expect("point enclosure");
8281 assert!(
8282 enclosure.derivative.lo <= point.derivative.lo
8283 && point.derivative.hi <= enclosure.derivative.hi,
8284 "UNSOUND derivative enclosure on [{a}, {b}]: the midpoint range [{}, {}] is not \
8285 inside the cell range [{}, {}]",
8286 point.derivative.lo,
8287 point.derivative.hi,
8288 enclosure.derivative.lo,
8289 enclosure.derivative.hi
8290 );
8291 assert!(
8292 enclosure.score.value.lo <= point.score.value.lo
8293 && point.score.value.hi <= enclosure.score.value.hi,
8294 "UNSOUND value enclosure on [{a}, {b}]: the midpoint range [{}, {}] is not \
8295 inside the cell range [{}, {}]",
8296 point.score.value.lo,
8297 point.score.value.hi,
8298 enclosure.score.value.lo,
8299 enclosure.score.value.hi
8300 );
8301 assert!(
8302 enclosure.curvature.lo <= point.curvature.lo
8303 && point.curvature.hi <= enclosure.curvature.hi,
8304 "UNSOUND curvature enclosure on [{a}, {b}]: the midpoint range [{}, {}] is not \
8305 inside the cell range [{}, {}]",
8306 point.curvature.lo,
8307 point.curvature.hi,
8308 enclosure.curvature.lo,
8309 enclosure.curvature.hi
8310 );
8311 // The rounded scalar jet is NOT asserted against the curvature, for
8312 // the same reason it is not asserted against the derivative, and the
8313 // margin is now large enough to be worth naming: at w=2e-6 the
8314 // certified curvature range is [1.2498332e-5, 1.2499254e-5] and the
8315 // jet says 1.2492615e-5, outside it by 4.6e-4 relative. That is the
8316 // scalar path's own loss, not the enclosure's -- `evaluate` forms
8317 // the residual as the cancelling `energy - sum q_i/h_i` and then
8318 // divides by it twice, while the interval path intersects that with
8319 // the well-conditioned zero-smoothing complement. Centring has made
8320 // the proof object tight enough that the rounded evaluator is the
8321 // less accurate of the two, which is exactly why the search
8322 // documents scalar derivatives as proposals and takes every
8323 // exclusion, isolation and ordering decision on the ranges.
8324 }
8325 }
8326}