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