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
59/// Dimension of the penalty null space: span{1, x1, x2}. The mixed
60/// `2·a1·a2·f_{x1x2}²` term excludes `x1·x2` (its cross derivative is 1).
61const PENALTY_NULLITY: usize = 3;
62
63/// Cholesky pivot floor below which the penalized system is declared singular.
64/// Dense-Cholesky sizing contract documented in the module header.
65const MAX_CELLS_PER_AXIS: usize = 32;
66
67/// 4-point Gauss–Legendre nodes and weights on [−1, 1]. Exact through degree
68/// 2·4−1 = 7, which dominates the degree-6 worst per-axis factor of the
69/// penalty integrands (see the module header for the degree arithmetic).
70const GL4_NODES: [f64; 4] = [
71    -0.861_136_311_594_052_6,
72    -0.339_981_043_584_856_26,
73    0.339_981_043_584_856_26,
74    0.861_136_311_594_052_6,
75];
76const GL4_WEIGHTS: [f64; 4] = [
77    0.347_854_845_137_453_85,
78    0.652_145_154_862_546_2,
79    0.652_145_154_862_546_2,
80    0.347_854_845_137_453_85,
81];
82
83/// Cubic B-spline segment values at local coordinate `u` within a cell.
84/// Entry `m` weights basis `cell + m`: m = 0 is the spline ENDING in this
85/// cell (`(1−u)³/6`), m = 3 the one STARTING (`u³/6`). The four entries sum
86/// to 1 (partition of unity) for u ∈ [0, 1].
87#[inline]
88fn bspline_value(u: f64) -> [f64; 4] {
89    let v = 1.0 - u;
90    [
91        v * v * v / 6.0,
92        (3.0 * u * u * u - 6.0 * u * u + 4.0) / 6.0,
93        (-3.0 * u * u * u + 3.0 * u * u + 3.0 * u + 1.0) / 6.0,
94        u * u * u / 6.0,
95    ]
96}
97
98/// d/du of `bspline_value` (caller scales by 1/h for d/dx). Entries sum to 0.
99#[inline]
100fn bspline_d1(u: f64) -> [f64; 4] {
101    let v = 1.0 - u;
102    [
103        -0.5 * v * v,
104        0.5 * (3.0 * u * u - 4.0 * u),
105        0.5 * (-3.0 * u * u + 2.0 * u + 1.0),
106        0.5 * u * u,
107    ]
108}
109
110/// d²/du² of `bspline_value` (caller scales by 1/h²). Piecewise LINEAR in u —
111/// the degree-1 factor in the quadrature-exactness argument. Entries sum to 0.
112#[inline]
113fn bspline_d2(u: f64) -> [f64; 4] {
114    [1.0 - u, 3.0 * u - 2.0, 1.0 - 3.0 * u, u]
115}
116
117/// One uniform B-spline axis over `[lo, lo + cells·h]`.
118#[derive(Clone, Copy, Debug)]
119struct Axis {
120    lo: f64,
121    h: f64,
122    cells: usize,
123}
124
125impl Axis {
126    /// Cell index and local coordinate. Inside the box `u ∈ [0, 1]`; outside,
127    /// the cell clamps and `u` leaves [0, 1], extending the boundary cell's
128    /// cubic polynomial (deterministic extrapolation, no special casing).
129    #[inline]
130    fn locate(&self, x: f64) -> (usize, f64) {
131        let t = (x - self.lo) / self.h;
132        let cell = (t.floor().max(0.0) as usize).min(self.cells - 1);
133        (cell, t - cell as f64)
134    }
135}
136
137/// The four active cubic B-spline values of one uniform axis `(lo, h, cells)`
138/// at `x`: `(first basis index, values)`, where `values[i]` weights basis
139/// `first + i` of the `cells + 3` axis splines. Outside `[lo, lo + cells·h]`
140/// the boundary cell's cubic polynomial extends — the single convention
141/// shared by fitting, prediction, and every consumer-rebuilt basis row.
142pub fn axis_basis_at(lo: f64, h: f64, cells: usize, x: f64) -> (usize, [f64; 4]) {
143    let (cell, u) = Axis { lo, h, cells }.locate(x);
144    (cell, bspline_value(u))
145}
146
147/// The 16 active tensor-basis entries `(flat index, value)` at `(x1, x2)`.
148/// Flat indices are strictly increasing across the returned arrays.
149#[inline]
150fn basis_row(axes: &[Axis; 2], m_axis: usize, x1: f64, x2: f64) -> ([usize; 16], [f64; 16]) {
151    let (c1, u1) = axes[0].locate(x1);
152    let (c2, u2) = axes[1].locate(x2);
153    let b1 = bspline_value(u1);
154    let b2 = bspline_value(u2);
155    let mut idx = [0usize; 16];
156    let mut val = [0f64; 16];
157    for i in 0..4 {
158        for j in 0..4 {
159            idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
160            val[4 * i + j] = b1[i] * b2[j];
161        }
162    }
163    (idx, val)
164}
165
166/// Dense lower-Cholesky in place (row-major `p×p`); returns the exact
167/// `log det` (twice the log of the pivot products). The strict upper triangle
168/// is zeroed so the buffer is exactly `L` afterwards.
169pub fn cholesky_logdet(a: &mut [f64], p: usize) -> Result<f64, String> {
170    let mut logdet = 0.0;
171    for j in 0..p {
172        let diag = a[j * p + j];
173        let mut s = diag;
174        let mut subtracted = 0.0_f64;
175        for t in 0..j {
176            let sq = a[j * p + t] * a[j * p + t];
177            s -= sq;
178            subtracted += sq;
179        }
180        // The pivot is `a_jj − Σ_t l_jt²`: one product and one subtraction per
181        // term, so a pivot inside the rounding band of that accumulation is
182        // not distinguishable from zero and the system is not positive definite
183        // as computed (#2469). An absolute `1e-300` passed pure roundoff of an
184        // O(1) row and refused an honest tiny pivot.
185        let band = gam_linalg::roundoff::accumulation_growth(2 * j + 1) * (diag.abs() + subtracted);
186        if !(s.is_finite() && s > band) {
187            return Err(format!(
188                "grid spline 2d: penalized system not positive definite at pivot {j} (value {s})"
189            ));
190        }
191        let l = s.sqrt();
192        a[j * p + j] = l;
193        logdet += 2.0 * l.ln();
194        for i in j + 1..p {
195            let mut s2 = a[i * p + j];
196            for t in 0..j {
197                s2 -= a[i * p + t] * a[j * p + t];
198            }
199            a[i * p + j] = s2 / l;
200        }
201    }
202    for i in 0..p {
203        for j in i + 1..p {
204            a[i * p + j] = 0.0;
205        }
206    }
207    Ok(logdet)
208}
209
210/// Solve `L z = b` from a dense row-major lower-triangular factor.
211fn lower_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
212    let mut z = b.to_vec();
213    for i in 0..p {
214        let mut s = z[i];
215        for t in 0..i {
216            s -= l[i * p + t] * z[t];
217        }
218        z[i] = s / l[i * p + i];
219    }
220    z
221}
222
223/// Solve `L Lᵀ x = b` from the stored lower factor.
224pub fn chol_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
225    let mut z = lower_solve(l, p, b);
226    for i in (0..p).rev() {
227        let mut s = z[i];
228        for t in i + 1..p {
229            s -= l[t * p + i] * z[t];
230        }
231        z[i] = s / l[i * p + i];
232    }
233    z
234}
235
236/// Banded sufficient statistics of one streaming pass plus the exact penalty:
237/// everything needed to evaluate the REML criterion and solve at any λ.
238pub struct GridSpline2dDesign {
239    axes: [Axis; 2],
240    /// Basis count per axis, `K + 3`.
241    /// Total coefficients, `(K + 3)²`.
242    p: usize,
243    /// Upper half-bandwidth `3·(K+3) + 3` of both banded matrices.
244    band_half: usize,
245    /// Upper band of `X'WX`: entry `(g, g+d)` at `g·(band_half+1) + d`.
246    gram_band: Vec<f64>,
247    /// Upper band of the exact anisotropic biharmonic penalty `S`.
248    pen_band: Vec<f64>,
249    /// `X'Wy_d`, one length-`p` vector per response dimension. The design
250    /// (gram and penalty bands) is shared across dimensions; only these
251    /// right-hand sides and the response cross-moments are per-dimension.
252    rhs: Vec<Vec<f64>>,
253    /// Response cross-moments `y_d'W y_e` (`D × D` row-major), for the
254    /// profiled-σ² residual quadratics and the residual cross-covariance.
255    cross_moments: Vec<f64>,
256    n_obs: usize,
257}
258
259impl GridSpline2dDesign {
260    /// Single-response entry: see [`Self::build_multi`].
261    pub fn build(
262        x1: &[f64],
263        x2: &[f64],
264        y: &[f64],
265        w: &[f64],
266        k: usize,
267        metric: [f64; 2],
268    ) -> Result<Self, String> {
269        Self::build_multi(x1, x2, &[y], w, k, metric)
270    }
271
272    /// One streaming pass over the rows plus the exact per-cell quadrature
273    /// assembly of the penalty. `k` is the number of cells per axis;
274    /// `metric = [a1, a2]` is the diagonal anisotropy of the biharmonic form.
275    /// `responses` holds one length-`n` response per dimension; the design,
276    /// penalty, and the REML-shared λ are common to all dimensions (one
277    /// surface smoothness), only the right-hand sides differ.
278    pub fn build_multi(
279        x1: &[f64],
280        x2: &[f64],
281        responses: &[&[f64]],
282        w: &[f64],
283        k: usize,
284        metric: [f64; 2],
285    ) -> Result<Self, String> {
286        let n = x1.len();
287        if responses.is_empty() {
288            return Err("grid spline 2d: no response dimensions supplied".to_string());
289        }
290        if x2.len() != n || w.len() != n {
291            return Err(format!(
292                "grid spline 2d: length mismatch x1={n}, x2={}, w={}",
293                x2.len(),
294                w.len()
295            ));
296        }
297        for (d, y) in responses.iter().enumerate() {
298            if y.len() != n {
299                return Err(format!(
300                    "grid spline 2d: response dimension {d} has length {} != {n}",
301                    y.len()
302                ));
303            }
304        }
305        if n <= PENALTY_NULLITY {
306            return Err(format!(
307                "grid spline 2d: needs more than {PENALTY_NULLITY} rows for the profiled REML \
308                 degrees of freedom, got {n}"
309            ));
310        }
311        if k == 0 || k > MAX_CELLS_PER_AXIS {
312            return Err(format!(
313                "grid spline 2d: k must be in 1..={MAX_CELLS_PER_AXIS} (dense Cholesky on \
314                 (k+3)² coefficients — see module sizing contract), got {k}"
315            ));
316        }
317        if !(metric[0].is_finite() && metric[0] > 0.0 && metric[1].is_finite() && metric[1] > 0.0) {
318            return Err(format!(
319                "grid spline 2d: metric diagonal must be finite and positive, got [{}, {}]",
320                metric[0], metric[1]
321            ));
322        }
323        for i in 0..n {
324            if !(x1[i].is_finite() && x2[i].is_finite()) || !(w[i] > 0.0) || !w[i].is_finite() {
325                return Err(format!(
326                    "grid spline 2d: non-finite or non-positive input at row {i} \
327                     (x1={}, x2={}, w={})",
328                    x1[i], x2[i], w[i]
329                ));
330            }
331            for (d, y) in responses.iter().enumerate() {
332                if !y[i].is_finite() {
333                    return Err(format!(
334                        "grid spline 2d: non-finite response at row {i}, dimension {d} ({})",
335                        y[i]
336                    ));
337                }
338            }
339        }
340        let mut axes = [Axis {
341            lo: 0.0,
342            h: 1.0,
343            cells: k,
344        }; 2];
345        for (axis, xs) in axes.iter_mut().zip([x1, x2]) {
346            let mut lo = f64::INFINITY;
347            let mut hi = f64::NEG_INFINITY;
348            for &v in xs {
349                lo = lo.min(v);
350                hi = hi.max(v);
351            }
352            if !(hi > lo) {
353                return Err(format!(
354                    "grid spline 2d: degenerate axis bounding box [{lo}, {hi}]"
355                ));
356            }
357            axis.lo = lo;
358            axis.h = (hi - lo) / k as f64;
359        }
360        let m_axis = k + 3;
361        let p = m_axis * m_axis;
362        let band_half = 3 * m_axis + 3;
363        let stride = band_half + 1;
364        let n_dims = responses.len();
365        let mut gram_band = vec![0.0_f64; p * stride];
366        let mut rhs = vec![vec![0.0_f64; p]; n_dims];
367        let mut cross_moments = vec![0.0_f64; n_dims * n_dims];
368
369        // ── ONE streaming pass: scatter-add X'WX (upper band) and X'Wy_d ──
370        // Each row touches exactly 16 basis entries with strictly increasing
371        // flat indices, so the in-row pair loop (a ≤ b) lands directly in the
372        // upper band: O(n·(16² + 16·D)) total work.
373        for i in 0..n {
374            let (idx, val) = basis_row(&axes, m_axis, x1[i], x2[i]);
375            let wi = w[i];
376            for (d, y) in responses.iter().enumerate() {
377                let wy = wi * y[i];
378                for e in 0..16 {
379                    rhs[d][idx[e]] += wy * val[e];
380                }
381                for (e, ye) in responses.iter().enumerate().skip(d) {
382                    cross_moments[d * n_dims + e] += wy * ye[i];
383                }
384            }
385            for a in 0..16 {
386                let base = idx[a] * stride - idx[a];
387                let wa = wi * val[a];
388                for b in a..16 {
389                    gram_band[base + idx[b]] += wa * val[b];
390                }
391            }
392        }
393        for d in 0..n_dims {
394            for e in 0..d {
395                cross_moments[d * n_dims + e] = cross_moments[e * n_dims + d];
396            }
397        }
398
399        // ── Exact penalty assembly: 4-pt Gauss–Legendre per axis per cell ──
400        // Per-axis quadrature tables (cell-independent on a uniform grid):
401        // values, d/dx (scaled 1/h), d²/dx² (scaled 1/h²) at each GL node.
402        let mut tab = [[[[0.0_f64; 4]; 4]; 3]; 2]; // [axis][channel][node][basis offset]
403        for ax in 0..2 {
404            let h = axes[ax].h;
405            for q in 0..4 {
406                let u = 0.5 * (1.0 + GL4_NODES[q]);
407                let v0 = bspline_value(u);
408                let v1 = bspline_d1(u);
409                let v2 = bspline_d2(u);
410                for e in 0..4 {
411                    tab[ax][0][q][e] = v0[e];
412                    tab[ax][1][q][e] = v1[e] / h;
413                    tab[ax][2][q][e] = v2[e] / (h * h);
414                }
415            }
416        }
417        // Channel scales: J = ∫ a1²·f11² + 2·a1·a2·f12² + a2²·f22².
418        let s11 = metric[0] * metric[0];
419        let s12 = 2.0 * metric[0] * metric[1];
420        let s22 = metric[1] * metric[1];
421        let cell_area_jac = 0.25 * axes[0].h * axes[1].h; // d(x1,x2)/d(ξ1,ξ2) on [−1,1]²
422        let mut pen_band = vec![0.0_f64; p * stride];
423        let mut r11 = [0.0_f64; 16];
424        let mut r12 = [0.0_f64; 16];
425        let mut r22 = [0.0_f64; 16];
426        let mut idx = [0usize; 16];
427        for c1 in 0..k {
428            for c2 in 0..k {
429                for i in 0..4 {
430                    for j in 0..4 {
431                        idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
432                    }
433                }
434                for q1 in 0..4 {
435                    for q2 in 0..4 {
436                        let wq = cell_area_jac * GL4_WEIGHTS[q1] * GL4_WEIGHTS[q2];
437                        for i in 0..4 {
438                            for j in 0..4 {
439                                let e = 4 * i + j;
440                                r11[e] = tab[0][2][q1][i] * tab[1][0][q2][j];
441                                r12[e] = tab[0][1][q1][i] * tab[1][1][q2][j];
442                                r22[e] = tab[0][0][q1][i] * tab[1][2][q2][j];
443                            }
444                        }
445                        for a in 0..16 {
446                            let base = idx[a] * stride - idx[a];
447                            let (pa11, pa12, pa22) =
448                                (wq * s11 * r11[a], wq * s12 * r12[a], wq * s22 * r22[a]);
449                            for b in a..16 {
450                                pen_band[base + idx[b]] +=
451                                    pa11 * r11[b] + pa12 * r12[b] + pa22 * r22[b];
452                            }
453                        }
454                    }
455                }
456            }
457        }
458
459        Ok(GridSpline2dDesign {
460            axes,
461            p,
462            band_half,
463            gram_band,
464            pen_band,
465            rhs,
466            cross_moments,
467            n_obs: n,
468        })
469    }
470
471    /// Lower corner of the data bounding box per axis.
472    pub fn lower_corner(&self) -> [f64; 2] {
473        [self.axes[0].lo, self.axes[1].lo]
474    }
475
476    /// Knot-cell width per axis.
477    pub fn cell_widths(&self) -> [f64; 2] {
478        [self.axes[0].h, self.axes[1].h]
479    }
480
481    /// Exact penalty quadratic form `J(f) = c'Sc` of a coefficient vector —
482    /// the assembled anisotropic biharmonic energy of the spline it encodes.
483    pub fn penalty_value(&self, coeff: &[f64]) -> Result<f64, String> {
484        if coeff.len() != self.p {
485            return Err(format!(
486                "grid spline 2d: coefficient length {} != {}",
487                coeff.len(),
488                self.p
489            ));
490        }
491        let stride = self.band_half + 1;
492        let mut j = 0.0;
493        for g in 0..self.p {
494            let dmax = self.band_half.min(self.p - 1 - g);
495            j += self.pen_band[g * stride] * coeff[g] * coeff[g];
496            for d in 1..=dmax {
497                j += 2.0 * self.pen_band[g * stride + d] * coeff[g] * coeff[g + d];
498            }
499        }
500        Ok(j)
501    }
502
503    /// `a'(X'WX)b` through the retained upper band (exact, O(p·bandwidth)).
504    fn gram_quadratic(&self, a: &[f64], b: &[f64]) -> f64 {
505        let stride = self.band_half + 1;
506        let mut q = 0.0;
507        for g in 0..self.p {
508            let dmax = self.band_half.min(self.p - 1 - g);
509            q += self.gram_band[g * stride] * a[g] * b[g];
510            for d in 1..=dmax {
511                q += self.gram_band[g * stride + d] * (a[g] * b[g + d] + a[g + d] * b[g]);
512            }
513        }
514        q
515    }
516
517    /// Posterior summary of a fit FROM THIS DESIGN, in the exact algebra of
518    /// the solved system (no approximation):
519    /// - `unit_covariance = (X'WX + λS)⁻¹` (scale-free Bayesian posterior
520    ///   covariance of the row-major coefficient vec, shared by dimensions);
521    /// - `edf = tr[(X'WX + λS)⁻¹ X'WX]` (the smoother's effective degrees of
522    ///   freedom at the fitted λ);
523    /// - `residual_cross_cov[d,e] = r_d'W r_e / (n − edf)` assembled from the
524    ///   streamed sufficient statistics
525    ///   (`y_d'Wy_e − c_d'X'Wy_e − c_e'X'Wy_d + c_d'X'WX c_e`).
526    pub fn posterior(&self, fit: &GridSpline2dFit) -> Result<GridSpline2dPosterior, String> {
527        let p = self.p;
528        let n_dims = self.rhs.len();
529        if fit.coeffs.len() != n_dims || fit.coeffs.iter().any(|c| c.len() != p) {
530            return Err(format!(
531                "grid spline 2d: posterior asked for a fit with {} dimensions of length {}, \
532                 design has {n_dims} of {p}",
533                fit.coeffs.len(),
534                fit.coeffs.first().map_or(0, Vec::len)
535            ));
536        }
537        // H⁻¹ column by column through the retained factor (symmetric, O(p³)).
538        let mut unit_covariance = vec![0.0_f64; p * p];
539        let mut e_g = vec![0.0_f64; p];
540        for g in 0..p {
541            e_g[g] = 1.0;
542            let col = chol_solve(&fit.chol, p, &e_g);
543            e_g[g] = 0.0;
544            for (r, &v) in col.iter().enumerate() {
545                unit_covariance[r * p + g] = v;
546            }
547        }
548        // edf = tr(H⁻¹ X'WX) via the gram band (diagonal once, off-band twice).
549        let stride = self.band_half + 1;
550        let mut edf = 0.0;
551        for g in 0..p {
552            let dmax = self.band_half.min(p - 1 - g);
553            edf += self.gram_band[g * stride] * unit_covariance[g * p + g];
554            for d in 1..=dmax {
555                edf += 2.0 * self.gram_band[g * stride + d] * unit_covariance[g * p + g + d];
556            }
557        }
558        let residual_df = self.n_obs as f64 - edf;
559        if !(residual_df >= 1.0) {
560            return Err(format!(
561                "grid spline 2d: too few rows for a scale estimate \
562                 (n = {}, edf = {edf:.2}; need n − edf ≥ 1)",
563                self.n_obs
564            ));
565        }
566        let mut residual_cross_cov = vec![0.0_f64; n_dims * n_dims];
567        for d in 0..n_dims {
568            for e in d..n_dims {
569                let mut cd_rhse = 0.0;
570                let mut ce_rhsd = 0.0;
571                for g in 0..p {
572                    cd_rhse += fit.coeffs[d][g] * self.rhs[e][g];
573                    ce_rhsd += fit.coeffs[e][g] * self.rhs[d][g];
574                }
575                let quad = self.gram_quadratic(&fit.coeffs[d], &fit.coeffs[e]);
576                let v =
577                    (self.cross_moments[d * n_dims + e] - cd_rhse - ce_rhsd + quad) / residual_df;
578                residual_cross_cov[d * n_dims + e] = v;
579                residual_cross_cov[e * n_dims + d] = v;
580            }
581        }
582        Ok(GridSpline2dPosterior {
583            unit_covariance,
584            edf,
585            residual_df,
586            residual_cross_cov,
587        })
588    }
589}
590
591/// Exact posterior summary of a [`GridSpline2dFit`] (see
592/// [`GridSpline2dDesign::posterior`]): the bridge from the streaming engine
593/// to covariance-consuming clients (the ANOVA pair-component carve).
594pub struct GridSpline2dPosterior {
595    /// `(X'WX + λS)⁻¹`, `p × p` row-major — scale-free posterior covariance
596    /// of the row-major coefficient vec, shared by all response dimensions.
597    pub unit_covariance: Vec<f64>,
598    /// `tr[(X'WX + λS)⁻¹ X'WX]`.
599    pub edf: f64,
600    /// `n − edf`.
601    pub residual_df: f64,
602    /// `D × D` row-major residual cross-covariance at `n − edf`.
603    pub residual_cross_cov: Vec<f64>,
604}
605
606/// Fitted penalized tensor-product smoother with its factored covariance.
607pub struct GridSpline2dFit {
608    /// Per response dimension: coefficients in row-major flat order
609    /// `g = j1·(K+3) + j2`.
610    pub coeffs: Vec<Vec<f64>>,
611    /// Selected (or supplied) log smoothing parameter, shared by all
612    /// response dimensions.
613    pub log_lambda: f64,
614    /// Per response dimension: profiled (or supplied) observation variance σ².
615    pub sigma2: Vec<f64>,
616    /// Pooled restricted log-likelihood at the optimum, up to λ- and
617    /// data-independent additive constants (exact REML differences across λ).
618    pub restricted_loglik: f64,
619    /// Lower Cholesky factor of `X'WX + λS` — the factored posterior precision
620    /// (unit-σ² scale) used for prediction variances, shared by all dimensions.
621    chol: Vec<f64>,
622    axes: [Axis; 2],
623    m_axis: usize,
624}
625
626/// Serializable snapshot of a [`GridSpline2dFit`] (#1031 persistence
627/// prerequisite). The grid is deliberately NOT a formula fast path — it is an
628/// ANOVA pair component (#975 carve) — so there is no `FitResult` variant; this
629/// state is what the carve's persistence payload serializes and what
630/// `from_state` replays for an exact predict.
631///
632/// Predict needs the MEAN (`coeffs` + the 16-entry tensor basis row, which is a
633/// pure function of `axes`/`m_axis`) and the VARIANCE
634/// (`σ²·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor `chol`). All of
635/// that — and nothing about the training rows — lives on the fit already, so the
636/// state is a verbatim snapshot: no design CSR, no re-factor on load.
637#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
638pub struct GridSpline2dState {
639    /// Per response dimension: row-major coefficients `g = j1·(K+3) + j2`.
640    pub coeffs: Vec<Vec<f64>>,
641    pub log_lambda: f64,
642    /// Per response dimension: profiled (or supplied) observation variance σ².
643    pub sigma2: Vec<f64>,
644    pub restricted_loglik: f64,
645    /// Lower Cholesky factor of `X'WX + λS` (unit-σ² scale), `p × p` row-major —
646    /// the factored posterior precision the variance term solves against.
647    pub chol: Vec<f64>,
648    /// Per axis lower corner of the basis bounding box.
649    pub axis_lo: [f64; 2],
650    /// Per axis cell width `h = (hi − lo)/K`.
651    pub axis_h: [f64; 2],
652    /// Per axis cell count `K`.
653    pub axis_cells: [u64; 2],
654    /// Basis count per axis, `K + 3` (so `p = m_axis²`).
655    pub m_axis: u64,
656}
657
658impl GridSpline2dFit {
659
660    /// Posterior `(mean, variance)` of response dimension `dim` at an
661    /// arbitrary point: the 16-entry basis row dotted with the coefficients,
662    /// and `σ̂²_dim·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor.
663    /// Outside the bounding box the boundary cell's cubic polynomial extends.
664    pub fn predict(&self, dim: usize, x1: f64, x2: f64) -> Result<(f64, f64), String> {
665        if dim >= self.coeffs.len() {
666            return Err(format!(
667                "grid spline 2d: response dimension {dim} out of range (D = {})",
668                self.coeffs.len()
669            ));
670        }
671        if !(x1.is_finite() && x2.is_finite()) {
672            return Err(format!(
673                "grid spline 2d: non-finite prediction point ({x1}, {x2})"
674            ));
675        }
676        let (idx, val) = basis_row(&self.axes, self.m_axis, x1, x2);
677        let p = self.coeffs[dim].len();
678        let mut mean = 0.0;
679        let mut row = vec![0.0_f64; p];
680        for e in 0..16 {
681            mean += val[e] * self.coeffs[dim][idx[e]];
682            row[idx[e]] += val[e];
683        }
684        let z = chol_solve(&self.chol, p, &row);
685        let mut quad = 0.0;
686        for g in 0..p {
687            quad += row[g] * z[g];
688        }
689        Ok((mean, self.sigma2[dim] * quad))
690    }
691}
692