Skip to main content

gam_solve/
spline_scan.rs

1//! Exact O(n) state-space polynomial smoothing spline ("the scan").
2//!
3//! The order-`m` intrinsic Gaussian prior whose penalized posterior mean is the
4//! degree-`(2m−1)` smoothing spline (penalty `λ∫(f^{(m)})²`) is a Markov process
5//! in the state `α(x) = (f, f′, …, f^{(m−1)})`: an `m`-fold integrated Wiener
6//! process. The Kalman filter + RTS smoother over the x-sorted observations
7//! therefore computes the EXACT smoothing-spline posterior — mean, derivatives,
8//! pointwise variance — and the diffuse innovations decomposition computes the
9//! EXACT restricted (REML) likelihood, all in O(n) work per smoothing-parameter
10//! trial instead of the dense O(n·k²) design/Gram + O(k³) solve per trial
11//! (Wahba 1978; Kohn & Ansley 1987; Durbin & Koopman exact diffuse init).
12//!
13//! Supported orders are `m ∈ {1, 2, 3}` (`MAX_ORDER`): `m = 1` is the
14//! random-walk / linear smoother (penalty `λ∫f′²`), `m = 2` the cubic smoother
15//! (`λ∫f″²`), `m = 3` the quintic smoother (`λ∫(f‴)²`, natural spline degree
16//! `2m−1 = 5`). The diffuse prior carries `m` improper dimensions consumed by
17//! the first `m` distinct abscissae, leaving `m − 1` *partially-diffuse leading
18//! nodes* whose smoothed moments the ordinary RTS recursion cannot reach (its
19//! predicted covariance is rank-deficient there). For `m = 2` that is the
20//! single node 0; for `m = 3` the pair {0, 1}. These are recovered exactly by a
21//! joint Gaussian conditioning of the whole leading block on the first proper
22//! smoothed node (see the smoother pass) — the exact diffuse analog of RTS, and
23//! the multi-node generalization of the `m = 2` reverse-Markov closure.
24//!
25//! Model, after sorting and pooling tied abscissae (precision-weighted):
26//!   α_{t+1} = F_t α_t + η_t,   η_t ~ N(0, q·Q(δ_t)),   q = σ_w²/σ² = 1/λ,
27//!   y_t     = H α_t + ε_t,     ε_t ~ N(0, σ²/w_t),     H = [1 0 … 0],
28//!   F(δ) = exp(δA) (nilpotent shift A),   Q(δ) the m-fold IWP noise,
29//! with a diffuse (improper, flat) prior on the first `m` states carrying the
30//! unpenalized degree-`<m` polynomial null space the spline leaves unshrunk.
31//! (`m = 2`: `F = [[1,δ],[0,1]]`, `Q = [[δ³/3,δ²/2],[δ²/2,δ]]`.)
32//!
33//! Exactness boundaries, by construction:
34//! - the diffuse dimension is `m` and is consumed by the first `m` distinct
35//!   abscissae, after which the filter is an ordinary proper Kalman filter;
36//! - the `m − 1` partially-diffuse leading nodes are recovered by exact Markov
37//!   conditioning of the whole leading block on the first proper smoothed node,
38//!   `p(α_{0..m−2} | y) = ∫ p(α_{0..m−2} | α_{m−1}, y_{0..m−2}) p(α_{m−1} | y)`
39//!   — an affine `((m−1)m)×m` Bayes update built from the flat leading prior,
40//!   the Markov increments, and the leading observations; it reduces to the
41//!   single-node reverse-Markov closure at `m = 2` and needs no diffuse RTS
42//!   recursion;
43//! - off-knot prediction is the Gaussian bridge conditional on the two
44//!   flanking smoothed states (using the exact lag-one smoothed
45//!   cross-covariance `G_t · P^s_{t+1}`), or boundary extrapolation from the
46//!   end states, which reproduces the spline's polynomial extrapolation with
47//!   growing variance — bridge-don't-sag is a theorem here.
48//!
49//! The smoothing parameter is selected by isolating every stationary interval
50//! of the concentrated diffuse restricted log-likelihood over log λ. Exact
51//! analytic score sensitivities are propagated through the filter, and global
52//! curvature bounds drive certified adaptive subdivision; both finite-domain
53//! boundaries compete exactly. σ² is profiled in closed form from the proper
54//! innovations plus the within-tie residual sum.
55
56use gam_math::score_opt::{ClosedInterval, DerivativeEnclosure, ScoreJet, maximize_score_1d};
57
58/// One pooled (distinct-abscissa) observation node.
59#[derive(Clone, Copy, Debug)]
60struct PooledNode {
61    x: f64,
62    /// Precision-weighted mean of the tied responses.
63    y: f64,
64    /// Total weight of the pooled ties (observation variance is `σ²/w`).
65    w: f64,
66}
67
68/// Search interval for log λ (natural log), generous on both sides.
69const LOG_LAMBDA_LO: f64 = -18.0;
70const LOG_LAMBDA_HI: f64 = 18.0;
71/// Numerical floor treating a predicted innovation variance as singular.
72const INNOVATION_VAR_FLOOR: f64 = 1e-300;
73
74/// Maximum supported smoothing-spline order handled by the fixed-capacity
75/// small-matrix layer. Order `m` penalizes `∫(f^{(m)})²`; the state dimension
76/// is `m`. The exact diffuse leading-block smoother (see the smoother pass)
77/// recovers the `m − 1` partially-diffuse leading nodes for any `m`: `m = 1`
78/// has none, `m = 2` has node 0, `m = 3` has {0, 1}. Order 3 (the quintic
79/// smoothing spline, #1044) is the current cap; bumping it further only needs a
80/// wider `mat_inv` branch and the (already order-general) leading-block solve.
81const MAX_ORDER: usize = 3;
82
83/// Row-major `m × m` matrix stored in a fixed `MAX_ORDER`-capacity buffer; only
84/// the top-left `m × m` block is meaningful. Generalizing the order-2 cubic
85/// scan to order `m ∈ {1, 2, 3}` (#1034 item 2, #1044) keeps the
86/// allocation-free fixed storage of the hot filter loop while letting `m` vary
87/// at runtime.
88type Mat2 = [[f64; MAX_ORDER]; MAX_ORDER];
89type Vec2 = [f64; MAX_ORDER];
90
91#[inline]
92fn mat_mul(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
93    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
94    for i in 0..m {
95        for j in 0..m {
96            let mut acc = 0.0;
97            for k in 0..m {
98                acc += a[i][k] * b[k][j];
99            }
100            c[i][j] = acc;
101        }
102    }
103    c
104}
105
106#[inline]
107fn mat_t(a: &Mat2, m: usize) -> Mat2 {
108    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
109    for i in 0..m {
110        for j in 0..m {
111            c[i][j] = a[j][i];
112        }
113    }
114    c
115}
116
117#[inline]
118fn mat_vec(a: &Mat2, v: &Vec2, m: usize) -> Vec2 {
119    let mut out = [0.0; MAX_ORDER];
120    for i in 0..m {
121        let mut acc = 0.0;
122        for j in 0..m {
123            acc += a[i][j] * v[j];
124        }
125        out[i] = acc;
126    }
127    out
128}
129
130#[inline]
131fn mat_add(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
132    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
133    for i in 0..m {
134        for j in 0..m {
135            c[i][j] = a[i][j] + b[i][j];
136        }
137    }
138    c
139}
140
141#[inline]
142fn mat_sub(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
143    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
144    for i in 0..m {
145        for j in 0..m {
146            c[i][j] = a[i][j] - b[i][j];
147        }
148    }
149    c
150}
151
152/// Inverse of an `m × m` (`m ∈ {1, 2, 3}`) with a hard singularity error.
153/// Closed-form cofactor inverses keep the hot-loop arithmetic exact and
154/// branch-free; order 3 is the quintic smoother's state dimension (#1044).
155fn mat_inv(a: &Mat2, m: usize, what: &str) -> Result<Mat2, String> {
156    let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
157    match m {
158        1 => {
159            let d = a[0][0];
160            if !(d.is_finite() && d.abs() > 0.0) {
161                return Err(format!("spline scan: singular 1x1 in {what} (a00={d})"));
162            }
163            out[0][0] = 1.0 / d;
164        }
165        2 => {
166            let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
167            if !(det.is_finite() && det.abs() > 0.0) {
168                return Err(format!("spline scan: singular 2x2 in {what} (det={det})"));
169            }
170            out[0][0] = a[1][1] / det;
171            out[0][1] = -a[0][1] / det;
172            out[1][0] = -a[1][0] / det;
173            out[1][1] = a[0][0] / det;
174        }
175        3 => {
176            // Cofactor / adjugate inverse. Cofactors of the 2×2 minors:
177            let c00 = a[1][1] * a[2][2] - a[1][2] * a[2][1];
178            let c01 = a[1][2] * a[2][0] - a[1][0] * a[2][2];
179            let c02 = a[1][0] * a[2][1] - a[1][1] * a[2][0];
180            let det = a[0][0] * c00 + a[0][1] * c01 + a[0][2] * c02;
181            if !(det.is_finite() && det.abs() > 0.0) {
182                return Err(format!("spline scan: singular 3x3 in {what} (det={det})"));
183            }
184            let inv_det = 1.0 / det;
185            // inv = adj/det = (cofactor matrix)ᵀ / det.
186            out[0][0] = c00 * inv_det;
187            out[0][1] = (a[0][2] * a[2][1] - a[0][1] * a[2][2]) * inv_det;
188            out[0][2] = (a[0][1] * a[1][2] - a[0][2] * a[1][1]) * inv_det;
189            out[1][0] = c01 * inv_det;
190            out[1][1] = (a[0][0] * a[2][2] - a[0][2] * a[2][0]) * inv_det;
191            out[1][2] = (a[0][2] * a[1][0] - a[0][0] * a[1][2]) * inv_det;
192            out[2][0] = c02 * inv_det;
193            out[2][1] = (a[0][1] * a[2][0] - a[0][0] * a[2][1]) * inv_det;
194            out[2][2] = (a[0][0] * a[1][1] - a[0][1] * a[1][0]) * inv_det;
195        }
196        _ => return Err(format!("spline scan: unsupported order {m} in {what}")),
197    }
198    Ok(out)
199}
200
201/// Inverse of a general dense `d × d` SPD matrix via Gauss–Jordan elimination
202/// with partial pivoting, symmetric diagonal (Jacobi) equilibration, and one
203/// iterative-refinement step. Used once per fit by the leading-block diffuse
204/// smoother (dimension `(order−1)·order ≤ 6`), so clarity over speed — it is
205/// NOT on the hot REML grid path (that runs only `run_filter`).
206///
207/// Equilibration matters at order `m ≥ 3`: the IWP process noise `Q(δ)` scales
208/// the `f^{(k)}` state components by `δ^{2m−1}` down to `δ`, so its inverse
209/// `(qQ)⁻¹` — and hence the leading-block precision `Λ` — spans many orders of
210/// magnitude (the f-component carries the `O(w)` observation term, the
211/// high-derivative components carry `O(1/(qδ^{2m−1}))` penalty mass). A bare
212/// Gauss–Jordan inverse of such a `Λ` loses `≈ ε·κ(Λ)` digits, which at heavy
213/// smoothing (small `q`) would corrupt the quintic's leading smoothed nodes.
214/// Rescaling to unit diagonal (`Λ̃ = SΛS`, `s_i = 1/√Λ_ii`) collapses that
215/// scale disparity before the elimination, then `Λ⁻¹ = S Λ̃⁻¹ S`.
216fn dense_spd_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
217    let d = a.len();
218    // Jacobi equilibration scale s_i = 1/√Λ_ii (Λ SPD ⇒ Λ_ii > 0).
219    let s: Vec<f64> = (0..d)
220        .map(|i| {
221            let dii = a[i][i];
222            if dii.is_finite() && dii > 0.0 {
223                1.0 / dii.sqrt()
224            } else {
225                1.0
226            }
227        })
228        .collect();
229    let a_s: Vec<Vec<f64>> = (0..d)
230        .map(|i| (0..d).map(|j| s[i] * a[i][j] * s[j]).collect())
231        .collect();
232    // Gauss–Jordan inverse of the equilibrated matrix.
233    let mut inv_s = gauss_jordan_inverse(&a_s, what)?;
234    // One iterative-refinement step against the equilibrated system:
235    // X ← X + X·(I − Λ̃·X), reducing the residual to near machine precision.
236    let mut resid = vec![vec![0.0_f64; d]; d]; // R = I − Λ̃·X
237    for i in 0..d {
238        for j in 0..d {
239            let mut ax = 0.0;
240            for k in 0..d {
241                ax += a_s[i][k] * inv_s[k][j];
242            }
243            resid[i][j] = f64::from(u8::from(i == j)) - ax;
244        }
245    }
246    let mut delta = vec![vec![0.0_f64; d]; d]; // ΔX = X·R
247    for i in 0..d {
248        for j in 0..d {
249            let mut acc = 0.0;
250            for k in 0..d {
251                acc += inv_s[i][k] * resid[k][j];
252            }
253            delta[i][j] = acc;
254        }
255    }
256    for i in 0..d {
257        for j in 0..d {
258            inv_s[i][j] += delta[i][j];
259        }
260    }
261    // Un-equilibrate: Λ⁻¹ = S·Λ̃⁻¹·S.
262    Ok((0..d)
263        .map(|i| (0..d).map(|j| s[i] * inv_s[i][j] * s[j]).collect())
264        .collect())
265}
266
267/// Gauss–Jordan inverse with partial pivoting (helper for `dense_spd_inverse`).
268fn gauss_jordan_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
269    let d = a.len();
270    let mut aug = a.to_vec();
271    let mut inv = vec![vec![0.0_f64; d]; d];
272    for i in 0..d {
273        inv[i][i] = 1.0;
274    }
275    for col in 0..d {
276        let piv = (col..d)
277            .max_by(|&i, &j| aug[i][col].abs().total_cmp(&aug[j][col].abs()))
278            .unwrap();
279        let p = aug[piv][col];
280        if !(p.is_finite() && p.abs() > 0.0) {
281            return Err(format!(
282                "spline scan: singular {d}x{d} in {what} (pivot={p})"
283            ));
284        }
285        aug.swap(col, piv);
286        inv.swap(col, piv);
287        let d_piv = aug[col][col];
288        for k in 0..d {
289            aug[col][k] /= d_piv;
290            inv[col][k] /= d_piv;
291        }
292        for r in 0..d {
293            if r == col {
294                continue;
295            }
296            let f = aug[r][col];
297            if f == 0.0 {
298                continue;
299            }
300            for k in 0..d {
301                aug[r][k] -= f * aug[col][k];
302                inv[r][k] -= f * inv[col][k];
303            }
304        }
305    }
306    Ok(inv)
307}
308
309/// Factorials `k!` for `k ≤ 2·MAX_ORDER` — the only ones the order-`m`
310/// transition and process-noise formulas reference.
311#[inline]
312fn factorial(k: usize) -> f64 {
313    (1..=k).map(|v| v as f64).product::<f64>().max(1.0)
314}
315
316/// Transition `F(δ) = exp(δ·A)` of the `m`-th order integrated Wiener process,
317/// `A` the nilpotent shift: `F[i][j] = δ^{j−i}/(j−i)!` for `j ≥ i`, else 0.
318/// `m = 1 ⇒ [[1]]`; `m = 2 ⇒ [[1, δ], [0, 1]]` (the cubic case, unchanged).
319#[inline]
320fn transition(delta: f64, m: usize) -> Mat2 {
321    let mut f = [[0.0; MAX_ORDER]; MAX_ORDER];
322    for i in 0..m {
323        for j in i..m {
324            f[i][j] = delta.powi((j - i) as i32) / factorial(j - i);
325        }
326    }
327    f
328}
329
330/// Process noise `Q(δ) = ∫₀^δ e^{As} b bᵀ e^{Aᵀs} ds` (`b = e_{m−1}`) of the
331/// `m`-th order IWP at unit `q`, scaled by `q`:
332/// `Q[i][j] = q · δ^{2m−1−i−j} / ((m−1−i)! (m−1−j)! (2m−1−i−j))`.
333/// `m = 1 ⇒ [[q·δ]]`; `m = 2 ⇒ [[q·δ³/3, q·δ²/2], [q·δ²/2, q·δ]]` (unchanged).
334#[inline]
335fn process_noise(delta: f64, q: f64, m: usize) -> Mat2 {
336    let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
337    for i in 0..m {
338        for j in 0..m {
339            let p = 2 * m - 1 - i - j;
340            out[i][j] = q * delta.powi(p as i32)
341                / (factorial(m - 1 - i) * factorial(m - 1 - j) * (p as f64));
342        }
343    }
344    out
345}
346
347/// Symmetrize in place against drift from the rank-one update arithmetic.
348#[inline]
349fn symmetrize(a: &mut Mat2, m: usize) {
350    for i in 0..m {
351        for j in (i + 1)..m {
352            let off = 0.5 * (a[i][j] + a[j][i]);
353            a[i][j] = off;
354            a[j][i] = off;
355        }
356    }
357}
358
359/// Per-node filter storage needed by the RTS backward pass.
360struct FilterStep {
361    /// Filtered mean `a_{t|t}` and proper covariance `P*_{t|t}`.
362    a_filt: Vec2,
363    p_filt: Mat2,
364    /// One-step prediction `a_{t|t-1}`, proper covariance `P*_{t|t-1}` (for t ≥ 1).
365    a_pred: Vec2,
366    p_pred: Mat2,
367}
368
369/// Output of one full filter pass at a fixed `q = 1/λ` (run at unit σ²).
370struct FilterPass {
371    steps: Vec<FilterStep>,
372    /// Σ over proper steps of `log F̃_t` (innovation variances at σ²=1).
373    sum_log_f: f64,
374    /// First three analytic derivatives of `sum_log_f` with respect to
375    /// `rho = log lambda` (`q = exp(-rho)`). The third order feeds the
376    /// cube-rate certified-search enclosure (#2300): anchoring the derivative
377    /// radius on endpoint `V‴` jets certifies λ→∞ tail cells at width
378    /// `(|V′|/L₄)^{1/3}` instead of `(|V′|/L₃)^{1/2}`.
379    sum_log_f_d1: f64,
380    sum_log_f_d2: f64,
381    sum_log_f_d3: f64,
382    /// Σ over proper steps of `v_t² / F̃_t`.
383    sum_v2_over_f: f64,
384    /// First three analytic `rho` derivatives of `sum_v2_over_f`.
385    sum_v2_over_f_d1: f64,
386    sum_v2_over_f_d2: f64,
387    sum_v2_over_f_d3: f64,
388    /// Number of proper (non-diffuse) innovations.
389    n_proper: usize,
390}
391
392fn run_filter(nodes: &[PooledNode], q: f64, order: usize) -> Result<FilterPass, String> {
393    let n = nodes.len();
394    let mut steps = Vec::with_capacity(n);
395    // Exact diffuse initialization (Durbin–Koopman): P = P* + κ·P_∞, κ → ∞.
396    // The order-`m` polynomial null space (degree < m) is fully diffuse: the
397    // diffuse rank starts at `order`, consumed by the first `order` distinct
398    // abscissae.
399    let mut a: Vec2 = [0.0; MAX_ORDER];
400    let mut a_d1: Vec2 = [0.0; MAX_ORDER];
401    let mut a_d2: Vec2 = [0.0; MAX_ORDER];
402    let mut a_d3: Vec2 = [0.0; MAX_ORDER];
403    let mut p_star: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
404    let mut p_star_d1: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
405    let mut p_star_d2: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
406    let mut p_star_d3: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
407    let mut p_inf: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
408    for i in 0..order {
409        p_inf[i][i] = 1.0;
410    }
411    let mut diffuse_rank = order;
412    let mut sum_log_f = 0.0;
413    let mut sum_log_f_d1 = 0.0;
414    let mut sum_log_f_d2 = 0.0;
415    let mut sum_log_f_d3 = 0.0;
416    let mut sum_v2_over_f = 0.0;
417    let mut sum_v2_over_f_d1 = 0.0;
418    let mut sum_v2_over_f_d2 = 0.0;
419    let mut sum_v2_over_f_d3 = 0.0;
420    let mut n_proper = 0usize;
421    for t in 0..n {
422        let a_pred = a;
423        let p_pred = p_star;
424        let r = 1.0 / nodes[t].w;
425        let v = nodes[t].y - a[0];
426        let v_d1 = -a_d1[0];
427        let v_d2 = -a_d2[0];
428        let v_d3 = -a_d3[0];
429        // H = [1 0 … 0] ⇒ M = P·H' is the first column, F = M[0] (+ r).
430        let mut m_star: Vec2 = [0.0; MAX_ORDER];
431        let mut m_star_d1: Vec2 = [0.0; MAX_ORDER];
432        let mut m_star_d2: Vec2 = [0.0; MAX_ORDER];
433        let mut m_star_d3: Vec2 = [0.0; MAX_ORDER];
434        for i in 0..order {
435            m_star[i] = p_star[i][0];
436            m_star_d1[i] = p_star_d1[i][0];
437            m_star_d2[i] = p_star_d2[i][0];
438            m_star_d3[i] = p_star_d3[i][0];
439        }
440        let f_star = m_star[0] + r;
441        let f_star_d1 = m_star_d1[0];
442        let f_star_d2 = m_star_d2[0];
443        let f_star_d3 = m_star_d3[0];
444        let mut proper_update = diffuse_rank == 0;
445        if diffuse_rank > 0 {
446            let mut m_inf: Vec2 = [0.0; MAX_ORDER];
447            for i in 0..order {
448                m_inf[i] = p_inf[i][0];
449            }
450            let f_inf = m_inf[0];
451            if f_inf > INNOVATION_VAR_FLOOR {
452                // Exact diffuse update (Koopman 1997): the κ→∞ limit of the
453                // standard update; the diffuse step contributes −½·log F_∞ to
454                // the restricted likelihood and consumes one diffuse dimension.
455                for i in 0..order {
456                    let k_inf = m_inf[i] / f_inf;
457                    a[i] += k_inf * v;
458                    a_d1[i] += k_inf * v_d1;
459                    a_d2[i] += k_inf * v_d2;
460                    a_d3[i] += k_inf * v_d3;
461                }
462                let mut p_new = p_star;
463                let mut p_new_d1 = p_star_d1;
464                let mut p_new_d2 = p_star_d2;
465                let mut p_new_d3 = p_star_d3;
466                for i in 0..order {
467                    for j in 0..order {
468                        p_new[i][j] += -m_inf[i] * m_star[j] / f_inf - m_star[i] * m_inf[j] / f_inf
469                            + m_inf[i] * m_inf[j] * f_star / (f_inf * f_inf);
470                        p_new_d1[i][j] += -m_inf[i] * m_star_d1[j] / f_inf
471                            - m_star_d1[i] * m_inf[j] / f_inf
472                            + m_inf[i] * m_inf[j] * f_star_d1 / (f_inf * f_inf);
473                        p_new_d2[i][j] += -m_inf[i] * m_star_d2[j] / f_inf
474                            - m_star_d2[i] * m_inf[j] / f_inf
475                            + m_inf[i] * m_inf[j] * f_star_d2 / (f_inf * f_inf);
476                        p_new_d3[i][j] += -m_inf[i] * m_star_d3[j] / f_inf
477                            - m_star_d3[i] * m_inf[j] / f_inf
478                            + m_inf[i] * m_inf[j] * f_star_d3 / (f_inf * f_inf);
479                    }
480                }
481                p_star = p_new;
482                p_star_d1 = p_new_d1;
483                p_star_d2 = p_new_d2;
484                p_star_d3 = p_new_d3;
485                symmetrize(&mut p_star, order);
486                symmetrize(&mut p_star_d1, order);
487                symmetrize(&mut p_star_d2, order);
488                symmetrize(&mut p_star_d3, order);
489                for i in 0..order {
490                    for j in 0..order {
491                        p_inf[i][j] -= m_inf[i] * m_inf[j] / f_inf;
492                    }
493                }
494                symmetrize(&mut p_inf, order);
495                diffuse_rank -= 1;
496                if diffuse_rank == 0 {
497                    p_inf = [[0.0; MAX_ORDER]; MAX_ORDER];
498                }
499            } else {
500                // Diffuse direction orthogonal to H: this observation is an
501                // ordinary proper update of P* even though diffuse rank remains.
502                proper_update = true;
503            }
504        }
505        if proper_update {
506            if f_star <= INNOVATION_VAR_FLOOR {
507                return Err("spline scan: non-positive innovation variance".to_string());
508            }
509            let inv_f = 1.0 / f_star;
510            // Quotient jets in the recursive Leibniz form: for s = num/f,
511            //   s_k = (num_k − Σ_{j=1..k} C(k,j)·s_{k−j}·f_j) / f,
512            // which is exactly the closed inv_f² / inv_f³ expansion used
513            // before, extended to third order.
514            let mut gain = [0.0; MAX_ORDER];
515            let mut gain_d1 = [0.0; MAX_ORDER];
516            let mut gain_d2 = [0.0; MAX_ORDER];
517            let mut gain_d3 = [0.0; MAX_ORDER];
518            for i in 0..order {
519                gain[i] = m_star[i] * inv_f;
520                gain_d1[i] = (m_star_d1[i] - gain[i] * f_star_d1) * inv_f;
521                gain_d2[i] =
522                    (m_star_d2[i] - 2.0 * gain_d1[i] * f_star_d1 - gain[i] * f_star_d2) * inv_f;
523                gain_d3[i] = (m_star_d3[i]
524                    - 3.0 * gain_d2[i] * f_star_d1
525                    - 3.0 * gain_d1[i] * f_star_d2
526                    - gain[i] * f_star_d3)
527                    * inv_f;
528            }
529            let a_old_d1 = a_d1;
530            let a_old_d2 = a_d2;
531            let a_old_d3 = a_d3;
532            for i in 0..order {
533                a[i] += gain[i] * v;
534                a_d1[i] = a_old_d1[i] + gain_d1[i] * v + gain[i] * v_d1;
535                a_d2[i] = a_old_d2[i] + gain_d2[i] * v + 2.0 * gain_d1[i] * v_d1 + gain[i] * v_d2;
536                a_d3[i] = a_old_d3[i]
537                    + gain_d3[i] * v
538                    + 3.0 * gain_d2[i] * v_d1
539                    + 3.0 * gain_d1[i] * v_d2
540                    + gain[i] * v_d3;
541            }
542            let mut p_new = p_star;
543            let mut p_new_d1 = p_star_d1;
544            let mut p_new_d2 = p_star_d2;
545            let mut p_new_d3 = p_star_d3;
546            for i in 0..order {
547                for j in 0..order {
548                    let mm = m_star[i] * m_star[j];
549                    let mm_d1 = m_star_d1[i] * m_star[j] + m_star[i] * m_star_d1[j];
550                    let mm_d2 = m_star_d2[i] * m_star[j]
551                        + 2.0 * m_star_d1[i] * m_star_d1[j]
552                        + m_star[i] * m_star_d2[j];
553                    let mm_d3 = m_star_d3[i] * m_star[j]
554                        + 3.0 * m_star_d2[i] * m_star_d1[j]
555                        + 3.0 * m_star_d1[i] * m_star_d2[j]
556                        + m_star[i] * m_star_d3[j];
557                    let s0 = mm * inv_f;
558                    let s1 = (mm_d1 - s0 * f_star_d1) * inv_f;
559                    let s2 = (mm_d2 - 2.0 * s1 * f_star_d1 - s0 * f_star_d2) * inv_f;
560                    let s3 = (mm_d3
561                        - 3.0 * s2 * f_star_d1
562                        - 3.0 * s1 * f_star_d2
563                        - s0 * f_star_d3)
564                        * inv_f;
565                    p_new[i][j] -= s0;
566                    p_new_d1[i][j] -= s1;
567                    p_new_d2[i][j] -= s2;
568                    p_new_d3[i][j] -= s3;
569                }
570            }
571            p_star = p_new;
572            p_star_d1 = p_new_d1;
573            p_star_d2 = p_new_d2;
574            p_star_d3 = p_new_d3;
575            symmetrize(&mut p_star, order);
576            symmetrize(&mut p_star_d1, order);
577            symmetrize(&mut p_star_d2, order);
578            symmetrize(&mut p_star_d3, order);
579
580            let vv = v * v;
581            let vv_d1 = 2.0 * v * v_d1;
582            let vv_d2 = 2.0 * (v_d1 * v_d1 + v * v_d2);
583            let vv_d3 = 2.0 * (v * v_d3 + 3.0 * v_d1 * v_d2);
584            let logf_d1 = f_star_d1 * inv_f;
585            let logf_d2 = f_star_d2 * inv_f - logf_d1 * logf_d1;
586            let logf_d3 = f_star_d3 * inv_f - 3.0 * (f_star_d2 * inv_f) * logf_d1
587                + 2.0 * logf_d1 * logf_d1 * logf_d1;
588            sum_log_f += f_star.ln();
589            sum_log_f_d1 += logf_d1;
590            sum_log_f_d2 += logf_d2;
591            sum_log_f_d3 += logf_d3;
592            let t0 = vv * inv_f;
593            let t1 = (vv_d1 - t0 * f_star_d1) * inv_f;
594            let t2 = (vv_d2 - 2.0 * t1 * f_star_d1 - t0 * f_star_d2) * inv_f;
595            let t3 = (vv_d3 - 3.0 * t2 * f_star_d1 - 3.0 * t1 * f_star_d2 - t0 * f_star_d3)
596                * inv_f;
597            sum_v2_over_f += t0;
598            sum_v2_over_f_d1 += t1;
599            sum_v2_over_f_d2 += t2;
600            sum_v2_over_f_d3 += t3;
601            n_proper += 1;
602        }
603        steps.push(FilterStep {
604            a_filt: a,
605            p_filt: p_star,
606            a_pred,
607            p_pred,
608        });
609        // Predict to the next node.
610        if t + 1 < n {
611            let delta = nodes[t + 1].x - nodes[t].x;
612            let f_t = transition(delta, order);
613            a = mat_vec(&f_t, &a, order);
614            a_d1 = mat_vec(&f_t, &a_d1, order);
615            a_d2 = mat_vec(&f_t, &a_d2, order);
616            a_d3 = mat_vec(&f_t, &a_d3, order);
617            let f_t_t = mat_t(&f_t, order);
618            let q_noise = process_noise(delta, q, order);
619            let mut p_next = mat_add(
620                &mat_mul(&mat_mul(&f_t, &p_star, order), &f_t_t, order),
621                &q_noise,
622                order,
623            );
624            let mut p_next_d1 = mat_sub(
625                &mat_mul(&mat_mul(&f_t, &p_star_d1, order), &f_t_t, order),
626                &q_noise,
627                order,
628            );
629            let mut p_next_d2 = mat_add(
630                &mat_mul(&mat_mul(&f_t, &p_star_d2, order), &f_t_t, order),
631                &q_noise,
632                order,
633            );
634            // d^k q / d rho^k = (−1)^k q, so the noise term alternates sign.
635            let mut p_next_d3 = mat_sub(
636                &mat_mul(&mat_mul(&f_t, &p_star_d3, order), &f_t_t, order),
637                &q_noise,
638                order,
639            );
640            symmetrize(&mut p_next, order);
641            symmetrize(&mut p_next_d1, order);
642            symmetrize(&mut p_next_d2, order);
643            symmetrize(&mut p_next_d3, order);
644            p_star = p_next;
645            p_star_d1 = p_next_d1;
646            p_star_d2 = p_next_d2;
647            p_star_d3 = p_next_d3;
648            if diffuse_rank > 0 {
649                let mut pi_next =
650                    mat_mul(&mat_mul(&f_t, &p_inf, order), &mat_t(&f_t, order), order);
651                symmetrize(&mut pi_next, order);
652                p_inf = pi_next;
653            }
654        }
655    }
656    Ok(FilterPass {
657        steps,
658        sum_log_f,
659        sum_log_f_d1,
660        sum_log_f_d2,
661        sum_log_f_d3,
662        sum_v2_over_f,
663        sum_v2_over_f_d1,
664        sum_v2_over_f_d2,
665        sum_v2_over_f_d3,
666        n_proper,
667    })
668}
669
670/// Fitted exact smoothing-spline posterior on the pooled knots.
671#[derive(Clone, Debug)]
672pub struct SplineScanFit {
673    /// Smoothing-spline order `m` (penalize `∫(f^{(m)})²`); state dimension.
674    /// `m = 1` is the random-walk/linear smoother, `m = 2` the cubic smoother,
675    /// `m = 3` the quintic smoother.
676    pub order: usize,
677    /// Distinct sorted abscissae (pooled knots).
678    pub knots: Vec<f64>,
679    /// Smoothed posterior mean of `f` at each knot.
680    pub mean: Vec<f64>,
681    /// Smoothed posterior mean of `f′` at each knot, present only for order
682    /// `m ≥ 2`. At `m = 1` the latent process is Brownian motion, which has NO
683    /// pointwise derivative state (it is a.s. nondifferentiable), so this is
684    /// `None` rather than a fabricated zero.
685    pub deriv: Option<Vec<f64>>,
686    /// Posterior variance of `f` at each knot (scaled by `sigma2`).
687    pub var: Vec<f64>,
688    /// Selected (or supplied) log smoothing parameter `log λ`.
689    log_lambda: f64,
690    /// Profiled (or supplied) observation variance σ².
691    pub sigma2: f64,
692    /// Concentrated diffuse restricted log-likelihood at the optimum, up to a
693    /// λ- and data-independent additive constant. Differences across λ are
694    /// exact REML criterion differences.
695    pub restricted_loglik: f64,
696    /// Raw observation count `n` (pre-pooling; ties collapse to fewer knots),
697    /// retained for the residual d.o.f. `n − order` (#1046).
698    pub n_obs: usize,
699    /// Weighted DATA residual sum of squares `Σ wᵢ (yᵢ − f̂(xᵢ))²` at the
700    /// smoothed posterior mean. Stored explicitly because the profiled
701    /// innovations quadratic `σ̂²·(n − order)` is the REML objective's
702    /// quadratic — data residual energy PLUS process/roughness energy at the
703    /// posterior mode — and is therefore NOT the Gaussian deviance.
704    pub data_sse: f64,
705    /// Smoothed full states `(f, f′)` per knot.
706    smoothed_state: Vec<Vec2>,
707    /// Smoothed full state covariances per knot (unit-σ² scale).
708    smoothed_cov: Vec<Mat2>,
709    /// RTS backward gains `G_t` (lag-one cross-covariance is `G_t · P^s_{t+1}`).
710    rts_gain: Vec<Mat2>,
711    /// q = 1/λ used by the pass (unit-σ² scale).
712    q: f64,
713    /// Pooled observation weight per knot (sum of tied raw weights).
714    node_weight: Vec<f64>,
715}
716
717/// Pool tied abscissae and validate inputs. Returns nodes plus the within-tie
718/// weighted residual sum and the raw observation count.
719fn pool_nodes(
720    x: &[f64],
721    y: &[f64],
722    w: &[f64],
723    order: usize,
724) -> Result<(Vec<PooledNode>, f64, usize), String> {
725    let n = x.len();
726    if y.len() != n || w.len() != n {
727        return Err(format!(
728            "spline scan: length mismatch x={n}, y={}, w={}",
729            y.len(),
730            w.len()
731        ));
732    }
733    for i in 0..n {
734        if !(x[i].is_finite() && y[i].is_finite() && w[i].is_finite() && w[i] > 0.0) {
735            return Err(format!(
736                "spline scan: non-finite or non-positive input at row {i} (x={}, y={}, w={})",
737                x[i], y[i], w[i]
738            ));
739        }
740    }
741    let mut perm: Vec<usize> = (0..n).collect();
742    perm.sort_by(|&i, &j| x[i].total_cmp(&x[j]));
743    let mut nodes: Vec<PooledNode> = Vec::new();
744    for &i in &perm {
745        match nodes.last_mut() {
746            Some(last) if last.x == x[i] => {
747                let w_new = last.w + w[i];
748                last.y = (last.y * last.w + y[i] * w[i]) / w_new;
749                last.w = w_new;
750            }
751            _ => nodes.push(PooledNode {
752                x: x[i],
753                y: y[i],
754                w: w[i],
755            }),
756        }
757    }
758    // Need the `order` diffuse dimensions plus at least one proper innovation.
759    if nodes.len() < order + 1 {
760        return Err(format!(
761            "spline scan: order {order} needs at least {} distinct abscissae, got {}",
762            order + 1,
763            nodes.len()
764        ));
765    }
766    // Within-tie residual sum Σ w_i (y_i − ȳ_group)², part of the profiled σ².
767    let mut ssr_within = 0.0;
768    let mut k = 0usize;
769    for &i in &perm {
770        while nodes[k].x != x[i] {
771            k += 1;
772        }
773        let d = y[i] - nodes[k].y;
774        ssr_within += w[i] * d * d;
775    }
776    Ok((nodes, ssr_within, n))
777}
778
779/// Concentrated diffuse restricted log-likelihood and its exact first three
780/// derivatives with respect to `log λ` (σ² profiled). The derivatives are
781/// propagated through the same diffuse Kalman recursion as the value; no
782/// finite differencing or surrogate objective is involved. The third order
783/// exists solely to anchor the certified-search enclosure radius on endpoint
784/// jets (#2300 cube-rate tail).
785fn concentrated_criterion_jet(
786    nodes: &[PooledNode],
787    ssr_within: f64,
788    n_obs: usize,
789    log_lambda: f64,
790    order: usize,
791) -> Result<(f64, f64, f64, f64), String> {
792    let q = gam_problem::checked_exp_log_strength(-log_lambda)
793        .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
794    let pass = run_filter(nodes, q, order)?;
795    // Profiled σ̂² over the proper innovations plus within-tie residuals;
796    // the restricted degrees of freedom subtract the diffuse dimension `order`.
797    let dof = (n_obs - order) as f64;
798    let rss = pass.sum_v2_over_f + ssr_within;
799    if rss <= 0.0 {
800        return Err("spline scan: degenerate zero residual sum".to_string());
801    }
802    let sigma2 = rss / dof;
803    if pass.n_proper != nodes.len() - order {
804        return Err(format!(
805            "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
806            nodes.len() - order,
807            pass.n_proper
808        ));
809    }
810    let rss_d1 = pass.sum_v2_over_f_d1;
811    let rss_d2 = pass.sum_v2_over_f_d2;
812    let rss_d3 = pass.sum_v2_over_f_d3;
813    let rss_log_d1 = rss_d1 / rss;
814    let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
815    let rss_log_d3 = rss_d3 / rss - 3.0 * (rss_d2 / rss) * rss_log_d1
816        + 2.0 * rss_log_d1 * rss_log_d1 * rss_log_d1;
817    Ok((
818        -0.5 * (pass.sum_log_f + dof * sigma2.ln()),
819        -0.5 * (pass.sum_log_f_d1 + dof * rss_log_d1),
820        -0.5 * (pass.sum_log_f_d2 + dof * rss_log_d2),
821        -0.5 * (pass.sum_log_f_d3 + dof * rss_log_d3),
822    ))
823}
824
825/// Rigorous interval enclosure of the score's first two derivatives.
826///
827/// After eliminating the diffuse polynomial null space, the Gaussian profile
828/// is an affine covariance pencil. Every determinant mode has response
829/// `u in [0,1]`; every normalized profiled-residual derivative is a convex
830/// average of the same kernels. Consequently
831///
832/// `|L''| <= 1/2 (r/4 + 2 nu)`, `|L'''| <= 1/2 (r/4 + 6 nu)`, and
833/// `|L''''| <= 1/2 (r/4 + 26 nu)`,
834///
835/// where `r` is the number of proper innovation modes and `nu=n-order` is the
836/// residual d.f. The fourth-order coefficients: per determinant mode
837/// `|u''''| = u(1-u)|1-14u+36u^2-24u^3| <= 1/4` on `u in [0,1]`, and per
838/// residual kernel `t = z^2 (1-u)` every ratio `|t^{(k)}/t| <= 1` for
839/// `k <= 4`, so Faa di Bruno on `log R` gives `1+4+3+12+6 = 26`. Within-tie
840/// residual energy is lambda-independent and only tightens these bounds.
841/// Endpoint jets plus these analytic Lipschitz bounds therefore enclose the
842/// entire interval without a sampling lattice.
843fn concentrated_criterion_enclosure(
844    nodes: &[PooledNode],
845    ssr_within: f64,
846    n_obs: usize,
847    lo: f64,
848    hi: f64,
849    order: usize,
850) -> Result<DerivativeEnclosure, String> {
851    if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
852        return Err(format!(
853            "spline scan: invalid score-enclosure interval [{lo}, {hi}]"
854        ));
855    }
856    let left = concentrated_criterion_jet(nodes, ssr_within, n_obs, lo, order)?;
857    let right = concentrated_criterion_jet(nodes, ssr_within, n_obs, hi, order)?;
858    let width = hi - lo;
859    let proper_modes = (nodes.len() - order) as f64;
860    let residual_dof = (n_obs - order) as f64;
861    let fourth_abs_bound = 0.5 * (0.25 * proper_modes + 26.0 * residual_dof);
862    // Derivative enclosure from ENDPOINT JETS through third order, not a
863    // global constant. For any u in [lo, hi], Taylor with the L4-Lipschitz
864    // third derivative gives
865    //     |V'(u) − V'(e)| ≤ |V''(e)|·w + |V'''(e)|·w²/2 + L4·w³/6,
866    // so hull(V'(lo), V'(hi)) padded by that radius (with endpoint-max
867    // magnitudes) is a valid OUTER range. History of this radius (#2300):
868    // the original global bound (≈ n·w) made the λ→∞ saturation tail — where
869    // |V'| decays exponentially — grind through O(e^X) certify cells
870    // (>2·10⁶ evaluations, an effective hang); the endpoint-CURVATURE jet
871    // ((|V''(e)|+L3·w)·w) cut that to a half-rate e^{X/2} tail, still a
872    // node timeout at order 3 where L3 ≈ 1.8·10³ at n=600. Anchoring on the
873    // exact V''' endpoint jets makes the pad CUBIC in w on plateaus, so a
874    // tail cell certifies at width ~(|V'|/L4)^{1/3} and the walk costs
875    // e^{X/3} — each exact derivative order divides the exponent again.
876    let curvature_endpoint_abs = left.2.abs().max(right.2.abs());
877    let third_endpoint_abs = left.3.abs().max(right.3.abs());
878    let derivative_radius = curvature_endpoint_abs * width
879        + 0.5 * third_endpoint_abs * width * width
880        + fourth_abs_bound * width * width * width / 6.0;
881    // Curvature enclosure, endpoint-anchored the same way: V''' is
882    // L4-Lipschitz, so |V''(u) − V''(e)| ≤ |V'''(e)|·w + L4·w²/2.
883    let curvature_radius = third_endpoint_abs * width + 0.5 * fourth_abs_bound * width * width;
884    Ok(DerivativeEnclosure {
885        derivative: ClosedInterval::outward(
886            (left.1 - derivative_radius).min(right.1 - derivative_radius),
887            (left.1 + derivative_radius).max(right.1 + derivative_radius),
888        ),
889        curvature: ClosedInterval::outward(
890            (left.2 - curvature_radius).min(right.2 - curvature_radius),
891            (left.2 + curvature_radius).max(right.2 + curvature_radius),
892        ),
893    })
894}
895
896/// Exact diffuse smoother for the `order−1` partially-diffuse leading nodes
897/// (#1044 — the multi-node generalization of the `m = 2` reverse-Markov
898/// closure).
899///
900/// Ordinary RTS recovers every node `t ≥ order−1` (where the filtered
901/// distribution is proper). The first `order−1` nodes are partially diffuse:
902/// their filtered covariance still carries unresolved diffuse mass, so RTS —
903/// which needs the predicted covariance `P_{t+1|t}` to be invertible — cannot
904/// reach them. By the Markov property the leading block depends on all future
905/// data ONLY through the first proper smoothed node `α_{order−1}`:
906///
907///   p(α_{0..order−2} | y) = ∫ p(α_{0..order−2} | α_{order−1}, y_{0..order−2})
908///                             · p(α_{order−1} | y) dα_{order−1}.
909///
910/// The inner conditional is a proper Gaussian: it is the flat (improper)
911/// leading prior tightened by the Markov increments `(α_{t+1} − Fα_t)ᵀ(qQ)⁻¹(·)`
912/// and the leading observations `w_t (y_t − f_t)²`, with `α_{order−1}` entering
913/// linearly through the last increment. Writing `u = (α_0, …, α_{order−2})`,
914///
915///   u | α_{order−1} ~ N(C·α_{order−1} + d,  Σ),   Σ = Λ⁻¹,
916///   Λ  = increments(F'(qQ)⁻¹F …) + leading obs,
917///   d  = Σ·b_const,   C = Σ·B   (B = the pinned-node coupling F'(qQ)⁻¹),
918///
919/// and pushing the smoothed `α_{order−1} ~ N(α̂_p, V_p)` through the affine map
920/// gives the EXACT smoothed leading block, its covariances, and the lag-one
921/// cross-covariances `Cov(α_j, α_{j+1} | y)` the bridge `predict` needs:
922///
923///   mean(u) = C·α̂_p + d,   Cov(u) = C V_p Cᵀ + Σ,   Cov(u, α_p) = C V_p.
924///
925/// This is exact Gaussian conditioning — no diffuse RTS recursion, no
926/// sign-convention-laden `r/N` adjoint. At `order = 2` (one leading node) it is
927/// algebraically the existing single-node closure.
928fn leading_block_smooth(
929    sm_state: &mut [Vec2],
930    sm_cov: &mut [Mat2],
931    gains: &mut [Mat2],
932    nodes: &[PooledNode],
933    q: f64,
934    order: usize,
935) -> Result<(), String> {
936    let nb = order - 1; // leading nodes 0..nb-1 (the partially-diffuse ones)
937    let pin = order - 1; // first proper smoothed node (conditioning anchor)
938    let d = nb * order; // joint dimension of the leading block
939    let mut lambda = vec![vec![0.0_f64; d]; d];
940    let mut b_const = vec![0.0_f64; d];
941    let mut bmat = vec![vec![0.0_f64; order]; d]; // coupling to the pinned node
942
943    // Markov increments t = 0..order-2, each connecting node t and node t+1.
944    for t in 0..order - 1 {
945        let delta = nodes[t + 1].x - nodes[t].x;
946        let f = transition(delta, order);
947        let qn = process_noise(delta, q, order);
948        let a = mat_inv(&qn, order, "leading-block increment noise")?; // (qQ)⁻¹ (symmetric)
949        let ft = mat_t(&f, order);
950        let fta = mat_mul(&ft, &a, order); // F'A
951        let ftaf = mat_mul(&fta, &f, order); // F'A F
952        let af = mat_mul(&a, &f, order); // A F = (F'A)'
953        // Node t diagonal block (node t is always in the block): += F'A F.
954        for i in 0..order {
955            for j in 0..order {
956                lambda[t * order + i][t * order + j] += ftaf[i][j];
957            }
958        }
959        if t + 1 <= nb - 1 {
960            // Both nodes are in the block: fill node t+1's diagonal and the
961            // symmetric cross blocks.
962            for i in 0..order {
963                for j in 0..order {
964                    lambda[(t + 1) * order + i][(t + 1) * order + j] += a[i][j];
965                    lambda[t * order + i][(t + 1) * order + j] -= fta[i][j];
966                    lambda[(t + 1) * order + i][t * order + j] -= af[i][j];
967                }
968            }
969        } else {
970            // t+1 is the pinned node: it enters the conditional only linearly,
971            // through B (its coupling into node t's score is F'A·α_pin).
972            for i in 0..order {
973                for j in 0..order {
974                    bmat[t * order + i][j] += fta[i][j];
975                }
976            }
977        }
978    }
979    // Leading observations: y_t informs the f-component (local index 0) of node t.
980    for t in 0..nb {
981        let w = nodes[t].w;
982        lambda[t * order][t * order] += w;
983        b_const[t * order] += w * nodes[t].y;
984    }
985
986    // Conditional covariance Σ = Λ⁻¹, intercept d = Σ·b_const, coupling C = Σ·B.
987    let sigma = dense_spd_inverse(&lambda, "leading-block precision")?;
988    let dvec: Vec<f64> = (0..d)
989        .map(|i| (0..d).map(|k| sigma[i][k] * b_const[k]).sum())
990        .collect();
991    let cmat: Vec<Vec<f64>> = (0..d)
992        .map(|i| {
993            (0..order)
994                .map(|j| (0..d).map(|k| sigma[i][k] * bmat[k][j]).sum())
995                .collect()
996        })
997        .collect();
998
999    // Pinned smoothed moments (from the ordinary RTS pass).
1000    let ahat_p = sm_state[pin];
1001    let vp = sm_cov[pin];
1002    // cvp = C·V_p  (= Cov(u, α_pin)), D×order.
1003    let cvp: Vec<Vec<f64>> = (0..d)
1004        .map(|i| {
1005            (0..order)
1006                .map(|j| (0..order).map(|k| cmat[i][k] * vp[k][j]).sum())
1007                .collect()
1008        })
1009        .collect();
1010    // mean(u) = C·α̂_p + d.
1011    let mean_u: Vec<f64> = (0..d)
1012        .map(|i| (0..order).map(|j| cmat[i][j] * ahat_p[j]).sum::<f64>() + dvec[i])
1013        .collect();
1014    // Cov(u) = cvp·Cᵀ + Σ.
1015    let cov_u: Vec<Vec<f64>> = (0..d)
1016        .map(|i| {
1017            (0..d)
1018                .map(|k| (0..order).map(|j| cvp[i][j] * cmat[k][j]).sum::<f64>() + sigma[i][k])
1019                .collect()
1020        })
1021        .collect();
1022
1023    // Scatter the smoothed leading states and covariances.
1024    for j in 0..nb {
1025        for i in 0..order {
1026            sm_state[j][i] = mean_u[j * order + i];
1027        }
1028        let mut cov = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1029        for i in 0..order {
1030            for k in 0..order {
1031                cov[i][k] = cov_u[j * order + i][j * order + k];
1032            }
1033        }
1034        symmetrize(&mut cov, order);
1035        sm_cov[j] = cov;
1036    }
1037    // Lag-one bridge gains for the leading intervals [j, j+1], j = 0..order-2.
1038    // gain_j = Cov(α_j, α_{j+1} | y) · Cov(α_{j+1} | y)⁻¹, so that the bridge's
1039    // `gain_j · P^s_{j+1}` reproduces the exact lag-one smoothed cross-cov.
1040    for j in 0..nb {
1041        let mut cross = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1042        if j + 1 <= nb - 1 {
1043            // Both in the block: read the (j, j+1) sub-block of Cov(u).
1044            for i in 0..order {
1045                for k in 0..order {
1046                    cross[i][k] = cov_u[j * order + i][(j + 1) * order + k];
1047                }
1048            }
1049        } else {
1050            // j+1 is the pinned node: read node j's rows of Cov(u, α_pin) = cvp.
1051            for i in 0..order {
1052                for k in 0..order {
1053                    cross[i][k] = cvp[j * order + i][k];
1054                }
1055            }
1056        }
1057        let denom_inv = mat_inv(&sm_cov[j + 1], order, "leading-block gain denominator")?;
1058        gains[j] = mat_mul(&cross, &denom_inv, order);
1059    }
1060    Ok(())
1061}
1062
1063/// Fit at a FIXED `log λ` and order `m ∈ {1, 2, 3}`, σ² either supplied or
1064/// profiled.
1065pub fn fit_spline_scan_at(
1066    x: &[f64],
1067    y: &[f64],
1068    w: &[f64],
1069    log_lambda: f64,
1070    sigma2: Option<f64>,
1071    order: usize,
1072) -> Result<SplineScanFit, String> {
1073    if order == 0 || order > MAX_ORDER {
1074        return Err(format!(
1075            "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
1076        ));
1077    }
1078    let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
1079    let q = gam_problem::checked_exp_log_strength(-log_lambda)
1080        .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
1081    let pass = run_filter(&nodes, q, order)?;
1082    let n = nodes.len();
1083    let dof = (n_obs - order) as f64;
1084    let sigma2 = match sigma2 {
1085        Some(s) => {
1086            if !(s.is_finite() && s > 0.0) {
1087                return Err(format!("spline scan: invalid sigma2 {s}"));
1088            }
1089            s
1090        }
1091        None => (pass.sum_v2_over_f + ssr_within) / dof,
1092    };
1093    // Full diffuse restricted log-likelihood at this (λ, σ²), up to λ- and
1094    // σ-free additive constants: −½[Σ log F̃ + dof·ln σ² + RSS/σ²]. At the
1095    // profiled σ̂² the quadratic term collapses to the λ-free constant `dof`,
1096    // matching `concentrated_criterion` up to that constant.
1097    let rss = pass.sum_v2_over_f + ssr_within;
1098    let restricted_loglik = -0.5 * (pass.sum_log_f + dof * sigma2.ln() + rss / sigma2);
1099
1100    // ── Smoother: ordinary RTS for the proper nodes (t ≥ order−1) plus an
1101    // exact diffuse conditioning of the `order−1` leading nodes. ──
1102    // The filtered distribution is fully proper from node order−1 onward (the
1103    // diffuse rank, = order, is consumed by node order−1), so ordinary RTS is
1104    // valid for t ≥ order−1. The first order−1 nodes are partially diffuse —
1105    // their filtered covariance still carries unresolved diffuse mass and the
1106    // RTS predicted-covariance inverse is singular there — and are recovered
1107    // exactly, jointly, by `leading_block_smooth` (conditioning the whole
1108    // leading block on the first proper smoothed node). For order = 1 there is
1109    // no leading node and RTS covers every node down to t = 0.
1110    let mut sm_state = vec![[0.0_f64; MAX_ORDER]; n];
1111    let mut sm_cov = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
1112    let mut gains = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
1113    sm_state[n - 1] = pass.steps[n - 1].a_filt;
1114    sm_cov[n - 1] = pass.steps[n - 1].p_filt;
1115    for t in (order - 1..n - 1).rev() {
1116        let p_next_pred = &pass.steps[t + 1].p_pred;
1117        let delta = nodes[t + 1].x - nodes[t].x;
1118        let f_t = transition(delta, order);
1119        let p_inv = mat_inv(p_next_pred, order, "RTS predicted covariance")?;
1120        let g = mat_mul(
1121            &mat_mul(&pass.steps[t].p_filt, &mat_t(&f_t, order), order),
1122            &p_inv,
1123            order,
1124        );
1125        let mut dm: Vec2 = [0.0; MAX_ORDER];
1126        for i in 0..order {
1127            dm[i] = sm_state[t + 1][i] - pass.steps[t + 1].a_pred[i];
1128        }
1129        let corr = mat_vec(&g, &dm, order);
1130        for i in 0..order {
1131            sm_state[t][i] = pass.steps[t].a_filt[i] + corr[i];
1132        }
1133        let dp = mat_sub(&sm_cov[t + 1], p_next_pred, order);
1134        let mut cov = mat_add(
1135            &pass.steps[t].p_filt,
1136            &mat_mul(&mat_mul(&g, &dp, order), &mat_t(&g, order), order),
1137            order,
1138        );
1139        symmetrize(&mut cov, order);
1140        sm_cov[t] = cov;
1141        gains[t] = g;
1142    }
1143    // The order−1 partially-diffuse leading nodes by exact joint conditioning
1144    // (the multi-node generalization of the m=2 reverse-Markov closure).
1145    if order >= 2 {
1146        leading_block_smooth(&mut sm_state, &mut sm_cov, &mut gains, &nodes, q, order)?;
1147    }
1148
1149    let knots: Vec<f64> = nodes.iter().map(|n| n.x).collect();
1150    let mean: Vec<f64> = sm_state.iter().map(|s| s[0]).collect();
1151    // f′ lives at state index 1 — present for order ≥ 2 only; the m = 1 latent
1152    // process (Brownian motion) has no derivative state to expose.
1153    let deriv: Option<Vec<f64>> = (order >= 2).then(|| sm_state.iter().map(|s| s[1]).collect());
1154    let var: Vec<f64> = sm_cov.iter().map(|p| p[0][0] * sigma2).collect();
1155    // Weighted DATA residual sum of squares at the smoothed mean. Tied rows
1156    // pool exactly: Σᵢ wᵢ(yᵢ − f̂ₖ)² = Σᵢ wᵢ(yᵢ − ȳₖ)² + Σₖ Wₖ(ȳₖ − f̂ₖ)²
1157    // (within-tie scatter plus pooled-node misfit), so the raw rows the scan
1158    // does not retain are not needed.
1159    let data_sse = ssr_within
1160        + nodes
1161            .iter()
1162            .zip(mean.iter())
1163            .map(|(node, &fhat)| {
1164                let r = node.y - fhat;
1165                node.w * r * r
1166            })
1167            .sum::<f64>();
1168    Ok(SplineScanFit {
1169        order,
1170        knots,
1171        mean,
1172        deriv,
1173        var,
1174        log_lambda,
1175        sigma2,
1176        restricted_loglik,
1177        n_obs,
1178        data_sse,
1179        smoothed_state: sm_state,
1180        smoothed_cov: sm_cov,
1181        rts_gain: gains,
1182        q,
1183        node_weight: nodes.iter().map(|n| n.w).collect(),
1184    })
1185}
1186
1187/// Fit with `log λ` selected by the concentrated diffuse REML criterion.
1188/// Every stationary interval in the bounded, scale-equivariant log-λ domain
1189/// is isolated using analytic derivatives and rigorous interval bounds; the
1190/// two boundary/null-recovery candidates are evaluated exactly.
1191pub fn fit_spline_scan(
1192    x: &[f64],
1193    y: &[f64],
1194    w: &[f64],
1195    order: usize,
1196) -> Result<SplineScanFit, String> {
1197    if order == 0 || order > MAX_ORDER {
1198        return Err(format!(
1199            "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
1200        ));
1201    }
1202    let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
1203    // Covariate-rescaling equivariance (#1214). The order-`m` IWP process noise
1204    // is `Q(δ) ∝ q · δ^{2m−1}`, so under an affine covariate rescale `x → a·x`
1205    // (all abscissa gaps `δ → a·δ`) the posterior `f(x)` is *exactly* invariant
1206    // iff the smoothing parameter co-transforms as `q → q / a^{2m−1}`, i.e.
1207    // `log λ → log λ + (2m−1)·log a` (λ = 1/q). The whole smoother — criterion,
1208    // fit, and the Gaussian-bridge `predict` — runs self-consistently in the raw
1209    // covariate units, so the *only* place covariate scale leaks in is this
1210    // outer `log λ` search: a fixed absolute bracket `[LOG_LAMBDA_LO,
1211    // LOG_LAMBDA_HI]` does not track the data span, so at small/large covariate
1212    // scale the equivariant optimum rails out of the bracket and the fit drifts.
1213    // Anchor the bracket to the data's own length scale: search `log λ` around
1214    // `(2m−1)·log L` where `L` is the abscissa span (which scales linearly with
1215    // the covariate), so the search is performed in scale-free units and the
1216    // selected `q · L^{2m−1}` — hence the posterior `f(x)` — is invariant.
1217    let span = nodes.last().map(|n| n.x).unwrap_or(0.0) - nodes.first().map(|n| n.x).unwrap_or(0.0);
1218    let scale_shift = if span.is_finite() && span > 0.0 {
1219        (2 * order - 1) as f64 * span.ln()
1220    } else {
1221        0.0
1222    };
1223    let lo_anchor = LOG_LAMBDA_LO + scale_shift;
1224    let hi_anchor = LOG_LAMBDA_HI + scale_shift;
1225    let search = maximize_score_1d(
1226        lo_anchor,
1227        hi_anchor,
1228        f64::EPSILON.sqrt(),
1229        |ll| {
1230            concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order).map(
1231                |(value, derivative, curvature, _third)| ScoreJet {
1232                    value,
1233                    derivative,
1234                    curvature,
1235                },
1236            )
1237        },
1238        |lo, hi| concentrated_criterion_enclosure(&nodes, ssr_within, n_obs, lo, hi, order),
1239    )
1240    .map_err(|error| format!("spline scan: REML stationary isolation failed: {error}"))?;
1241    fit_spline_scan_at(x, y, w, search.optimum.x, None, order)
1242}
1243
1244/// Lossless serializable snapshot of a [`SplineScanFit`] (#1034).
1245///
1246/// Carries exactly the smoother state the Gaussian-bridge `predict` replays:
1247/// pooled knots, smoothed `(f, f′, …, f^{(m−1)})` states (`m` per knot),
1248/// smoothed state covariances (unit-σ² scale, symmetric — stored as the
1249/// upper triangle row-major, `m(m+1)/2` per knot), RTS backward gains (full
1250/// `m×m` row-major — gains are NOT symmetric), pooled node weights, and the
1251/// three fit scalars. `q = e^{−log λ}` and the public `mean`/`deriv`/`var`
1252/// views are derived on restore rather than stored, so a snapshot cannot go
1253/// internally inconsistent. The layouts are order-derived; at the historical
1254/// cubic `m = 2` they are exactly the original `[f, f′]` / `[c00, c01, c11]` /
1255/// `[g00, g01, g10, g11]` triples, so pre-order-generality snapshots restore
1256/// unchanged.
1257#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1258pub struct SplineScanState {
1259    /// Smoothing-spline order `m ∈ {1, 2, 3}` (`#[serde(default)]` → reads as
1260    /// the historical cubic `m = 2` for snapshots written before order
1261    /// generality).
1262    #[serde(default = "default_spline_scan_order")]
1263    pub order: usize,
1264    pub knots: Vec<f64>,
1265    /// Smoothed `(f, f′, …, f^{(m−1)})` per knot, row-major (`m` per knot).
1266    pub state: Vec<f64>,
1267    /// Smoothed covariance per knot at unit-σ² scale, upper triangle row-major
1268    /// (`m(m+1)/2` per knot): `[c00, c01, …, c0,m−1, c11, …, c_{m−1,m−1}]`.
1269    pub cov: Vec<f64>,
1270    /// RTS backward gain per knot, full `m×m` row-major (`m²` per knot); the
1271    /// last knot's gain is structurally unused and stored as written.
1272    pub gain: Vec<f64>,
1273    /// Pooled (tied-abscissa summed) observation weight per knot.
1274    pub node_weight: Vec<f64>,
1275    pub log_lambda: f64,
1276    pub sigma2: f64,
1277    pub restricted_loglik: f64,
1278    /// Raw observation count `n` (#1046).
1279    pub n_obs: u64,
1280    /// Weighted data residual sum of squares `Σ wᵢ (yᵢ − f̂(xᵢ))²` at the
1281    /// smoothed mean — the Gaussian deviance. Stored because it cannot be
1282    /// recovered from the profiled σ² (whose quadratic also carries
1283    /// process/roughness energy) and the raw rows are not retained.
1284    pub data_sse: f64,
1285}
1286
1287/// Serde default for [`SplineScanState::order`]: historical snapshots predate
1288/// order generality and are cubic (`m = 2`).
1289fn default_spline_scan_order() -> usize {
1290    2
1291}
1292
1293impl SplineScanFit {
1294    /// Snapshot the full smoother state for persistence (#1034).
1295    pub fn to_state(&self) -> SplineScanState {
1296        let order = self.order;
1297        let tri = order * (order + 1) / 2;
1298        let nk = self.knots.len();
1299        let mut state = Vec::with_capacity(order * nk);
1300        for s in &self.smoothed_state {
1301            state.extend_from_slice(&s[..order]);
1302        }
1303        let mut cov = Vec::with_capacity(tri * nk);
1304        for c in &self.smoothed_cov {
1305            for i in 0..order {
1306                for j in i..order {
1307                    cov.push(c[i][j]);
1308                }
1309            }
1310        }
1311        let mut gain = Vec::with_capacity(order * order * nk);
1312        for g in &self.rts_gain {
1313            for i in 0..order {
1314                for j in 0..order {
1315                    gain.push(g[i][j]);
1316                }
1317            }
1318        }
1319        SplineScanState {
1320            order: self.order,
1321            knots: self.knots.clone(),
1322            state,
1323            cov,
1324            gain,
1325            node_weight: self.node_weight.clone(),
1326            log_lambda: self.log_lambda,
1327            sigma2: self.sigma2,
1328            restricted_loglik: self.restricted_loglik,
1329            n_obs: self.n_obs as u64,
1330            data_sse: self.data_sse,
1331        }
1332    }
1333
1334    /// Rebuild the exact in-memory fit from a persisted snapshot (#1034).
1335    ///
1336    /// Validates shape, finiteness, strict knot ordering, positive weights and
1337    /// σ², so a corrupt payload fails loudly here instead of inside a later
1338    /// `predict`. The restored fit replays the Gaussian bridge bit-for-bit:
1339    /// every field `predict`/`edf`/`deriv_at_knot` reads is either stored
1340    /// verbatim or derived by the same expressions the fitter uses.
1341    pub fn from_state(state: &SplineScanState) -> Result<Self, String> {
1342        let order = state.order;
1343        if order == 0 || order > MAX_ORDER {
1344            return Err(format!(
1345                "spline scan state: order must be in 1..={MAX_ORDER}, got {order}"
1346            ));
1347        }
1348        let m = state.knots.len();
1349        if m < order + 1 {
1350            return Err(format!(
1351                "spline scan state: order {order} needs at least {} knots, got {m}",
1352                order + 1
1353            ));
1354        }
1355        let tri = order * (order + 1) / 2;
1356        if state.state.len() != order * m
1357            || state.cov.len() != tri * m
1358            || state.gain.len() != order * order * m
1359            || state.node_weight.len() != m
1360        {
1361            return Err(format!(
1362                "spline scan state: inconsistent lengths (order={order}, m={m}, state={}, cov={}, gain={}, weights={})",
1363                state.state.len(),
1364                state.cov.len(),
1365                state.gain.len(),
1366                state.node_weight.len()
1367            ));
1368        }
1369        let all = state
1370            .state
1371            .iter()
1372            .chain(&state.cov)
1373            .chain(&state.gain)
1374            .chain(&state.knots)
1375            .chain(&state.node_weight);
1376        for (i, v) in all.enumerate() {
1377            if !v.is_finite() {
1378                return Err(format!("spline scan state: non-finite entry at {i}"));
1379            }
1380        }
1381        gam_problem::validate_log_strength(state.log_lambda)
1382            .map_err(|error| format!("spline scan state: {error}"))?;
1383        if !(state.restricted_loglik.is_finite() && state.sigma2.is_finite() && state.sigma2 > 0.0)
1384        {
1385            return Err(format!(
1386                "spline scan state: invalid scalars (log_lambda={}, sigma2={}, restricted_loglik={})",
1387                state.log_lambda, state.sigma2, state.restricted_loglik
1388            ));
1389        }
1390        if !(state.data_sse.is_finite() && state.data_sse >= 0.0) {
1391            return Err(format!(
1392                "spline scan state: invalid data_sse {}",
1393                state.data_sse
1394            ));
1395        }
1396        if state.knots.windows(2).any(|kk| !(kk[0] < kk[1])) {
1397            return Err("spline scan state: knots must be strictly increasing".to_string());
1398        }
1399        if state.node_weight.iter().any(|&w| w <= 0.0) {
1400            return Err("spline scan state: node weights must be positive".to_string());
1401        }
1402        let smoothed_state: Vec<Vec2> = state
1403            .state
1404            .chunks_exact(order)
1405            .map(|s| {
1406                let mut v = [0.0_f64; MAX_ORDER];
1407                v[..order].copy_from_slice(s);
1408                v
1409            })
1410            .collect();
1411        let smoothed_cov: Vec<Mat2> = state
1412            .cov
1413            .chunks_exact(tri)
1414            .map(|c| {
1415                let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1416                let mut idx = 0;
1417                for i in 0..order {
1418                    for j in i..order {
1419                        mm[i][j] = c[idx];
1420                        mm[j][i] = c[idx];
1421                        idx += 1;
1422                    }
1423                }
1424                mm
1425            })
1426            .collect();
1427        let rts_gain: Vec<Mat2> = state
1428            .gain
1429            .chunks_exact(order * order)
1430            .map(|g| {
1431                let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1432                for i in 0..order {
1433                    for j in 0..order {
1434                        mm[i][j] = g[i * order + j];
1435                    }
1436                }
1437                mm
1438            })
1439            .collect();
1440        let sigma2 = state.sigma2;
1441        if state.n_obs == 0 {
1442            return Err("spline scan state: n_obs must be positive".to_string());
1443        }
1444        let n_obs = state.n_obs as usize;
1445        Ok(Self {
1446            order,
1447            knots: state.knots.clone(),
1448            mean: smoothed_state.iter().map(|s| s[0]).collect(),
1449            deriv: (order >= 2).then(|| smoothed_state.iter().map(|s| s[1]).collect()),
1450            var: smoothed_cov.iter().map(|c| c[0][0] * sigma2).collect(),
1451            log_lambda: state.log_lambda,
1452            sigma2,
1453            restricted_loglik: state.restricted_loglik,
1454            n_obs,
1455            data_sse: state.data_sse,
1456            smoothed_state,
1457            smoothed_cov,
1458            rts_gain,
1459            q: gam_problem::checked_exp_log_strength(-state.log_lambda)
1460                .map_err(|error| format!("spline scan inverse log strength: {error}"))?,
1461            node_weight: state.node_weight.clone(),
1462        })
1463    }
1464
1465    /// Exact posterior `(mean, variance)` of `f` at an arbitrary abscissa.
1466    ///
1467    /// Interior points use the Gaussian bridge conditional on the two flanking
1468    /// smoothed states with the exact lag-one smoothed cross-covariance
1469    /// `Cov(α_t, α_{t+1} | y) = G_t · P^s_{t+1}`; exterior points extrapolate
1470    /// from the boundary state (linear mean, cubically growing variance).
1471    pub fn predict(&self, x_new: f64) -> Result<(f64, f64), String> {
1472        if !x_new.is_finite() {
1473            return Err("spline scan: non-finite prediction abscissa".to_string());
1474        }
1475        let n = self.knots.len();
1476        let order = self.order;
1477        let first = self.knots[0];
1478        let last = self.knots[n - 1];
1479        if x_new <= first {
1480            let delta = first - x_new;
1481            // Backward extrapolation through the reverse map α(x) = F⁻¹(α₁ − η).
1482            let f_t = transition(delta, order);
1483            let f_inv = mat_inv(&f_t, order, "backward extrapolation transition")?;
1484            let mean_s = mat_vec(&f_inv, &self.smoothed_state[0], order);
1485            let qm = process_noise(delta, self.q, order);
1486            let cov = mat_add(
1487                &mat_mul(
1488                    &mat_mul(&f_inv, &self.smoothed_cov[0], order),
1489                    &mat_t(&f_inv, order),
1490                    order,
1491                ),
1492                &mat_mul(&mat_mul(&f_inv, &qm, order), &mat_t(&f_inv, order), order),
1493                order,
1494            );
1495            return Ok((mean_s[0], cov[0][0] * self.sigma2));
1496        }
1497        if x_new >= last {
1498            let delta = x_new - last;
1499            let f_t = transition(delta, order);
1500            let mean_s = mat_vec(&f_t, &self.smoothed_state[n - 1], order);
1501            let cov = mat_add(
1502                &mat_mul(
1503                    &mat_mul(&f_t, &self.smoothed_cov[n - 1], order),
1504                    &mat_t(&f_t, order),
1505                    order,
1506                ),
1507                &process_noise(delta, self.q, order),
1508                order,
1509            );
1510            return Ok((mean_s[0], cov[0][0] * self.sigma2));
1511        }
1512        // Flanking knot interval via binary search.
1513        let t = match self.knots.binary_search_by(|k| k.total_cmp(&x_new)) {
1514            Ok(idx) => return Ok((self.mean[idx], self.var[idx])),
1515            Err(idx) => idx - 1,
1516        };
1517        let (xa, xb) = (self.knots[t], self.knots[t + 1]);
1518        let (d1, d2) = (x_new - xa, xb - x_new);
1519        let (f1m, f2m) = (transition(d1, order), transition(d2, order));
1520        let (q1, q2) = (
1521            process_noise(d1, self.q, order),
1522            process_noise(d2, self.q, order),
1523        );
1524        let q1_inv = mat_inv(&q1, order, "bridge left noise")?;
1525        let q2_inv = mat_inv(&q2, order, "bridge right noise")?;
1526        // p(α* | α_t, α_{t+1}) ∝ N(α*; F₁α_t, Q₁)·N(α_{t+1}; F₂α*, Q₂):
1527        //   Λ = Q₁⁻¹ + F₂ᵀQ₂⁻¹F₂,  mean = Λ⁻¹(Q₁⁻¹F₁ α_t + F₂ᵀQ₂⁻¹ α_{t+1}).
1528        let lambda = mat_add(
1529            &q1_inv,
1530            &mat_mul(&mat_mul(&mat_t(&f2m, order), &q2_inv, order), &f2m, order),
1531            order,
1532        );
1533        let lam_inv = mat_inv(&lambda, order, "bridge precision")?;
1534        let ca = mat_mul(&lam_inv, &mat_mul(&q1_inv, &f1m, order), order);
1535        let cb = mat_mul(
1536            &lam_inv,
1537            &mat_mul(&mat_t(&f2m, order), &q2_inv, order),
1538            order,
1539        );
1540        let ma = mat_vec(&ca, &self.smoothed_state[t], order);
1541        let mb = mat_vec(&cb, &self.smoothed_state[t + 1], order);
1542        let mut mean_s = [0.0_f64; MAX_ORDER];
1543        for i in 0..order {
1544            mean_s[i] = ma[i] + mb[i];
1545        }
1546        // Push the joint smoothed covariance of (α_t, α_{t+1}) through the
1547        // affine map: cross term uses Cov(α_t, α_{t+1}|y) = G_t · P^s_{t+1}.
1548        let cross = mat_mul(&self.rts_gain[t], &self.smoothed_cov[t + 1], order);
1549        let mut cov = mat_add(
1550            &mat_add(
1551                &mat_mul(
1552                    &mat_mul(&ca, &self.smoothed_cov[t], order),
1553                    &mat_t(&ca, order),
1554                    order,
1555                ),
1556                &mat_mul(
1557                    &mat_mul(&cb, &self.smoothed_cov[t + 1], order),
1558                    &mat_t(&cb, order),
1559                    order,
1560                ),
1561                order,
1562            ),
1563            &lam_inv,
1564            order,
1565        );
1566        let cab = mat_mul(&mat_mul(&ca, &cross, order), &mat_t(&cb, order), order);
1567        cov = mat_add(&cov, &mat_add(&cab, &mat_t(&cab, order), order), order);
1568        symmetrize(&mut cov, order);
1569        Ok((mean_s[0], cov[0][0] * self.sigma2))
1570    }
1571
1572    /// Exact effective degrees of freedom of the fitted smoother.
1573    ///
1574    /// For a Gaussian smoother the influence (hat) matrix is
1575    /// `S = Cov_post · W / σ²` (posterior mean is linear in `y` with that
1576    /// exact coefficient matrix), so
1577    /// `EDF = tr(S) = tr(W · Cov_post) / σ² = Σ_t w_t · Var_smoothed(f_t) / σ²`.
1578    /// This is the standard Gaussian-process identity — no second smoother
1579    /// pass and no approximation. Tied abscissae pool exactly: each raw row
1580    /// `i` in tie-group `k` contributes `∂f̂(x_k)/∂y_i = C̃_kk · w_i` (the
1581    /// pooled mean `ȳ_k` is precision-weighted), so the raw-row trace
1582    /// `Σ_i w_i · C̃_{k(i),k(i)}` collapses to `Σ_k W_k · C̃_kk` with the
1583    /// pooled weights `W_k`. `smoothed_cov` is stored at unit-σ² scale
1584    /// (`C̃ = Cov_post / σ²`), so the σ² factors cancel exactly.
1585    pub fn edf(&self) -> f64 {
1586        self.node_weight
1587            .iter()
1588            .zip(self.smoothed_cov.iter())
1589            .map(|(w, c)| w * c[0][0])
1590            .sum()
1591    }
1592
1593    /// Posterior `(mean, variance)` of the derivative `f′` at a knot index.
1594    ///
1595    /// `None` at order `m = 1`: the latent process is Brownian motion, which
1596    /// is almost surely nondifferentiable — there is no derivative state, and
1597    /// fabricating a "known zero" `(0, 0)` would assert certainty about a
1598    /// quantity that does not exist.
1599    pub fn deriv_at_knot(&self, t: usize) -> Option<(f64, f64)> {
1600        (self.order >= 2).then(|| {
1601            (
1602                self.smoothed_state[t][1],
1603                self.smoothed_cov[t][1][1] * self.sigma2,
1604            )
1605        })
1606    }
1607
1608    /// Selected smoothing parameter `λ = e^{log λ}` (#1046).
1609    pub fn lambda(&self) -> f64 {
1610        gam_problem::checked_exp_log_strength(self.log_lambda)
1611            .expect("SplineScanFit construction validates its private log strength")
1612    }
1613
1614    pub fn log_lambda(&self) -> f64 {
1615        self.log_lambda
1616    }
1617
1618    /// Raw observation count `n` used to profile σ² (#1046).
1619    pub fn n_obs(&self) -> usize {
1620        self.n_obs
1621    }
1622
1623    /// Gaussian deviance — the weighted DATA residual sum of squares
1624    /// `Σ wᵢ(yᵢ − f̂ᵢ)²` at the smoothed mean (#1046). This is the stored
1625    /// `data_sse`, computed against the fitted values at fit time. It is NOT
1626    /// `σ̂²·(n − order)`: the profiled σ² divides the REML innovations
1627    /// quadratic, which is data residual energy PLUS process/roughness energy
1628    /// at the posterior mode (for order 1 on `x = (0,1)`, `y = (0,1)`, unit
1629    /// weights and λ = 1 the posterior mean is `(1/3, 2/3)`; the data SSE is
1630    /// 2/9 while `σ̂²·(n − order) = 1/3`, the extra 1/9 being penalty energy).
1631    pub fn deviance(&self) -> f64 {
1632        self.data_sse
1633    }
1634}
1635
1636#[cfg(test)]
1637mod tests {
1638
1639    /// Diagnostic reproduction of the #2300 weighted-scan non-termination:
1640    /// the exact acceptance DGP (n=180, step weights 1/9), with the SAME
1641    /// certified search `fit_spline_scan` runs — but through a counting
1642    /// wrapper that bails out with the evaluation count and the stuck
1643    /// abscissa once the search exceeds a budget no terminating search on a
1644    /// 36-wide bracket can legitimately need. A pass proves termination in
1645    /// bounded work; the panic message is the diagnosis.
1646    #[test]
1647    fn weighted_scan_dgp_2300_search_terminates_in_bounded_evaluations() {
1648        // Deterministic stand-in for the acceptance DGP (xorshift Box-Muller;
1649        // the hang class is structural, not noise-realization-specific).
1650        let n = 180usize;
1651        let mut state: u64 = 0x2300_2300_2300_2300;
1652        let mut next_unit = move || {
1653            state ^= state << 13;
1654            state ^= state >> 7;
1655            state ^= state << 17;
1656            (state >> 11) as f64 / (1u64 << 53) as f64
1657        };
1658        let mut x = Vec::with_capacity(n);
1659        let mut y = Vec::with_capacity(n);
1660        let mut w = Vec::with_capacity(n);
1661        for i in 0..n {
1662            let xi = -2.0 + 4.0 * (i as f64) / ((n - 1) as f64);
1663            let wi: f64 = if xi < 0.0 { 1.0 } else { 9.0 };
1664            let u1 = next_unit().max(1e-12);
1665            let u2 = next_unit();
1666            let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
1667            x.push(xi);
1668            w.push(wi);
1669            y.push(0.4 + (1.3 * xi).sin() + (0.45 / wi.sqrt()) * z);
1670        }
1671        // Every smoothing order, not just the cubic: the order-3 (quintic)
1672        // search has a deeper λ→∞ tail walk (scale shift (2m−1)·log L) and a
1673        // larger residual-d.f. Lipschitz constant, and was the remaining
1674        // effective hang after the order-2 fix (#2300 — the degree-5
1675        // observation-interval node timed out at 1500s). The V‴ endpoint-jet
1676        // enclosure certifies its tail at cube rate, so a uniform budget far
1677        // below the pre-fix eval counts must hold at all orders.
1678        for order in 1..=MAX_ORDER {
1679            let (nodes, ssr_within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
1680            let span = nodes.last().unwrap().x - nodes.first().unwrap().x;
1681            let scale_shift = (2 * order - 1) as f64 * span.ln();
1682            let lo = LOG_LAMBDA_LO + scale_shift;
1683            let hi = LOG_LAMBDA_HI + scale_shift;
1684
1685            let evals = std::cell::Cell::new(0u64);
1686            let last_x = std::cell::Cell::new(f64::NAN);
1687            let budget = 2_000_000u64;
1688            let result = gam_math::score_opt::maximize_score_1d(
1689                lo,
1690                hi,
1691                f64::EPSILON.sqrt(),
1692                |ll| {
1693                    let count = evals.get() + 1;
1694                    evals.set(count);
1695                    last_x.set(ll);
1696                    assert!(
1697                        count <= budget,
1698                        "order-{order} certified scan search exceeded {budget} criterion \
1699                         evaluations (last log-lambda sample {ll:.9}; bracket \
1700                         [{lo:.3}, {hi:.3}]) — non-terminating subdivision reproduced"
1701                    );
1702                    concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order).map(
1703                        |(value, derivative, curvature, _third)| gam_math::score_opt::ScoreJet {
1704                            value,
1705                            derivative,
1706                            curvature,
1707                        },
1708                    )
1709                },
1710                |a, b| concentrated_criterion_enclosure(&nodes, ssr_within, n_obs, a, b, order),
1711            );
1712            match result {
1713                Ok(search) => {
1714                    assert!(
1715                        search.optimum.x.is_finite(),
1716                        "order-{order} search must return a finite optimum"
1717                    );
1718                }
1719                Err(error) => panic!(
1720                    "order-{order} weighted scan search failed after {} evaluations \
1721                     (last x {:.9}): {error:?}",
1722                    evals.get(),
1723                    last_x.get()
1724                ),
1725            }
1726        }
1727    }
1728    /// Value-only diagnostic surface retained for the derivative oracle tests.
1729    fn concentrated_criterion(
1730        nodes: &[PooledNode],
1731        ssr_within: f64,
1732        n_obs: usize,
1733        log_lambda: f64,
1734        order: usize,
1735    ) -> Result<f64, String> {
1736        Ok(concentrated_criterion_jet(nodes, ssr_within, n_obs, log_lambda, order)?.0)
1737    }
1738    use super::*;
1739
1740    #[test]
1741    fn concentrated_score_jet_matches_test_only_differences() {
1742        let x = [0.0, 0.07, 0.19, 0.41, 0.41, 0.68, 1.0, 1.37];
1743        let y = [0.2, -0.4, 0.8, 0.1, 0.35, -0.2, 0.7, 0.15];
1744        let w = [1.0, 2.0, 0.7, 1.4, 0.9, 3.0, 1.2, 0.8];
1745        for order in 1..=MAX_ORDER {
1746            let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pooled data");
1747            for &rho in &[-4.0, -0.3, 2.5] {
1748                let (value, d1, d2, d3) =
1749                    concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
1750                        .expect("analytic score jet");
1751                // Finite differences are deliberately confined to this oracle
1752                // test; production selection uses the analytic sensitivities.
1753                let h = 2.0e-4;
1754                let fm = concentrated_criterion(&nodes, within, n_obs, rho - h, order)
1755                    .expect("left score");
1756                let fp = concentrated_criterion(&nodes, within, n_obs, rho + h, order)
1757                    .expect("right score");
1758                let fm2 = concentrated_criterion(&nodes, within, n_obs, rho - 2.0 * h, order)
1759                    .expect("far left score");
1760                let fp2 = concentrated_criterion(&nodes, within, n_obs, rho + 2.0 * h, order)
1761                    .expect("far right score");
1762                let d1_fd = (fp - fm) / (2.0 * h);
1763                let d2_fd = (fp - 2.0 * value + fm) / (h * h);
1764                let d3_fd = (fp2 - 2.0 * fp + 2.0 * fm - fm2) / (2.0 * h * h * h);
1765                let d1_scale = 1.0 + d1.abs().max(d1_fd.abs());
1766                let d2_scale = 1.0 + d2.abs().max(d2_fd.abs());
1767                let d3_scale = 1.0 + d3.abs().max(d3_fd.abs());
1768                assert!(
1769                    (d1 - d1_fd).abs() <= 2.0e-6 * d1_scale,
1770                    "order={order} rho={rho}: analytic d1={d1}, FD={d1_fd}"
1771                );
1772                assert!(
1773                    (d2 - d2_fd).abs() <= 2.0e-4 * d2_scale,
1774                    "order={order} rho={rho}: analytic d2={d2}, FD={d2_fd}"
1775                );
1776                assert!(
1777                    (d3 - d3_fd).abs() <= 5.0e-3 * d3_scale,
1778                    "order={order} rho={rho}: analytic d3={d3}, FD={d3_fd}"
1779                );
1780            }
1781        }
1782    }
1783
1784    /// #1034 persistence seam: snapshot → JSON → restore must replay the
1785    /// Gaussian bridge bit-for-bit — knot posteriors, off-knot bridge,
1786    /// boundary extrapolation, EDF, and derivative posteriors all compare
1787    /// with exact equality, because every replayed field is either stored
1788    /// verbatim or derived by the fitter's own expressions. Parameterized over
1789    /// the smoothing order so the order-derived state/cov/gain layouts
1790    /// (#1044: m=3 stores 3-wide state, 6-wide upper-tri cov, 9-wide gain) are
1791    /// each round-tripped.
1792    fn round_trip_predict_bit_for_bit(order: usize) {
1793        let n = 60usize;
1794        let x: Vec<f64> = (0..n).map(|i| (i as f64) / (n as f64 - 1.0)).collect();
1795        // Deterministic wiggly response with a tie pair to exercise pooling.
1796        let mut x = x;
1797        x[7] = x[6];
1798        let y: Vec<f64> = x
1799            .iter()
1800            .enumerate()
1801            .map(|(i, &xi)| {
1802                (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
1803            })
1804            .collect();
1805        let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * ((i % 3) as f64)).collect();
1806        let fit = fit_spline_scan(&x, &y, &w, order).expect("scan fit");
1807        assert_eq!(fit.order, order);
1808        // The raw count is retained verbatim (n rows, one tie pair collapses a
1809        // knot but not the count) and drives the recovered deviance (#1046).
1810        assert_eq!(fit.n_obs, n);
1811
1812        let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
1813        let state: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
1814        let restored = SplineScanFit::from_state(&state).expect("restore fit");
1815
1816        assert_eq!(fit.n_obs, restored.n_obs);
1817        assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
1818        assert_eq!(fit.knots, restored.knots);
1819        assert_eq!(fit.mean, restored.mean);
1820        assert_eq!(fit.var, restored.var);
1821        assert_eq!(fit.deriv, restored.deriv);
1822        assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
1823        assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
1824        assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
1825        for t in 0..fit.knots.len() {
1826            match (fit.deriv_at_knot(t), restored.deriv_at_knot(t)) {
1827                (Some((d0, v0)), Some((d1, v1))) => {
1828                    assert!(order >= 2);
1829                    assert_eq!(d0.to_bits(), d1.to_bits());
1830                    assert_eq!(v0.to_bits(), v1.to_bits());
1831                }
1832                (None, None) => assert_eq!(order, 1),
1833                _ => panic!("derivative availability drifted across the persistence seam"),
1834            }
1835        }
1836        // Off-knot bridge, exact knot hit, and both extrapolation sides.
1837        for &xq in &[-0.2, 0.0, 0.013, 0.5, x[6], 0.987, 1.0, 1.3] {
1838            let (m0, v0) = fit.predict(xq).expect("predict original");
1839            let (m1, v1) = restored.predict(xq).expect("predict restored");
1840            assert_eq!(
1841                m0.to_bits(),
1842                m1.to_bits(),
1843                "mean drift at x={xq} (m={order})"
1844            );
1845            assert_eq!(
1846                v0.to_bits(),
1847                v1.to_bits(),
1848                "variance drift at x={xq} (m={order})"
1849            );
1850        }
1851
1852        // Corrupt payloads fail loudly, not inside a later predict.
1853        let mut bad = fit.to_state();
1854        bad.cov.truncate(bad.cov.len() - 1);
1855        SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
1856        let mut bad = fit.to_state();
1857        bad.sigma2 = -1.0;
1858        SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
1859        let mut bad = fit.to_state();
1860        bad.knots[2] = bad.knots[1];
1861        SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
1862    }
1863
1864    #[test]
1865    fn state_snapshot_round_trips_predict_bit_for_bit() {
1866        round_trip_predict_bit_for_bit(2);
1867    }
1868
1869    /// #1044: the order-1 and order-3 layouts round-trip bit-for-bit too.
1870    #[test]
1871    fn state_snapshot_round_trips_predict_bit_for_bit_order1() {
1872        round_trip_predict_bit_for_bit(1);
1873    }
1874
1875    #[test]
1876    fn state_snapshot_round_trips_predict_bit_for_bit_order3() {
1877        round_trip_predict_bit_for_bit(3);
1878    }
1879
1880    /// Dense order-1 (random-walk / linear smoothing spline) posterior of the
1881    /// SAME intrinsic prior the order-1 scan integrates: improper level on
1882    /// `f_0`, increments `f_{t+1}−f_t ~ N(0, q·δ_t)`, observations `y_t` with
1883    /// precision `w_t` (unit σ²). Solve the tridiagonal precision densely and
1884    /// compare to the scan — the exact-equivalence gate for the new m=1 path.
1885    fn dense_rw_truth(x: &[f64], y: &[f64], w: &[f64], log_lambda: f64) -> (Vec<f64>, Vec<f64>) {
1886        let n = x.len();
1887        let q = (-log_lambda).exp();
1888        let mut prec = vec![vec![0.0_f64; n]; n];
1889        let mut rhs = vec![0.0_f64; n];
1890        for t in 0..n {
1891            prec[t][t] += w[t];
1892            rhs[t] += w[t] * y[t];
1893        }
1894        for t in 0..n - 1 {
1895            let p = 1.0 / (q * (x[t + 1] - x[t]));
1896            prec[t][t] += p;
1897            prec[t + 1][t + 1] += p;
1898            prec[t][t + 1] -= p;
1899            prec[t + 1][t] -= p;
1900        }
1901        // Dense inverse via Gauss-Jordan (small n in the test).
1902        let mut aug = prec.clone();
1903        let mut inv = vec![vec![0.0_f64; n]; n];
1904        for i in 0..n {
1905            inv[i][i] = 1.0;
1906        }
1907        for col in 0..n {
1908            let piv = (col..n)
1909                .max_by(|&a, &b| aug[a][col].abs().total_cmp(&aug[b][col].abs()))
1910                .unwrap();
1911            aug.swap(col, piv);
1912            inv.swap(col, piv);
1913            let d = aug[col][col];
1914            for k in 0..n {
1915                aug[col][k] /= d;
1916                inv[col][k] /= d;
1917            }
1918            for r in 0..n {
1919                if r == col {
1920                    continue;
1921                }
1922                let f = aug[r][col];
1923                if f == 0.0 {
1924                    continue;
1925                }
1926                for k in 0..n {
1927                    aug[r][k] -= f * aug[col][k];
1928                    inv[r][k] -= f * inv[col][k];
1929                }
1930            }
1931        }
1932        let mean: Vec<f64> = (0..n)
1933            .map(|i| (0..n).map(|j| inv[i][j] * rhs[j]).sum())
1934            .collect();
1935        let var: Vec<f64> = (0..n).map(|i| inv[i][i]).collect();
1936        (mean, var)
1937    }
1938
1939    /// The order-1 scan must reproduce the dense random-walk posterior exactly
1940    /// (mean, pointwise variance, and the EDF identity tr(S)=Σ w_t·Var_t/σ²) at
1941    /// the scan's own selected λ — the #1034-item-2 correctness gate.
1942    #[test]
1943    fn order_one_scan_matches_dense_random_walk_posterior() {
1944        let n = 30usize;
1945        let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
1946        let y: Vec<f64> = x
1947            .iter()
1948            .enumerate()
1949            .map(|(i, &xi)| 2.0 * xi + 0.4 * (5.0 * xi).sin() + 0.05 * ((i * 13 % 7) as f64 - 3.0))
1950            .collect();
1951        let w = vec![1.0_f64; n];
1952        let fit = fit_spline_scan(&x, &y, &w, 1).expect("order-1 scan fit");
1953        assert_eq!(fit.order, 1);
1954
1955        let (mean, var) = dense_rw_truth(&x, &y, &w, fit.log_lambda);
1956        for t in 0..n {
1957            assert!(
1958                (fit.mean[t] - mean[t]).abs() <= 1e-7 * mean[t].abs().max(1e-3),
1959                "order-1 mean mismatch at {t}: scan={} dense={}",
1960                fit.mean[t],
1961                mean[t]
1962            );
1963            let se_scan = fit.var[t].sqrt();
1964            let se_dense = (var[t] * fit.sigma2).sqrt();
1965            assert!(
1966                (se_scan - se_dense).abs() <= 1e-7 * se_dense.max(1e-12),
1967                "order-1 SE mismatch at {t}: scan={se_scan} dense={se_dense}"
1968            );
1969        }
1970        // EDF identity against the dense posterior variance diagonal.
1971        let dense_edf: f64 = w.iter().zip(var.iter()).map(|(wt, vt)| wt * vt).sum();
1972        assert!(
1973            (fit.edf() - dense_edf).abs() <= 1e-7 * dense_edf.max(1e-12),
1974            "order-1 EDF mismatch: scan={} dense={dense_edf}",
1975            fit.edf()
1976        );
1977        // Order-1 derivative state is structurally absent: Brownian motion has
1978        // no pointwise derivative, so the fit must say so rather than report a
1979        // fabricated known-zero.
1980        assert!(fit.deriv.is_none());
1981        assert!(fit.deriv_at_knot(0).is_none());
1982    }
1983
1984    /// `deviance()` must be the weighted DATA residual sum of squares at the
1985    /// fitted values, not the profiled REML quadratic. For order 1 on
1986    /// `x = (0, 1)`, `y = (0, 1)`, unit weights, λ = 1, the posterior mean is
1987    /// `(1/3, 2/3)`: the data SSE is `2·(1/3)² = 2/9`, while
1988    /// `σ̂²·(n − order) = 1/3` carries an extra `1/9` of process/roughness
1989    /// energy.
1990    #[test]
1991    fn deviance_is_data_sse_not_penalized_quadratic() {
1992        let x = [0.0, 1.0];
1993        let y = [0.0, 1.0];
1994        let w = [1.0, 1.0];
1995        let fit = fit_spline_scan_at(&x, &y, &w, 0.0, None, 1).expect("order-1 fit");
1996        // Self-consistency against a direct recomputation at the fitted values.
1997        let manual: f64 = x
1998            .iter()
1999            .zip(&y)
2000            .zip(&w)
2001            .map(|((&xi, &yi), &wi)| {
2002                let (m, _) = fit.predict(xi).expect("predict at knot");
2003                wi * (yi - m) * (yi - m)
2004            })
2005            .sum();
2006        assert!(
2007            (fit.deviance() - manual).abs() <= 1e-12 * manual.max(1e-300),
2008            "deviance {} != recomputed data SSE {manual}",
2009            fit.deviance()
2010        );
2011        assert!(
2012            (fit.deviance() - 2.0 / 9.0).abs() < 1e-10,
2013            "deviance {} != 2/9",
2014            fit.deviance()
2015        );
2016        // The old proxy is strictly larger: it includes penalty energy.
2017        let reml_quadratic = fit.sigma2 * (fit.n_obs as f64 - fit.order as f64);
2018        assert!(fit.deviance() < reml_quadratic);
2019    }
2020}