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