Skip to main content

gam_terms/
grid_spline_2d.rs

1//! Streaming scatter-add 2-D smoother: K×K tensor-product cubic B-splines
2//! with the EXACT anisotropic biharmonic penalty and REML-selected λ.
3//!
4//! Basis. Each axis carries K equal-width cells over the data's bounding box
5//! `[lo, hi]` with uniform extended knots `t_j = lo + (j−3)·h`, `h = (hi−lo)/K`,
6//! giving `m = K+3` cubic B-splines per axis; the tensor product has
7//! `p = (K+3)²` coefficients. A point in cell `i` activates exactly the four
8//! splines `i..i+3` per axis, hence exactly 4×4 = 16 tensor basis entries per
9//! data row.
10//!
11//! Streaming normal equations. ONE pass over the rows `(x1, x2, y_·, w)`
12//! scatter-adds `X'WX` and `X'Wy_d` (any number of response dimensions share
13//! the design, the penalty, and one REML λ — the multi-output "one surface
14//! smoothness" contract of the ANOVA pair component): O(n·(16² + 16·D)) work,
15//! no n×p design is ever materialized. Two tensor bases overlap only when both per-axis indices
16//! differ by ≤ 3, so under the row-major coefficient index
17//! `g = j1·(K+3) + j2` both `X'WX` and the penalty `S` are banded with
18//! half-bandwidth `3(K+3)+3`; they are stored as upper bands — O(K³) numbers.
19//!
20//! Penalty. The FULL anisotropic biharmonic form for the diagonal metric
21//! `A = diag(a1, a2)`,
22//!   `J(f) = ∫∫ a1²·f_{x1x1}² + 2·a1·a2·f_{x1x2}² + a2²·f_{x2x2}²  dx1 dx2`,
23//! INCLUDING the mixed `f_{x1x2}` term (the axis-wise P-spline difference
24//! shortcut drops it), assembled per knot cell by 4-point Gauss–Legendre per
25//! axis. Exactness degree arithmetic: on a knot cell every basis function is
26//! a single cubic polynomial per axis, so each entry of `S` is a sum over
27//! cells of integrands that factorize per axis as one of value·value
28//! (degree 3+3 = 6), deriv·deriv (2+2 = 4) or 2nd-deriv·2nd-deriv (1+1 = 2);
29//! every channel pairs a low-degree factor on one axis with at worst the
30//! degree-6 value·value factor on the other. 4-point Gauss–Legendre is exact
31//! through degree 2·4−1 = 7 ≥ 6, so the assembled `S` is the EXACT integral,
32//! not a quadrature approximation.
33//!
34//! Solve and selection. A single reference factorization of `H₀=X'WX+S`
35//! produces the affine generalized-eigenvalue pencil
36//! `H(λ)=L[I+(λ-1)U diag(μ)U']L'`. The profiled score and its first two
37//! analytic log-λ derivatives are then O(pD), and outward interval enclosures
38//! isolate every stationary interval without a grid. The selected system is
39//! factored once more for coefficients/posterior covariance. `p ≤ (32+3)² =
40//! 1225`; K is capped at 32 to keep the dense reference factor and eigensystem
41//! sizing contract honest. λ maximizes the
42//! profiled-σ² restricted (REML) criterion
43//!   `ℓ_R(λ) = −½[ log|X'WX+λS| − r·log λ + (n−3)·log σ̂²(λ) ] + const`,
44//! where `r = p−3` is the penalty rank — the null space of `J` is
45//! span{1, x1, x2} (the mixed term penalizes `x1·x2`, whose cross derivative
46//! is 1 ≠ 0, so it is NOT in the null space), `σ̂²(λ) = (y'Wy − c'X'Wy)/(n−3)`
47//! is the profiled scale, and the λ-free additive constants (`log|S|₊` on the
48//! row space of S, `Σ log w`, 2π factors) are dropped: differences across λ
49//! are exact REML criterion differences. The exact bounded-domain endpoints
50//! (including the null-recovery end) compete with every certified stationary
51//! point; no RNG or lattice is involved, so the same data imply the same fit.
52//!
53//! Prediction. `predict(x1, x2)` builds the 16-entry basis row; the mean is
54//! its dot with `c` and the variance is the Bayesian posterior
55//! `σ̂²·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor. Outside the
56//! bounding box the boundary cell's cubic polynomial extends naturally (the
57//! cell index clamps, the local coordinate does not).
58
59use faer::{Mat, Side};
60use gam_math::score_opt::{
61    AffineRemlProfile, ClosedInterval, ScoreOptimumLocation, certified_ln_positive,
62};
63
64/// Dimension of the penalty null space: span{1, x1, x2}. The mixed
65/// `2·a1·a2·f_{x1x2}²` term excludes `x1·x2` (its cross derivative is 1).
66const PENALTY_NULLITY: usize = 3;
67
68/// Cholesky pivot floor below which the penalized system is declared singular.
69const PIVOT_FLOOR: f64 = 1e-300;
70/// Dense-Cholesky sizing contract documented in the module header.
71const MAX_CELLS_PER_AXIS: usize = 32;
72
73/// 4-point Gauss–Legendre nodes and weights on [−1, 1]. Exact through degree
74/// 2·4−1 = 7, which dominates the degree-6 worst per-axis factor of the
75/// penalty integrands (see the module header for the degree arithmetic).
76const GL4_NODES: [f64; 4] = [
77    -0.861_136_311_594_052_6,
78    -0.339_981_043_584_856_26,
79    0.339_981_043_584_856_26,
80    0.861_136_311_594_052_6,
81];
82const GL4_WEIGHTS: [f64; 4] = [
83    0.347_854_845_137_453_85,
84    0.652_145_154_862_546_2,
85    0.652_145_154_862_546_2,
86    0.347_854_845_137_453_85,
87];
88
89/// Cubic B-spline segment values at local coordinate `u` within a cell.
90/// Entry `m` weights basis `cell + m`: m = 0 is the spline ENDING in this
91/// cell (`(1−u)³/6`), m = 3 the one STARTING (`u³/6`). The four entries sum
92/// to 1 (partition of unity) for u ∈ [0, 1].
93#[inline]
94fn bspline_value(u: f64) -> [f64; 4] {
95    let v = 1.0 - u;
96    [
97        v * v * v / 6.0,
98        (3.0 * u * u * u - 6.0 * u * u + 4.0) / 6.0,
99        (-3.0 * u * u * u + 3.0 * u * u + 3.0 * u + 1.0) / 6.0,
100        u * u * u / 6.0,
101    ]
102}
103
104/// d/du of `bspline_value` (caller scales by 1/h for d/dx). Entries sum to 0.
105#[inline]
106fn bspline_d1(u: f64) -> [f64; 4] {
107    let v = 1.0 - u;
108    [
109        -0.5 * v * v,
110        0.5 * (3.0 * u * u - 4.0 * u),
111        0.5 * (-3.0 * u * u + 2.0 * u + 1.0),
112        0.5 * u * u,
113    ]
114}
115
116/// d²/du² of `bspline_value` (caller scales by 1/h²). Piecewise LINEAR in u —
117/// the degree-1 factor in the quadrature-exactness argument. Entries sum to 0.
118#[inline]
119fn bspline_d2(u: f64) -> [f64; 4] {
120    [1.0 - u, 3.0 * u - 2.0, 1.0 - 3.0 * u, u]
121}
122
123/// One uniform B-spline axis over `[lo, lo + cells·h]`.
124#[derive(Clone, Copy, Debug)]
125struct Axis {
126    lo: f64,
127    h: f64,
128    cells: usize,
129}
130
131impl Axis {
132    /// Cell index and local coordinate. Inside the box `u ∈ [0, 1]`; outside,
133    /// the cell clamps and `u` leaves [0, 1], extending the boundary cell's
134    /// cubic polynomial (deterministic extrapolation, no special casing).
135    #[inline]
136    fn locate(&self, x: f64) -> (usize, f64) {
137        let t = (x - self.lo) / self.h;
138        let cell = (t.floor().max(0.0) as usize).min(self.cells - 1);
139        (cell, t - cell as f64)
140    }
141}
142
143/// The four active cubic B-spline values of one uniform axis `(lo, h, cells)`
144/// at `x`: `(first basis index, values)`, where `values[i]` weights basis
145/// `first + i` of the `cells + 3` axis splines. Outside `[lo, lo + cells·h]`
146/// the boundary cell's cubic polynomial extends — the single convention
147/// shared by fitting, prediction, and every consumer-rebuilt basis row.
148pub fn axis_basis_at(lo: f64, h: f64, cells: usize, x: f64) -> (usize, [f64; 4]) {
149    let (cell, u) = Axis { lo, h, cells }.locate(x);
150    (cell, bspline_value(u))
151}
152
153/// The 16 active tensor-basis entries `(flat index, value)` at `(x1, x2)`.
154/// Flat indices are strictly increasing across the returned arrays.
155#[inline]
156fn basis_row(axes: &[Axis; 2], m_axis: usize, x1: f64, x2: f64) -> ([usize; 16], [f64; 16]) {
157    let (c1, u1) = axes[0].locate(x1);
158    let (c2, u2) = axes[1].locate(x2);
159    let b1 = bspline_value(u1);
160    let b2 = bspline_value(u2);
161    let mut idx = [0usize; 16];
162    let mut val = [0f64; 16];
163    for i in 0..4 {
164        for j in 0..4 {
165            idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
166            val[4 * i + j] = b1[i] * b2[j];
167        }
168    }
169    (idx, val)
170}
171
172/// Dense lower-Cholesky in place (row-major `p×p`); returns the exact
173/// `log det` (twice the log of the pivot products). The strict upper triangle
174/// is zeroed so the buffer is exactly `L` afterwards.
175pub fn cholesky_logdet(a: &mut [f64], p: usize) -> Result<f64, String> {
176    let mut logdet = 0.0;
177    for j in 0..p {
178        let mut s = a[j * p + j];
179        for t in 0..j {
180            s -= a[j * p + t] * a[j * p + t];
181        }
182        if !(s.is_finite() && s > PIVOT_FLOOR) {
183            return Err(format!(
184                "grid spline 2d: penalized system not positive definite at pivot {j} (value {s})"
185            ));
186        }
187        let l = s.sqrt();
188        a[j * p + j] = l;
189        logdet += 2.0 * l.ln();
190        for i in j + 1..p {
191            let mut s2 = a[i * p + j];
192            for t in 0..j {
193                s2 -= a[i * p + t] * a[j * p + t];
194            }
195            a[i * p + j] = s2 / l;
196        }
197    }
198    for i in 0..p {
199        for j in i + 1..p {
200            a[i * p + j] = 0.0;
201        }
202    }
203    Ok(logdet)
204}
205
206/// Solve `L z = b` from a dense row-major lower-triangular factor.
207fn lower_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
208    let mut z = b.to_vec();
209    for i in 0..p {
210        let mut s = z[i];
211        for t in 0..i {
212            s -= l[i * p + t] * z[t];
213        }
214        z[i] = s / l[i * p + i];
215    }
216    z
217}
218
219/// Solve `L Lᵀ x = b` from the stored lower factor.
220pub fn chol_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
221    let mut z = lower_solve(l, p, b);
222    for i in (0..p).rev() {
223        let mut s = z[i];
224        for t in i + 1..p {
225            s -= l[t * p + i] * z[t];
226        }
227        z[i] = s / l[i * p + i];
228    }
229    z
230}
231
232/// Banded sufficient statistics of one streaming pass plus the exact penalty:
233/// everything needed to evaluate the REML criterion and solve at any λ.
234pub struct GridSpline2dDesign {
235    axes: [Axis; 2],
236    /// Basis count per axis, `K + 3`.
237    m_axis: usize,
238    /// Total coefficients, `(K + 3)²`.
239    p: usize,
240    /// Upper half-bandwidth `3·(K+3) + 3` of both banded matrices.
241    band_half: usize,
242    /// Upper band of `X'WX`: entry `(g, g+d)` at `g·(band_half+1) + d`.
243    gram_band: Vec<f64>,
244    /// Upper band of the exact anisotropic biharmonic penalty `S`.
245    pen_band: Vec<f64>,
246    /// `X'Wy_d`, one length-`p` vector per response dimension. The design
247    /// (gram and penalty bands) is shared across dimensions; only these
248    /// right-hand sides and the response cross-moments are per-dimension.
249    rhs: Vec<Vec<f64>>,
250    /// Response cross-moments `y_d'W y_e` (`D × D` row-major), for the
251    /// profiled-σ² residual quadratics and the residual cross-covariance.
252    cross_moments: Vec<f64>,
253    n_obs: usize,
254}
255
256/// Internal solve product at one λ (all response dimensions share the factor).
257struct Solved {
258    chol: Vec<f64>,
259    logdet: f64,
260    coeffs: Vec<Vec<f64>>,
261    /// Per dimension: penalized residual quadratic `y'Wy − c'X'Wy` =
262    /// `‖√W(y − Xc)‖² + λ c'Sc` at the minimizer.
263    rss_pen: Vec<f64>,
264}
265
266/// Owned spectral data for the shared affine REML profile.  Keeping the
267/// eigensystem reduction separate from the search makes every score evaluation
268/// O(pD) and ensures the final dense system is factored only at the selected λ.
269struct RemlSpectrum {
270    gram_modes: Vec<f64>,
271    penalty_modes: Vec<f64>,
272    projected_rhs_squared: Vec<f64>,
273    response_energy: Vec<f64>,
274    residual_dof: f64,
275    logdet_constant: f64,
276}
277
278impl RemlSpectrum {
279    fn profile(&self) -> Result<AffineRemlProfile<'_>, String> {
280        AffineRemlProfile::new(
281            &self.gram_modes,
282            &self.penalty_modes,
283            &self.projected_rhs_squared,
284            &self.response_energy,
285            self.residual_dof,
286            self.penalty_modes.len() - PENALTY_NULLITY,
287            self.logdet_constant,
288        )
289        .map_err(|error| format!("grid spline 2d: invalid REML spectrum: {error}"))
290    }
291
292    /// Derive a bounded, penalty-scale-equivariant log-λ domain from the
293    /// pencil's positive transition scales `g/s`. Extending the smallest and
294    /// largest transition by `sqrt(ε)` reaches both numerically distinct
295    /// asymptotes. If there is no mixed mode, the same arithmetic margin around
296    /// the reference λ=1 supplies a principled constant domain.
297    fn log_lambda_domain(&self) -> Result<(f64, f64), String> {
298        let mut lowest_transition = f64::INFINITY;
299        let mut highest_transition = f64::NEG_INFINITY;
300        for (&gram, &penalty) in self.gram_modes.iter().zip(&self.penalty_modes) {
301            if gram > 0.0 && penalty > 0.0 {
302                let transition = certified_ln_positive(gram)
303                    .ok_or_else(|| {
304                        "grid spline 2d: could not enclose a Gram-mode logarithm".to_string()
305                    })?
306                    .sub(certified_ln_positive(penalty).ok_or_else(|| {
307                        "grid spline 2d: could not enclose a penalty-mode logarithm".to_string()
308                    })?);
309                lowest_transition = lowest_transition.min(transition.lo);
310                highest_transition = highest_transition.max(transition.hi);
311            }
312        }
313        if !(lowest_transition.is_finite() && highest_transition.is_finite()) {
314            lowest_transition = 0.0;
315            highest_transition = 0.0;
316        }
317        let margin = certified_ln_positive(f64::EPSILON.sqrt())
318            .ok_or_else(|| {
319                "grid spline 2d: could not enclose the spectral-domain margin".to_string()
320            })?
321            .neg();
322        let minimum_log = certified_ln_positive(f64::MIN_POSITIVE)
323            .ok_or_else(|| {
324                "grid spline 2d: could not enclose the minimum-normal logarithm".to_string()
325            })?;
326        let maximum_log = certified_ln_positive(f64::MAX)
327            .ok_or_else(|| {
328                "grid spline 2d: could not enclose the maximum-finite logarithm".to_string()
329            })?;
330        let lo = ClosedInterval::point(lowest_transition)
331            .sub(margin)
332            .lo
333            .max(minimum_log.lo);
334        let hi = ClosedInterval::point(highest_transition)
335            .add(margin)
336            .hi
337            .min(maximum_log.lo);
338        if !(lo < hi) {
339            return Err(format!(
340                "grid spline 2d: no representable REML search domain after spectral scaling ({lo}, {hi})"
341            ));
342        }
343        Ok((lo, hi))
344    }
345}
346
347impl GridSpline2dDesign {
348    /// Single-response entry: see [`Self::build_multi`].
349    pub fn build(
350        x1: &[f64],
351        x2: &[f64],
352        y: &[f64],
353        w: &[f64],
354        k: usize,
355        metric: [f64; 2],
356    ) -> Result<Self, String> {
357        Self::build_multi(x1, x2, &[y], w, k, metric)
358    }
359
360    /// One streaming pass over the rows plus the exact per-cell quadrature
361    /// assembly of the penalty. `k` is the number of cells per axis;
362    /// `metric = [a1, a2]` is the diagonal anisotropy of the biharmonic form.
363    /// `responses` holds one length-`n` response per dimension; the design,
364    /// penalty, and the REML-shared λ are common to all dimensions (one
365    /// surface smoothness), only the right-hand sides differ.
366    pub fn build_multi(
367        x1: &[f64],
368        x2: &[f64],
369        responses: &[&[f64]],
370        w: &[f64],
371        k: usize,
372        metric: [f64; 2],
373    ) -> Result<Self, String> {
374        let n = x1.len();
375        if responses.is_empty() {
376            return Err("grid spline 2d: no response dimensions supplied".to_string());
377        }
378        if x2.len() != n || w.len() != n {
379            return Err(format!(
380                "grid spline 2d: length mismatch x1={n}, x2={}, w={}",
381                x2.len(),
382                w.len()
383            ));
384        }
385        for (d, y) in responses.iter().enumerate() {
386            if y.len() != n {
387                return Err(format!(
388                    "grid spline 2d: response dimension {d} has length {} != {n}",
389                    y.len()
390                ));
391            }
392        }
393        if n <= PENALTY_NULLITY {
394            return Err(format!(
395                "grid spline 2d: needs more than {PENALTY_NULLITY} rows for the profiled REML \
396                 degrees of freedom, got {n}"
397            ));
398        }
399        if k == 0 || k > MAX_CELLS_PER_AXIS {
400            return Err(format!(
401                "grid spline 2d: k must be in 1..={MAX_CELLS_PER_AXIS} (dense Cholesky on \
402                 (k+3)² coefficients — see module sizing contract), got {k}"
403            ));
404        }
405        if !(metric[0].is_finite() && metric[0] > 0.0 && metric[1].is_finite() && metric[1] > 0.0) {
406            return Err(format!(
407                "grid spline 2d: metric diagonal must be finite and positive, got [{}, {}]",
408                metric[0], metric[1]
409            ));
410        }
411        for i in 0..n {
412            if !(x1[i].is_finite() && x2[i].is_finite()) || !(w[i] > 0.0) || !w[i].is_finite() {
413                return Err(format!(
414                    "grid spline 2d: non-finite or non-positive input at row {i} \
415                     (x1={}, x2={}, w={})",
416                    x1[i], x2[i], w[i]
417                ));
418            }
419            for (d, y) in responses.iter().enumerate() {
420                if !y[i].is_finite() {
421                    return Err(format!(
422                        "grid spline 2d: non-finite response at row {i}, dimension {d} ({})",
423                        y[i]
424                    ));
425                }
426            }
427        }
428        let mut axes = [Axis {
429            lo: 0.0,
430            h: 1.0,
431            cells: k,
432        }; 2];
433        for (axis, xs) in axes.iter_mut().zip([x1, x2]) {
434            let mut lo = f64::INFINITY;
435            let mut hi = f64::NEG_INFINITY;
436            for &v in xs {
437                lo = lo.min(v);
438                hi = hi.max(v);
439            }
440            if !(hi > lo) {
441                return Err(format!(
442                    "grid spline 2d: degenerate axis bounding box [{lo}, {hi}]"
443                ));
444            }
445            axis.lo = lo;
446            axis.h = (hi - lo) / k as f64;
447        }
448        let m_axis = k + 3;
449        let p = m_axis * m_axis;
450        let band_half = 3 * m_axis + 3;
451        let stride = band_half + 1;
452        let n_dims = responses.len();
453        let mut gram_band = vec![0.0_f64; p * stride];
454        let mut rhs = vec![vec![0.0_f64; p]; n_dims];
455        let mut cross_moments = vec![0.0_f64; n_dims * n_dims];
456
457        // ── ONE streaming pass: scatter-add X'WX (upper band) and X'Wy_d ──
458        // Each row touches exactly 16 basis entries with strictly increasing
459        // flat indices, so the in-row pair loop (a ≤ b) lands directly in the
460        // upper band: O(n·(16² + 16·D)) total work.
461        for i in 0..n {
462            let (idx, val) = basis_row(&axes, m_axis, x1[i], x2[i]);
463            let wi = w[i];
464            for (d, y) in responses.iter().enumerate() {
465                let wy = wi * y[i];
466                for e in 0..16 {
467                    rhs[d][idx[e]] += wy * val[e];
468                }
469                for (e, ye) in responses.iter().enumerate().skip(d) {
470                    cross_moments[d * n_dims + e] += wy * ye[i];
471                }
472            }
473            for a in 0..16 {
474                let base = idx[a] * stride - idx[a];
475                let wa = wi * val[a];
476                for b in a..16 {
477                    gram_band[base + idx[b]] += wa * val[b];
478                }
479            }
480        }
481        for d in 0..n_dims {
482            for e in 0..d {
483                cross_moments[d * n_dims + e] = cross_moments[e * n_dims + d];
484            }
485        }
486
487        // ── Exact penalty assembly: 4-pt Gauss–Legendre per axis per cell ──
488        // Per-axis quadrature tables (cell-independent on a uniform grid):
489        // values, d/dx (scaled 1/h), d²/dx² (scaled 1/h²) at each GL node.
490        let mut tab = [[[[0.0_f64; 4]; 4]; 3]; 2]; // [axis][channel][node][basis offset]
491        for ax in 0..2 {
492            let h = axes[ax].h;
493            for q in 0..4 {
494                let u = 0.5 * (1.0 + GL4_NODES[q]);
495                let v0 = bspline_value(u);
496                let v1 = bspline_d1(u);
497                let v2 = bspline_d2(u);
498                for e in 0..4 {
499                    tab[ax][0][q][e] = v0[e];
500                    tab[ax][1][q][e] = v1[e] / h;
501                    tab[ax][2][q][e] = v2[e] / (h * h);
502                }
503            }
504        }
505        // Channel scales: J = ∫ a1²·f11² + 2·a1·a2·f12² + a2²·f22².
506        let s11 = metric[0] * metric[0];
507        let s12 = 2.0 * metric[0] * metric[1];
508        let s22 = metric[1] * metric[1];
509        let cell_area_jac = 0.25 * axes[0].h * axes[1].h; // d(x1,x2)/d(ξ1,ξ2) on [−1,1]²
510        let mut pen_band = vec![0.0_f64; p * stride];
511        let mut r11 = [0.0_f64; 16];
512        let mut r12 = [0.0_f64; 16];
513        let mut r22 = [0.0_f64; 16];
514        let mut idx = [0usize; 16];
515        for c1 in 0..k {
516            for c2 in 0..k {
517                for i in 0..4 {
518                    for j in 0..4 {
519                        idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
520                    }
521                }
522                for q1 in 0..4 {
523                    for q2 in 0..4 {
524                        let wq = cell_area_jac * GL4_WEIGHTS[q1] * GL4_WEIGHTS[q2];
525                        for i in 0..4 {
526                            for j in 0..4 {
527                                let e = 4 * i + j;
528                                r11[e] = tab[0][2][q1][i] * tab[1][0][q2][j];
529                                r12[e] = tab[0][1][q1][i] * tab[1][1][q2][j];
530                                r22[e] = tab[0][0][q1][i] * tab[1][2][q2][j];
531                            }
532                        }
533                        for a in 0..16 {
534                            let base = idx[a] * stride - idx[a];
535                            let (pa11, pa12, pa22) =
536                                (wq * s11 * r11[a], wq * s12 * r12[a], wq * s22 * r22[a]);
537                            for b in a..16 {
538                                pen_band[base + idx[b]] +=
539                                    pa11 * r11[b] + pa12 * r12[b] + pa22 * r22[b];
540                            }
541                        }
542                    }
543                }
544            }
545        }
546
547        Ok(GridSpline2dDesign {
548            axes,
549            m_axis,
550            p,
551            band_half,
552            gram_band,
553            pen_band,
554            rhs,
555            cross_moments,
556            n_obs: n,
557        })
558    }
559
560
561    /// Basis functions per axis, `K + 3`.
562    pub fn basis_per_axis(&self) -> usize {
563        self.m_axis
564    }
565
566    /// Total coefficient count `(K + 3)²`.
567    pub fn num_coeffs(&self) -> usize {
568        self.p
569    }
570
571    /// Lower corner of the data bounding box per axis.
572    pub fn lower_corner(&self) -> [f64; 2] {
573        [self.axes[0].lo, self.axes[1].lo]
574    }
575
576    /// Knot-cell width per axis.
577    pub fn cell_widths(&self) -> [f64; 2] {
578        [self.axes[0].h, self.axes[1].h]
579    }
580
581    /// Number of data rows the design was streamed from.
582    pub fn num_rows(&self) -> usize {
583        self.n_obs
584    }
585
586    /// Number of response dimensions sharing the design.
587    pub fn num_responses(&self) -> usize {
588        self.rhs.len()
589    }
590
591    /// The four active cubic B-spline values of one AXIS at `x`: returns
592    /// `(j0, values)` where `values[i]` weights basis `j0 + i` of that axis
593    /// (`0..K+3`). The tensor flat index of `(j1, j2)` is `j1·(K+3) + j2` —
594    /// row-major, axis 0 major. Outside the bounding box the boundary cell's
595    /// cubic polynomial extends (same convention as fitting and prediction).
596    pub fn axis_basis(&self, axis: usize, x: f64) -> Result<(usize, [f64; 4]), String> {
597        if axis > 1 {
598            return Err(format!("grid spline 2d: axis {axis} out of range"));
599        }
600        if !x.is_finite() {
601            return Err(format!("grid spline 2d: non-finite axis-{axis} point {x}"));
602        }
603        let ax = self.axes[axis];
604        Ok(axis_basis_at(ax.lo, ax.h, ax.cells, x))
605    }
606
607    /// Exact penalty quadratic form `J(f) = c'Sc` of a coefficient vector —
608    /// the assembled anisotropic biharmonic energy of the spline it encodes.
609    pub fn penalty_value(&self, coeff: &[f64]) -> Result<f64, String> {
610        if coeff.len() != self.p {
611            return Err(format!(
612                "grid spline 2d: coefficient length {} != {}",
613                coeff.len(),
614                self.p
615            ));
616        }
617        let stride = self.band_half + 1;
618        let mut j = 0.0;
619        for g in 0..self.p {
620            let dmax = self.band_half.min(self.p - 1 - g);
621            j += self.pen_band[g * stride] * coeff[g] * coeff[g];
622            for d in 1..=dmax {
623                j += 2.0 * self.pen_band[g * stride + d] * coeff[g] * coeff[g + d];
624            }
625        }
626        Ok(j)
627    }
628
629    /// Expand `X'WX + λS` from the bands to a dense symmetric matrix.
630    fn dense_system(&self, lambda: f64) -> Vec<f64> {
631        let p = self.p;
632        let stride = self.band_half + 1;
633        let mut a = vec![0.0_f64; p * p];
634        for g in 0..p {
635            let dmax = self.band_half.min(p - 1 - g);
636            for d in 0..=dmax {
637                let v = self.gram_band[g * stride + d] + lambda * self.pen_band[g * stride + d];
638                a[g * p + g + d] = v;
639                a[(g + d) * p + g] = v;
640            }
641        }
642        a
643    }
644
645    /// Expand the exact penalty band to a dense symmetric matrix.
646    fn dense_penalty(&self) -> Vec<f64> {
647        let p = self.p;
648        let stride = self.band_half + 1;
649        let mut penalty = vec![0.0_f64; p * p];
650        for g in 0..p {
651            let dmax = self.band_half.min(p - 1 - g);
652            for d in 0..=dmax {
653                let value = self.pen_band[g * stride + d];
654                penalty[g * p + g + d] = value;
655                penalty[(g + d) * p + g] = value;
656            }
657        }
658        penalty
659    }
660
661    /// Build the affine modes of
662    /// `H(λ) = H(1) + (λ-1)S = L U diag(1-μ+λμ) Uᵀ Lᵀ`.
663    /// The known three-dimensional biharmonic null space is represented by
664    /// exact zero penalty modes; all other modes retain the eigensolver's
665    /// analytic generalized eigenvalues.
666    fn reml_spectrum(&self) -> Result<RemlSpectrum, String> {
667        let p = self.p;
668        let mut reference_chol = self.dense_system(1.0);
669        let logdet_constant = cholesky_logdet(&mut reference_chol, p)?;
670
671        let lower = Mat::from_fn(p, p, |row, col| {
672            if row >= col {
673                reference_chol[row * p + col]
674            } else {
675                0.0
676            }
677        });
678        let dense_penalty = self.dense_penalty();
679        let mut whitened = Mat::from_fn(p, p, |row, col| dense_penalty[row * p + col]);
680        // L X = S, followed by L Bᵀ = Xᵀ, gives B = L⁻¹ S L⁻ᵀ.
681        // faer's blocked matrix solves avoid constructing L⁻¹ and retain the
682        // reference factor's numerical conditioning.
683        lower
684            .as_ref()
685            .solve_lower_triangular_in_place(whitened.as_mut());
686        lower
687            .as_ref()
688            .solve_lower_triangular_in_place(whitened.as_mut().transpose_mut());
689        if (0..p).any(|row| (0..p).any(|col| !whitened[(row, col)].is_finite())) {
690            return Err("grid spline 2d: non-finite whitened penalty".to_string());
691        }
692        // The two solves accumulate in a different order above and below the
693        // diagonal. Eigensolve their symmetric average.
694        let mut symmetry_correction_rows = vec![0.0_f64; p];
695        for row in 0..p {
696            for col in row + 1..p {
697                let correction = 0.5 * (whitened[(row, col)] - whitened[(col, row)]).abs();
698                symmetry_correction_rows[row] += correction;
699                symmetry_correction_rows[col] += correction;
700                let value = 0.5 * (whitened[(row, col)] + whitened[(col, row)]);
701                whitened[(row, col)] = value;
702                whitened[(col, row)] = value;
703            }
704        }
705        let matrix_inf_norm = (0..p).fold(0.0_f64, |norm, row| {
706            let row_sum = (0..p).map(|col| whitened[(row, col)].abs()).sum();
707            norm.max(row_sum)
708        });
709        // Standard dot-product backward-error factor γ_p = pε/(1-pε),
710        // scaled by an infinity-norm bound for the symmetric pencil. This is a
711        // dimension- and arithmetic-derived acceptance band, not a rank knob.
712        let p_epsilon = p as f64 * f64::EPSILON;
713        let symmetrization_error = symmetry_correction_rows.into_iter().fold(0.0_f64, f64::max);
714        let eigenvalue_roundoff =
715            symmetrization_error + (p_epsilon / (1.0 - p_epsilon)) * matrix_inf_norm.max(1.0);
716        let eigensystem = whitened
717            .as_ref()
718            .self_adjoint_eigen(Side::Lower)
719            .map_err(|error| {
720                format!("grid spline 2d: reference-pencil eigendecomposition failed: {error:?}")
721            })?;
722        let eigenvalues = eigensystem.S();
723        let eigenvectors = eigensystem.U();
724
725        // Do not depend on a backend-specific ordering convention.
726        let mut order: Vec<usize> = (0..p).collect();
727        order.sort_unstable_by(|&left, &right| eigenvalues[left].total_cmp(&eigenvalues[right]));
728
729        let mut gram_modes = Vec::with_capacity(p);
730        let mut penalty_modes = Vec::with_capacity(p);
731        for (position, &mode) in order.iter().enumerate() {
732            let raw = eigenvalues[mode];
733            if !raw.is_finite() {
734                return Err(format!(
735                    "grid spline 2d: non-finite reference-pencil eigenvalue at mode {position}"
736                ));
737            }
738
739            // Exact arithmetic gives 0 ≤ μ ≤ 1 because G,S are PSD and
740            // H(1)=G+S. Reject a pencil outside its backward-error band instead
741            // of hiding a material violation behind projection.
742            if raw < -eigenvalue_roundoff || raw > 1.0 + eigenvalue_roundoff {
743                return Err(format!(
744                    "grid spline 2d: reference-pencil eigenvalue {raw} at mode {position} lies outside the certified [0, 1] roundoff band ±{eigenvalue_roundoff}"
745                ));
746            }
747            let penalty = if position < PENALTY_NULLITY {
748                if raw.abs() > eigenvalue_roundoff {
749                    return Err(format!(
750                        "grid spline 2d: expected null mode {position} has eigenvalue {raw}, outside zero roundoff band ±{eigenvalue_roundoff}"
751                    ));
752                }
753                0.0
754            } else if raw <= eigenvalue_roundoff {
755                return Err(format!(
756                    "grid spline 2d: penalty rank is below {}: non-null mode {position} has eigenvalue {raw} inside zero roundoff band ±{eigenvalue_roundoff}",
757                    p - PENALTY_NULLITY,
758                ));
759            } else {
760                // Only the admitted upper-band eigensolver excursion is
761                // projected back to the exact generalized-spectrum boundary.
762                raw.min(1.0)
763            };
764            penalty_modes.push(penalty);
765            gram_modes.push(1.0 - penalty);
766        }
767
768        let n_dims = self.rhs.len();
769        let mut projected_rhs_squared = Vec::with_capacity(n_dims * p);
770        for rhs in &self.rhs {
771            let whitened_rhs = lower_solve(&reference_chol, p, rhs);
772            for &mode in &order {
773                let mut coordinate = 0.0;
774                for row in 0..p {
775                    coordinate += eigenvectors[(row, mode)] * whitened_rhs[row];
776                }
777                projected_rhs_squared.push(coordinate * coordinate);
778            }
779        }
780
781        let response_energy = (0..n_dims)
782            .map(|dimension| self.cross_moments[dimension * n_dims + dimension])
783            .collect();
784        Ok(RemlSpectrum {
785            gram_modes,
786            penalty_modes,
787            projected_rhs_squared,
788            response_energy,
789            residual_dof: (self.n_obs - PENALTY_NULLITY) as f64,
790            logdet_constant,
791        })
792    }
793
794    fn solve_at(&self, log_lambda: f64) -> Result<Solved, String> {
795        let lambda = gam_problem::checked_exp_log_strength(log_lambda)
796            .map_err(|error| format!("grid spline 2d: {error}"))?;
797        let mut a = self.dense_system(lambda);
798        let logdet = cholesky_logdet(&mut a, self.p)?;
799        let n_dims = self.rhs.len();
800        let mut coeffs = Vec::with_capacity(n_dims);
801        let mut rss_pen = Vec::with_capacity(n_dims);
802        for (d, rhs) in self.rhs.iter().enumerate() {
803            let coeff = chol_solve(&a, self.p, rhs);
804            let mut quad = 0.0;
805            for g in 0..self.p {
806                quad += rhs[g] * coeff[g];
807            }
808            rss_pen.push(self.cross_moments[d * n_dims + d] - quad);
809            coeffs.push(coeff);
810        }
811        Ok(Solved {
812            chol: a,
813            logdet,
814            coeffs,
815            rss_pen,
816        })
817    }
818
819    /// Fit at a FIXED `log λ`, with σ² either supplied (applied to every
820    /// response dimension) or profiled per dimension.
821    pub fn fit_at(&self, log_lambda: f64, sigma2: Option<f64>) -> Result<GridSpline2dFit, String> {
822        let solved = self.solve_at(log_lambda)?;
823        let dof = (self.n_obs - PENALTY_NULLITY) as f64;
824        let mut sigma2_dims = Vec::with_capacity(solved.rss_pen.len());
825        for &rss in &solved.rss_pen {
826            match sigma2 {
827                Some(s) => {
828                    if !(s.is_finite() && s > 0.0) {
829                        return Err(format!("grid spline 2d: invalid sigma2 {s}"));
830                    }
831                    sigma2_dims.push(s);
832                }
833                None => {
834                    if !(rss > 0.0) {
835                        return Err(format!(
836                            "grid spline 2d: degenerate penalized residual {rss}"
837                        ));
838                    }
839                    sigma2_dims.push(rss / dof);
840                }
841            }
842        }
843        // Full restricted log-likelihood at this (λ, σ²) up to λ- and σ-free
844        // constants, pooled across dimensions: at the profiled σ̂²_d the
845        // quadratic collapses to the λ-free constant `dof` per dimension,
846        // matching the profiled spectral score up to that constant.
847        let r = (self.p - PENALTY_NULLITY) as f64;
848        let mut restricted_loglik = 0.0;
849        for (d, &rss) in solved.rss_pen.iter().enumerate() {
850            restricted_loglik -= 0.5
851                * (solved.logdet - r * log_lambda
852                    + dof * sigma2_dims[d].ln()
853                    + rss / sigma2_dims[d]);
854        }
855        Ok(GridSpline2dFit {
856            coeffs: solved.coeffs,
857            log_lambda,
858            sigma2: sigma2_dims,
859            restricted_loglik,
860            chol: solved.chol,
861            axes: self.axes,
862            m_axis: self.m_axis,
863        })
864    }
865
866    /// Fit with `log λ` selected by the profiled REML criterion.  The affine
867    /// score supplies exact analytic first/second derivatives and rigorous
868    /// interval enclosures to isolate every stationary point on the bounded
869    /// domain. Both boundaries compete directly with all isolated optima.
870    pub fn fit_reml(&self) -> Result<GridSpline2dFit, String> {
871        let spectrum = self.reml_spectrum()?;
872        let profile = spectrum.profile()?;
873        let (log_lambda_lo, log_lambda_hi) = spectrum.log_lambda_domain()?;
874        let search = profile
875            .maximize_value_ordered(log_lambda_lo, log_lambda_hi, f64::EPSILON.sqrt())
876            .map_err(|error| format!("grid spline 2d: REML optimization failed: {error}"))?;
877        if search.value_certificate.maximum_excess
878            > search.value_certificate.comparison_resolution
879        {
880            return Err(format!(
881                "grid spline 2d: REML candidates are not globally ordered \
882                 (maximum excess {}, comparison resolution {})",
883                search.value_certificate.maximum_excess,
884                search.value_certificate.comparison_resolution
885            ));
886        }
887        enum KktKind {
888            LowerBoundary,
889            UpperBoundary,
890            Stationary,
891        }
892        let (bracket, kkt_kind) = match search.location {
893            ScoreOptimumLocation::LowerBoundary => (
894                gam_math::score_opt::ClosedInterval::point(search.lower_boundary.x),
895                KktKind::LowerBoundary,
896            ),
897            ScoreOptimumLocation::UpperBoundary => (
898                gam_math::score_opt::ClosedInterval::point(search.upper_boundary.x),
899                KktKind::UpperBoundary,
900            ),
901            ScoreOptimumLocation::Stationary(index) => (
902                search
903                    .stationary_points
904                    .get(index)
905                    .ok_or_else(|| {
906                        "grid spline 2d: optimizer returned an invalid stationary index".to_string()
907                    })?
908                    .bracket,
909                KktKind::Stationary,
910            ),
911            ScoreOptimumLocation::ResolutionFlat(index) => {
912                let flat = search.resolution_flat_regions.get(index).ok_or_else(|| {
913                    "grid spline 2d: optimizer returned an invalid resolution-flat index"
914                        .to_string()
915                })?;
916                return Err(format!(
917                    "grid spline 2d: REML optimum is value-resolved but not stationary on \
918                     {:?} (gap {}, resolution {})",
919                    flat.bracket, flat.max_score_gap, flat.score_resolution
920                ));
921            }
922        };
923        let kkt = profile
924            .enclose(bracket.lo, bracket.hi)
925            .map_err(|error| format!("grid spline 2d: {error}"))?;
926        let kkt_holds = match kkt_kind {
927            KktKind::LowerBoundary => kkt.derivative.hi <= 0.0,
928            KktKind::UpperBoundary => kkt.derivative.lo >= 0.0,
929            KktKind::Stationary => {
930                kkt.derivative.contains_zero() && kkt.curvature.hi < 0.0
931            }
932        };
933        if !kkt_holds {
934            return Err(format!(
935                "grid spline 2d: exact-real REML KKT certificate failed on {bracket:?}: {kkt:?}"
936            ));
937        }
938        self.fit_at(search.optimum.x, None)
939    }
940
941    /// `a'(X'WX)b` through the retained upper band (exact, O(p·bandwidth)).
942    fn gram_quadratic(&self, a: &[f64], b: &[f64]) -> f64 {
943        let stride = self.band_half + 1;
944        let mut q = 0.0;
945        for g in 0..self.p {
946            let dmax = self.band_half.min(self.p - 1 - g);
947            q += self.gram_band[g * stride] * a[g] * b[g];
948            for d in 1..=dmax {
949                q += self.gram_band[g * stride + d] * (a[g] * b[g + d] + a[g + d] * b[g]);
950            }
951        }
952        q
953    }
954
955    /// Posterior summary of a fit FROM THIS DESIGN, in the exact algebra of
956    /// the solved system (no approximation):
957    /// - `unit_covariance = (X'WX + λS)⁻¹` (scale-free Bayesian posterior
958    ///   covariance of the row-major coefficient vec, shared by dimensions);
959    /// - `edf = tr[(X'WX + λS)⁻¹ X'WX]` (the smoother's effective degrees of
960    ///   freedom at the fitted λ);
961    /// - `residual_cross_cov[d,e] = r_d'W r_e / (n − edf)` assembled from the
962    ///   streamed sufficient statistics
963    ///   (`y_d'Wy_e − c_d'X'Wy_e − c_e'X'Wy_d + c_d'X'WX c_e`).
964    pub fn posterior(&self, fit: &GridSpline2dFit) -> Result<GridSpline2dPosterior, String> {
965        let p = self.p;
966        let n_dims = self.rhs.len();
967        if fit.coeffs.len() != n_dims || fit.coeffs.iter().any(|c| c.len() != p) {
968            return Err(format!(
969                "grid spline 2d: posterior asked for a fit with {} dimensions of length {}, \
970                 design has {n_dims} of {p}",
971                fit.coeffs.len(),
972                fit.coeffs.first().map_or(0, Vec::len)
973            ));
974        }
975        // H⁻¹ column by column through the retained factor (symmetric, O(p³)).
976        let mut unit_covariance = vec![0.0_f64; p * p];
977        let mut e_g = vec![0.0_f64; p];
978        for g in 0..p {
979            e_g[g] = 1.0;
980            let col = chol_solve(&fit.chol, p, &e_g);
981            e_g[g] = 0.0;
982            for (r, &v) in col.iter().enumerate() {
983                unit_covariance[r * p + g] = v;
984            }
985        }
986        // edf = tr(H⁻¹ X'WX) via the gram band (diagonal once, off-band twice).
987        let stride = self.band_half + 1;
988        let mut edf = 0.0;
989        for g in 0..p {
990            let dmax = self.band_half.min(p - 1 - g);
991            edf += self.gram_band[g * stride] * unit_covariance[g * p + g];
992            for d in 1..=dmax {
993                edf += 2.0 * self.gram_band[g * stride + d] * unit_covariance[g * p + g + d];
994            }
995        }
996        let residual_df = self.n_obs as f64 - edf;
997        if !(residual_df >= 1.0) {
998            return Err(format!(
999                "grid spline 2d: too few rows for a scale estimate \
1000                 (n = {}, edf = {edf:.2}; need n − edf ≥ 1)",
1001                self.n_obs
1002            ));
1003        }
1004        let mut residual_cross_cov = vec![0.0_f64; n_dims * n_dims];
1005        for d in 0..n_dims {
1006            for e in d..n_dims {
1007                let mut cd_rhse = 0.0;
1008                let mut ce_rhsd = 0.0;
1009                for g in 0..p {
1010                    cd_rhse += fit.coeffs[d][g] * self.rhs[e][g];
1011                    ce_rhsd += fit.coeffs[e][g] * self.rhs[d][g];
1012                }
1013                let quad = self.gram_quadratic(&fit.coeffs[d], &fit.coeffs[e]);
1014                let v =
1015                    (self.cross_moments[d * n_dims + e] - cd_rhse - ce_rhsd + quad) / residual_df;
1016                residual_cross_cov[d * n_dims + e] = v;
1017                residual_cross_cov[e * n_dims + d] = v;
1018            }
1019        }
1020        Ok(GridSpline2dPosterior {
1021            unit_covariance,
1022            edf,
1023            residual_df,
1024            residual_cross_cov,
1025        })
1026    }
1027}
1028
1029/// Exact posterior summary of a [`GridSpline2dFit`] (see
1030/// [`GridSpline2dDesign::posterior`]): the bridge from the streaming engine
1031/// to covariance-consuming clients (the ANOVA pair-component carve).
1032pub struct GridSpline2dPosterior {
1033    /// `(X'WX + λS)⁻¹`, `p × p` row-major — scale-free posterior covariance
1034    /// of the row-major coefficient vec, shared by all response dimensions.
1035    pub unit_covariance: Vec<f64>,
1036    /// `tr[(X'WX + λS)⁻¹ X'WX]`.
1037    pub edf: f64,
1038    /// `n − edf`.
1039    pub residual_df: f64,
1040    /// `D × D` row-major residual cross-covariance at `n − edf`.
1041    pub residual_cross_cov: Vec<f64>,
1042}
1043
1044/// Fitted penalized tensor-product smoother with its factored covariance.
1045pub struct GridSpline2dFit {
1046    /// Per response dimension: coefficients in row-major flat order
1047    /// `g = j1·(K+3) + j2`.
1048    pub coeffs: Vec<Vec<f64>>,
1049    /// Selected (or supplied) log smoothing parameter, shared by all
1050    /// response dimensions.
1051    pub log_lambda: f64,
1052    /// Per response dimension: profiled (or supplied) observation variance σ².
1053    pub sigma2: Vec<f64>,
1054    /// Pooled restricted log-likelihood at the optimum, up to λ- and
1055    /// data-independent additive constants (exact REML differences across λ).
1056    pub restricted_loglik: f64,
1057    /// Lower Cholesky factor of `X'WX + λS` — the factored posterior precision
1058    /// (unit-σ² scale) used for prediction variances, shared by all dimensions.
1059    chol: Vec<f64>,
1060    axes: [Axis; 2],
1061    m_axis: usize,
1062}
1063
1064/// Serializable snapshot of a [`GridSpline2dFit`] (#1031 persistence
1065/// prerequisite). The grid is deliberately NOT a formula fast path — it is an
1066/// ANOVA pair component (#975 carve) — so there is no `FitResult` variant; this
1067/// state is what the carve's persistence payload serializes and what
1068/// `from_state` replays for an exact predict.
1069///
1070/// Predict needs the MEAN (`coeffs` + the 16-entry tensor basis row, which is a
1071/// pure function of `axes`/`m_axis`) and the VARIANCE
1072/// (`σ²·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor `chol`). All of
1073/// that — and nothing about the training rows — lives on the fit already, so the
1074/// state is a verbatim snapshot: no design CSR, no re-factor on load.
1075#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1076pub struct GridSpline2dState {
1077    /// Per response dimension: row-major coefficients `g = j1·(K+3) + j2`.
1078    pub coeffs: Vec<Vec<f64>>,
1079    pub log_lambda: f64,
1080    /// Per response dimension: profiled (or supplied) observation variance σ².
1081    pub sigma2: Vec<f64>,
1082    pub restricted_loglik: f64,
1083    /// Lower Cholesky factor of `X'WX + λS` (unit-σ² scale), `p × p` row-major —
1084    /// the factored posterior precision the variance term solves against.
1085    pub chol: Vec<f64>,
1086    /// Per axis lower corner of the basis bounding box.
1087    pub axis_lo: [f64; 2],
1088    /// Per axis cell width `h = (hi − lo)/K`.
1089    pub axis_h: [f64; 2],
1090    /// Per axis cell count `K`.
1091    pub axis_cells: [u64; 2],
1092    /// Basis count per axis, `K + 3` (so `p = m_axis²`).
1093    pub m_axis: u64,
1094}
1095
1096impl GridSpline2dFit {
1097    /// Snapshot the fit for persistence (#1031). Verbatim — every field
1098    /// `predict` reads is copied; the training design is not retained on the fit
1099    /// and is not needed for replay.
1100    pub fn to_state(&self) -> GridSpline2dState {
1101        GridSpline2dState {
1102            coeffs: self.coeffs.clone(),
1103            log_lambda: self.log_lambda,
1104            sigma2: self.sigma2.clone(),
1105            restricted_loglik: self.restricted_loglik,
1106            chol: self.chol.clone(),
1107            axis_lo: [self.axes[0].lo, self.axes[1].lo],
1108            axis_h: [self.axes[0].h, self.axes[1].h],
1109            axis_cells: [self.axes[0].cells as u64, self.axes[1].cells as u64],
1110            m_axis: self.m_axis as u64,
1111        }
1112    }
1113
1114    /// Rebuild a predict-capable fit from a snapshot (#1031). Validates shape,
1115    /// finiteness, positive cell widths/counts, positive σ², and that the basis
1116    /// arithmetic is self-consistent (`m_axis = K + 3`, `chol` is `p × p`,
1117    /// `coeffs`/`sigma2` agree on `D`), so a corrupt payload fails here rather
1118    /// than inside a later `predict`. The restored fit replays the posterior
1119    /// mean+variance bit-for-bit: `predict` reads only the snapshotted fields.
1120    pub fn from_state(state: &GridSpline2dState) -> Result<Self, String> {
1121        let m_axis = state.m_axis as usize;
1122        let p = m_axis * m_axis;
1123        for a in 0..2 {
1124            let cells = state.axis_cells[a] as usize;
1125            if cells == 0 {
1126                return Err(format!(
1127                    "grid spline 2d state: axis {a} must have at least one cell"
1128                ));
1129            }
1130            if m_axis != cells + 3 {
1131                return Err(format!(
1132                    "grid spline 2d state: m_axis {m_axis} must equal K+3 = {} for axis {a}",
1133                    cells + 3
1134                ));
1135            }
1136            if !(state.axis_lo[a].is_finite()
1137                && state.axis_h[a].is_finite()
1138                && state.axis_h[a] > 0.0)
1139            {
1140                return Err(format!(
1141                    "grid spline 2d state: axis {a} must have finite lo and positive h, got lo={}, h={}",
1142                    state.axis_lo[a], state.axis_h[a]
1143                ));
1144            }
1145        }
1146        if state.chol.len() != p * p {
1147            return Err(format!(
1148                "grid spline 2d state: chol must be p×p = {p}² = {}, got {}",
1149                p * p,
1150                state.chol.len()
1151            ));
1152        }
1153        let d = state.coeffs.len();
1154        if d == 0 || state.sigma2.len() != d {
1155            return Err(format!(
1156                "grid spline 2d state: need ≥1 response dimension with matching σ² (coeffs D={d}, sigma2 D={})",
1157                state.sigma2.len()
1158            ));
1159        }
1160        for (dim, c) in state.coeffs.iter().enumerate() {
1161            if c.len() != p {
1162                return Err(format!(
1163                    "grid spline 2d state: response dimension {dim} has {} coeffs, expected p = {p}",
1164                    c.len()
1165                ));
1166            }
1167        }
1168        for (dim, &s2) in state.sigma2.iter().enumerate() {
1169            if !(s2.is_finite() && s2 > 0.0) {
1170                return Err(format!(
1171                    "grid spline 2d state: response dimension {dim} has non-positive σ² = {s2}"
1172                ));
1173            }
1174        }
1175        for (i, v) in state
1176            .chol
1177            .iter()
1178            .chain(state.coeffs.iter().flatten())
1179            .enumerate()
1180        {
1181            if !v.is_finite() {
1182                return Err(format!("grid spline 2d state: non-finite entry at {i}"));
1183            }
1184        }
1185        // The diagonal of a lower Cholesky factor is strictly positive; a
1186        // zero/negative pivot means the persisted factor is not a valid
1187        // precision factor and `chol_solve` would divide by it.
1188        for g in 0..p {
1189            let piv = state.chol[g * p + g];
1190            if !(piv.is_finite() && piv > 0.0) {
1191                return Err(format!(
1192                    "grid spline 2d state: non-positive Cholesky pivot {piv} at index {g}"
1193                ));
1194            }
1195        }
1196        if !(state.log_lambda.is_finite() && state.restricted_loglik.is_finite()) {
1197            return Err(format!(
1198                "grid spline 2d state: invalid scalars (log_lambda={}, restricted_loglik={})",
1199                state.log_lambda, state.restricted_loglik
1200            ));
1201        }
1202        let axes = [
1203            Axis {
1204                lo: state.axis_lo[0],
1205                h: state.axis_h[0],
1206                cells: state.axis_cells[0] as usize,
1207            },
1208            Axis {
1209                lo: state.axis_lo[1],
1210                h: state.axis_h[1],
1211                cells: state.axis_cells[1] as usize,
1212            },
1213        ];
1214        Ok(GridSpline2dFit {
1215            coeffs: state.coeffs.clone(),
1216            log_lambda: state.log_lambda,
1217            sigma2: state.sigma2.clone(),
1218            restricted_loglik: state.restricted_loglik,
1219            chol: state.chol.clone(),
1220            axes,
1221            m_axis,
1222        })
1223    }
1224
1225    /// Posterior `(mean, variance)` of response dimension `dim` at an
1226    /// arbitrary point: the 16-entry basis row dotted with the coefficients,
1227    /// and `σ̂²_dim·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor.
1228    /// Outside the bounding box the boundary cell's cubic polynomial extends.
1229    pub fn predict(&self, dim: usize, x1: f64, x2: f64) -> Result<(f64, f64), String> {
1230        if dim >= self.coeffs.len() {
1231            return Err(format!(
1232                "grid spline 2d: response dimension {dim} out of range (D = {})",
1233                self.coeffs.len()
1234            ));
1235        }
1236        if !(x1.is_finite() && x2.is_finite()) {
1237            return Err(format!(
1238                "grid spline 2d: non-finite prediction point ({x1}, {x2})"
1239            ));
1240        }
1241        let (idx, val) = basis_row(&self.axes, self.m_axis, x1, x2);
1242        let p = self.coeffs[dim].len();
1243        let mut mean = 0.0;
1244        let mut row = vec![0.0_f64; p];
1245        for e in 0..16 {
1246            mean += val[e] * self.coeffs[dim][idx[e]];
1247            row[idx[e]] += val[e];
1248        }
1249        let z = chol_solve(&self.chol, p, &row);
1250        let mut quad = 0.0;
1251        for g in 0..p {
1252            quad += row[g] * z[g];
1253        }
1254        Ok((mean, self.sigma2[dim] * quad))
1255    }
1256}
1257
1258/// Build the streaming design and fit with REML-selected λ.
1259pub fn fit_grid_spline_2d(
1260    x1: &[f64],
1261    x2: &[f64],
1262    y: &[f64],
1263    w: &[f64],
1264    k: usize,
1265    metric: [f64; 2],
1266) -> Result<GridSpline2dFit, String> {
1267    GridSpline2dDesign::build(x1, x2, y, w, k, metric)?.fit_reml()
1268}
1269
1270/// Build the streaming design and fit at a FIXED `log λ` (σ² supplied or profiled).
1271pub fn fit_grid_spline_2d_at(
1272    x1: &[f64],
1273    x2: &[f64],
1274    y: &[f64],
1275    w: &[f64],
1276    k: usize,
1277    metric: [f64; 2],
1278    log_lambda: f64,
1279    sigma2: Option<f64>,
1280) -> Result<GridSpline2dFit, String> {
1281    GridSpline2dDesign::build(x1, x2, y, w, k, metric)?.fit_at(log_lambda, sigma2)
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287
1288    #[test]
1289    fn affine_reml_profile_matches_direct_factorizations() {
1290        let side = 10usize;
1291        let mut x1 = Vec::with_capacity(side * side);
1292        let mut x2 = Vec::with_capacity(side * side);
1293        let mut y0 = Vec::with_capacity(side * side);
1294        let mut y1 = Vec::with_capacity(side * side);
1295        for i in 0..side {
1296            for j in 0..side {
1297                let a = i as f64 / (side - 1) as f64;
1298                let b = j as f64 / (side - 1) as f64;
1299                x1.push(a);
1300                x2.push(b);
1301                y0.push((2.0 * a).sin() * (3.0 * b).cos() + a * b);
1302                y1.push(a * a - b * b + (a + 2.0 * b).sin());
1303            }
1304        }
1305        let weights = vec![1.0; x1.len()];
1306        let responses: [&[f64]; 2] = [&y0, &y1];
1307        let design = GridSpline2dDesign::build_multi(&x1, &x2, &responses, &weights, 3, [1.0, 1.5])
1308            .expect("design");
1309        let spectrum = design.reml_spectrum().expect("reference pencil");
1310        let profile = spectrum.profile().expect("affine profile");
1311        let dof = (design.n_obs - PENALTY_NULLITY) as f64;
1312        let rank = (design.p - PENALTY_NULLITY) as f64;
1313
1314        for log_lambda in [-5.0, 0.0, 6.0] {
1315            let solved = design.solve_at(log_lambda).expect("direct solve");
1316            let shared = solved.logdet - rank * log_lambda;
1317            let direct = -0.5
1318                * solved
1319                    .rss_pen
1320                    .iter()
1321                    .map(|rss| shared + dof * (rss / dof).ln())
1322                    .sum::<f64>();
1323            let spectral = profile.evaluate(log_lambda).expect("spectral score").value;
1324            assert!(
1325                (direct - spectral).abs() <= f64::EPSILON.sqrt() * (1.0 + direct.abs()),
1326                "score mismatch at log lambda {log_lambda}: direct={direct}, spectral={spectral}"
1327            );
1328        }
1329    }
1330
1331    /// State → JSON → from_state replays the posterior mean+variance bit-for-bit
1332    /// at held-out points (the grid carries no training CSR, so the snapshot is
1333    /// the whole predict-capable object). This is the #1031 persistence
1334    /// prerequisite the ANOVA carve consumes.
1335    #[test]
1336    fn grid_spline_2d_state_roundtrip_reproduces_predict() {
1337        let k = 8usize;
1338        // A smooth multi-output surface on a scattered grid of points.
1339        let mut x1 = Vec::new();
1340        let mut x2 = Vec::new();
1341        let mut y0 = Vec::new();
1342        let mut y1 = Vec::new();
1343        for i in 0..24 {
1344            for j in 0..24 {
1345                let a = i as f64 / 23.0;
1346                let b = j as f64 / 23.0;
1347                x1.push(a);
1348                x2.push(b);
1349                y0.push((2.5 * a).sin() * (1.7 * b).cos() + 0.3 * a * b);
1350                y1.push(a * a - 0.5 * b + 0.2 * (3.0 * a * b).cos());
1351            }
1352        }
1353        let n = x1.len();
1354        let w = vec![1.0_f64; n];
1355        let ys: Vec<&[f64]> = vec![&y0, &y1];
1356        let fit = GridSpline2dDesign::build_multi(&x1, &x2, &ys, &w, k, [1.0, 1.0])
1357            .expect("design")
1358            .fit_reml()
1359            .expect("fit");
1360
1361        let json = serde_json::to_string(&fit.to_state()).expect("serialize");
1362        let state: GridSpline2dState = serde_json::from_str(&json).expect("deserialize");
1363        let restored = GridSpline2dFit::from_state(&state).expect("restore");
1364
1365        // Held-out points, including one outside the box to exercise the
1366        // boundary-cell polynomial extension.
1367        let probes = [
1368            (0.13, 0.77),
1369            (0.41, 0.05),
1370            (0.66, 0.92),
1371            (0.99, 0.31),
1372            (1.20, -0.10),
1373        ];
1374        for dim in 0..2 {
1375            for &(p1, p2) in &probes {
1376                let (m0, v0) = fit.predict(dim, p1, p2).expect("orig predict");
1377                let (m1, v1) = restored.predict(dim, p1, p2).expect("restored predict");
1378                assert!(
1379                    (m0 - m1).abs() <= 1e-12 * (1.0 + m0.abs()),
1380                    "mean drift dim={dim} at ({p1},{p2}): {m0} vs {m1}"
1381                );
1382                assert!(
1383                    (v0 - v1).abs() <= 1e-12 * (1.0 + v0.abs()),
1384                    "variance drift dim={dim} at ({p1},{p2}): {v0} vs {v1}"
1385                );
1386            }
1387        }
1388        assert!((fit.log_lambda - restored.log_lambda).abs() <= 0.0);
1389        assert!((fit.restricted_loglik - restored.restricted_loglik).abs() <= 0.0);
1390    }
1391
1392    /// Corrupt snapshots fail loudly in `from_state`, not inside a later predict.
1393    #[test]
1394    fn grid_spline_2d_state_rejects_corruption() {
1395        let k = 6usize;
1396        // A dense grid with n > p = (k+3)² so the fit is well-posed: this test
1397        // exercises `from_state` corruption rejection, not the small-n regime,
1398        // so the fit must succeed first (n=18 ≪ p=81 left the penalized design
1399        // rank-deficient and `fit_grid_spline_2d` refused before any assertion).
1400        let side = 12usize;
1401        let mut x1 = Vec::new();
1402        let mut x2 = Vec::new();
1403        for i in 0..side {
1404            for j in 0..side {
1405                x1.push(i as f64 / (side - 1) as f64);
1406                x2.push(j as f64 / (side - 1) as f64);
1407            }
1408        }
1409        let n = x1.len();
1410        // The response must carry genuine curvature: a purely affine `a + b`
1411        // lies entirely in the penalty NULL SPACE (the spline reproduces it
1412        // exactly at any λ), so the penalized residual is identically zero and
1413        // `fit_grid_spline_2d` correctly refuses with "degenerate penalized
1414        // residual 0" — there is no variance to estimate. Add a smooth
1415        // non-null-space (curved) component so the penalized fit leaves a
1416        // positive residual and the REML criterion is well-posed; this test is
1417        // about `from_state` corruption rejection, which needs a successful fit
1418        // first.
1419        let y: Vec<f64> = x1
1420            .iter()
1421            .zip(&x2)
1422            .map(|(&a, &b)| a + b + (3.0 * a).sin() * (2.5 * b).cos())
1423            .collect();
1424        let w = vec![1.0_f64; n];
1425        let fit = fit_grid_spline_2d(&x1, &x2, &y, &w, k, [1.0, 1.0]).expect("fit");
1426
1427        let good = fit.to_state();
1428        let mut bad = good.clone();
1429        bad.chol.pop();
1430        assert!(
1431            GridSpline2dFit::from_state(&bad).is_err(),
1432            "chol length mismatch must error"
1433        );
1434
1435        let mut bad = good.clone();
1436        bad.sigma2[0] = -1.0;
1437        assert!(
1438            GridSpline2dFit::from_state(&bad).is_err(),
1439            "non-positive σ² must error"
1440        );
1441
1442        let mut bad = good.clone();
1443        bad.m_axis += 1;
1444        assert!(
1445            GridSpline2dFit::from_state(&bad).is_err(),
1446            "m_axis ≠ K+3 must error"
1447        );
1448
1449        let mut bad = good.clone();
1450        bad.axis_h[0] = 0.0;
1451        assert!(
1452            GridSpline2dFit::from_state(&bad).is_err(),
1453            "non-positive cell width must error"
1454        );
1455
1456        let mut bad = good;
1457        bad.chol[0] = 0.0;
1458        assert!(
1459            GridSpline2dFit::from_state(&bad).is_err(),
1460            "zero Cholesky pivot must error"
1461        );
1462    }
1463}