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 std::cell::RefCell;
57use std::collections::HashMap;
58
59use gam_math::score_opt::{
60    ClosedInterval, DerivativeEnclosure, ScoreJet, ScoreOptimumLocation, ScoreSample,
61    ScoreSearchResult, ScoreValueEnclosure, maximize_score_1d,
62};
63
64/// One pooled (distinct-abscissa) observation node.
65#[derive(Clone, Copy, Debug)]
66struct PooledNode {
67    x: f64,
68    /// Precision-weighted mean of the tied responses.
69    y: f64,
70    /// Total weight of the pooled ties (observation variance is `σ²/w`).
71    w: f64,
72}
73
74/// Search interval for log λ (natural log), generous on both sides.
75const LOG_LAMBDA_LO: f64 = -18.0;
76const LOG_LAMBDA_HI: f64 = 18.0;
77/// Maximum supported smoothing-spline order handled by the fixed-capacity
78/// small-matrix layer. Order `m` penalizes `∫(f^{(m)})²`; the state dimension
79/// is `m`. The exact diffuse leading-block smoother (see the smoother pass)
80/// recovers the `m − 1` partially-diffuse leading nodes for any `m`: `m = 1`
81/// has none, `m = 2` has node 0, `m = 3` has {0, 1}. Order 3 (the quintic
82/// smoothing spline, #1044) is the current cap; bumping it further only needs a
83/// wider `mat_inv` branch and the (already order-general) leading-block solve.
84const MAX_ORDER: usize = 3;
85
86/// Row-major `m × m` matrix stored in a fixed `MAX_ORDER`-capacity buffer; only
87/// the top-left `m × m` block is meaningful. Generalizing the order-2 cubic
88/// scan to order `m ∈ {1, 2, 3}` (#1034 item 2, #1044) keeps the
89/// allocation-free fixed storage of the hot filter loop while letting `m` vary
90/// at runtime.
91type Mat2 = [[f64; MAX_ORDER]; MAX_ORDER];
92type Vec2 = [f64; MAX_ORDER];
93
94/// A nearest-rounded representative carried beside an outward interval for the
95/// exact-real result of the same arithmetic expression.
96///
97/// Every elementary operation rounds both interval endpoints away from the
98/// result. `exp` and `ln` use `gam-math`'s range-reduced, directed Taylor
99/// enclosures; all other operations are IEEE basic operations. No platform
100/// libm accuracy contract is assumed. This is interval arithmetic, not a
101/// tolerance: widening is entirely source-derived from the operations the
102/// filter actually performs.
103#[derive(Clone, Copy, Debug, PartialEq)]
104struct Ball {
105    value: f64,
106    lo: f64,
107    hi: f64,
108}
109
110impl Ball {
111    const ZERO: Self = Self {
112        value: 0.0,
113        lo: 0.0,
114        hi: 0.0,
115    };
116    const ONE: Self = Self {
117        value: 1.0,
118        lo: 1.0,
119        hi: 1.0,
120    };
121
122    #[inline]
123    fn exact(value: f64) -> Self {
124        Self {
125            value,
126            lo: value,
127            hi: value,
128        }
129    }
130
131    /// Attach an independently certified exact-real enclosure to a rounded
132    /// representative.
133    #[inline]
134    fn certified(value: f64, enclosure: ClosedInterval) -> Self {
135        Self {
136            value,
137            lo: enclosure.lo,
138            hi: enclosure.hi,
139        }
140    }
141
142    #[inline]
143    fn add(self, other: Self) -> Self {
144        let enclosure = self.interval().add(other.interval());
145        Self {
146            value: self.value + other.value,
147            lo: enclosure.lo,
148            hi: enclosure.hi,
149        }
150    }
151
152    #[inline]
153    fn neg(self) -> Self {
154        Self {
155            value: -self.value,
156            lo: -self.hi,
157            hi: -self.lo,
158        }
159    }
160
161    #[inline]
162    fn sub(self, other: Self) -> Self {
163        self.add(other.neg())
164    }
165
166    #[inline]
167    fn mul(self, other: Self) -> Self {
168        let enclosure = self.interval().mul(other.interval());
169        Self {
170            value: self.value * other.value,
171            lo: enclosure.lo,
172            hi: enclosure.hi,
173        }
174    }
175
176    #[inline]
177    fn scale(self, factor: f64) -> Self {
178        self.mul(Self::exact(factor))
179    }
180
181    /// Division after the caller has proved the denominator interval positive.
182    #[inline]
183    fn div_positive(self, denominator: Self) -> Self {
184        assert!(
185            denominator.is_finite() && denominator.lo > 0.0,
186            "Ball::div_positive requires a finite, strictly positive denominator interval, got \
187             value={} lo={} hi={}",
188            denominator.value,
189            denominator.lo,
190            denominator.hi
191        );
192        let reciprocal = Self {
193            value: 1.0 / denominator.value,
194            lo: if denominator.hi == 1.0 {
195                1.0
196            } else {
197                next_down_ball(1.0 / denominator.hi)
198            },
199            hi: if denominator.lo == 1.0 {
200                1.0
201            } else {
202                next_up_ball(1.0 / denominator.lo)
203            },
204        };
205        self.mul(reciprocal)
206    }
207
208    #[inline]
209    fn ln_positive(self) -> Self {
210        assert!(
211            self.is_finite() && self.lo > 0.0,
212            "Ball::ln_positive requires a finite, strictly positive interval, got value={} lo={} \
213             hi={}",
214            self.value,
215            self.lo,
216            self.hi
217        );
218        let lo = gam_math::score_opt::certified_ln_positive(self.lo)
219            .expect("positive finite interval lower endpoint");
220        let hi = gam_math::score_opt::certified_ln_positive(self.hi)
221            .expect("positive finite interval upper endpoint");
222        Self {
223            value: self.value.ln(),
224            lo: lo.lo,
225            hi: hi.hi,
226        }
227    }
228
229    #[inline]
230    fn square(self) -> Self {
231        let hi_abs = self.lo.abs().max(self.hi.abs());
232        let lo_abs = if self.lo <= 0.0 && self.hi >= 0.0 {
233            0.0
234        } else {
235            self.lo.abs().min(self.hi.abs())
236        };
237        Self {
238            value: self.value * self.value,
239            lo: if lo_abs == 0.0 {
240                0.0
241            } else if lo_abs == 1.0 {
242                1.0
243            } else {
244                next_down_ball(lo_abs * lo_abs)
245            },
246            hi: if hi_abs == 0.0 || hi_abs == 1.0 {
247                hi_abs
248            } else {
249                next_up_ball(hi_abs * hi_abs)
250            },
251        }
252    }
253
254    #[inline]
255    fn is_finite(self) -> bool {
256        self.value.is_finite() && self.lo.is_finite() && self.hi.is_finite() && self.lo <= self.hi
257    }
258
259    #[inline]
260    fn interval(self) -> ClosedInterval {
261        ClosedInterval::new(self.lo, self.hi)
262    }
263
264    #[inline]
265    fn forward_error(self) -> f64 {
266        // One outward successor covers the subtraction roundoff even when the
267        // exact distance is subnormal and the rounded subtraction is zero.
268        // Every Ball primitive already preserves exact structural zero or
269        // moves each inexact endpoint by one representable value.
270        next_up_ball(
271            (self.value - self.lo)
272                .abs()
273                .max((self.hi - self.value).abs()),
274        )
275    }
276}
277
278type BallMat = [[Ball; MAX_ORDER]; MAX_ORDER];
279type BallVec = [Ball; MAX_ORDER];
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
282pub enum SplineInnovationKind {
283    Diffuse,
284    Proper,
285}
286
287/// Failure to construct a numerical proof for one spline-score evaluation.
288#[derive(Clone, Debug, PartialEq)]
289pub enum SplineScoreProofError {
290    /// Directed roundoff made an innovation interval include zero, so division
291    /// by that innovation cannot be certified.
292    InnovationContainsZero {
293        node: usize,
294        kind: SplineInnovationKind,
295        enclosure: ClosedInterval,
296    },
297    /// An innovation interval was entirely nonpositive. This indicates a
298    /// violated covariance invariant rather than loss of numerical resolution.
299    NonPositiveInnovation {
300        node: usize,
301        kind: SplineInnovationKind,
302        enclosure: ClosedInterval,
303    },
304    NonPositiveProfileResidual {
305        enclosure: ClosedInterval,
306    },
307    InvalidArithmetic {
308        context: &'static str,
309    },
310    /// A certified filter accumulator left the finite range, reported with the
311    /// node it happened at and the enclosure that did it.
312    ///
313    /// The `InvalidArithmetic{"diffuse filter accumulator"}` refusal above named
314    /// a PHASE and nothing else: not which of the eight accumulators diverged,
315    /// not at which of the ~180 nodes, and not how wide it was when it went.
316    /// Diagnosing #2614 from it required adding a print, and the two repairs
317    /// attempted before that print existed were both aimed at the wrong term —
318    /// each exact, each landing a byte-identical failure. A verdict has to
319    /// carry the quantity it was decided against (#2465), and this is that
320    /// quantity: the first accumulator to leave the finite range, where, and
321    /// the `q = e^{−ρ}` it was evaluated at.
322    AccumulatorDiverged {
323        node: usize,
324        n_proper: usize,
325        accumulator: &'static str,
326        value: f64,
327        lo: f64,
328        hi: f64,
329        q_value: f64,
330        /// This node's own contribution to that accumulator. A finite running
331        /// sum plus an infinite total means the CONTRIBUTION diverged, so this
332        /// is the term to look at, not the sum.
333        contribution_lo: f64,
334        contribution_hi: f64,
335        /// The third covariance-derivative entry `F'''` every third-order chain
336        /// rule on this path divides by `F`. Reported alongside so a wide
337        /// contribution can be told from a wide INPUT: if `F'''` is already
338        /// unbounded the covariance jet is at fault, and if it is tight while
339        /// the contribution is not, the cancellation in the chain rule is.
340        f_star_d3_lo: f64,
341        f_star_d3_hi: f64,
342        /// The same entry AFTER this node's measurement update. `F'''` above is
343        /// the PREDICTED value, so the pair localises the growth to one of the
344        /// filter's two steps: an updated entry much narrower than the
345        /// predicted one means the update contracts and the PREDICTION is
346        /// growing it, and the reverse means the update is.
347        updated_d3_lo: f64,
348        updated_d3_hi: f64,
349    },
350    InvalidInput(String),
351    MissingEndpointCertificate {
352        log_lambda: f64,
353    },
354    GlobalValueOrderingUnresolved {
355        maximum_excess: f64,
356        comparison_resolution: f64,
357    },
358    OptimumKktUncertified {
359        location: ScoreOptimumLocation,
360        bracket: ClosedInterval,
361        derivative: ClosedInterval,
362        curvature: ClosedInterval,
363    },
364    Search(String),
365    Computation(String),
366}
367
368impl std::fmt::Display for SplineScoreProofError {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        match self {
371            Self::InnovationContainsZero {
372                node,
373                kind,
374                enclosure,
375            } => write!(
376                f,
377                "spline scan: {kind:?} innovation ball at node {node} contains zero: {enclosure:?}"
378            ),
379            Self::NonPositiveInnovation {
380                node,
381                kind,
382                enclosure,
383            } => write!(
384                f,
385                "spline scan: {kind:?} innovation ball at node {node} is nonpositive: {enclosure:?}"
386            ),
387            Self::NonPositiveProfileResidual { enclosure } => write!(
388                f,
389                "spline scan: profiled residual ball is not strictly positive: {enclosure:?}"
390            ),
391            Self::InvalidArithmetic { context } => {
392                write!(
393                    f,
394                    "spline scan: non-finite interval arithmetic in {context}"
395                )
396            }
397            Self::AccumulatorDiverged {
398                node,
399                n_proper,
400                accumulator,
401                value,
402                lo,
403                hi,
404                q_value,
405                contribution_lo,
406                contribution_hi,
407                f_star_d3_lo,
408                f_star_d3_hi,
409                updated_d3_lo,
410                updated_d3_hi,
411            } => {
412                write!(
413                    f,
414                    "spline scan: certified accumulator `{accumulator}` left the finite range at \
415                     node {node} (proper innovations so far {n_proper}, q = {q_value:.6e}): \
416                     value {value:.9e} in [{lo:.9e}, {hi:.9e}]; this node's contribution was \
417                     [{contribution_lo:.9e}, {contribution_hi:.9e}], predicted F''' was \
418                     [{f_star_d3_lo:.9e}, {f_star_d3_hi:.9e}] and updated F''' was \
419                     [{updated_d3_lo:.9e}, {updated_d3_hi:.9e}]"
420                )
421            }
422            Self::InvalidInput(reason) => f.write_str(reason),
423            Self::MissingEndpointCertificate { log_lambda } => write!(
424                f,
425                "spline scan: certified search requested an uncached endpoint {log_lambda}"
426            ),
427            Self::GlobalValueOrderingUnresolved {
428                maximum_excess,
429                comparison_resolution,
430            } => write!(
431                f,
432                "spline scan: the selected REML representative can trail another exact \
433                 candidate by {maximum_excess}, beyond the certified comparison resolution \
434                 {comparison_resolution}"
435            ),
436            Self::OptimumKktUncertified {
437                location,
438                bracket,
439                derivative,
440                curvature,
441            } => write!(
442                f,
443                "spline scan: exact-real REML KKT condition is uncertified for {location:?} \
444                 on {bracket:?} (derivative {derivative:?}, curvature {curvature:?})"
445            ),
446            Self::Search(reason) => {
447                write!(f, "spline scan: REML stationary isolation failed: {reason}")
448            }
449            Self::Computation(reason) => f.write_str(reason),
450        }
451    }
452}
453
454impl std::error::Error for SplineScoreProofError {}
455
456impl From<String> for SplineScoreProofError {
457    fn from(reason: String) -> Self {
458        Self::Computation(reason)
459    }
460}
461
462#[inline]
463fn require_positive_innovation(
464    node: usize,
465    kind: SplineInnovationKind,
466    innovation: Ball,
467) -> Result<(), SplineScoreProofError> {
468    if !innovation.is_finite() {
469        return Err(SplineScoreProofError::InvalidArithmetic {
470            context: "innovation recurrence",
471        });
472    }
473    let enclosure = innovation.interval();
474    if innovation.lo <= 0.0 && innovation.hi >= 0.0 {
475        Err(SplineScoreProofError::InnovationContainsZero {
476            node,
477            kind,
478            enclosure,
479        })
480    } else if innovation.hi < 0.0 {
481        Err(SplineScoreProofError::NonPositiveInnovation {
482            node,
483            kind,
484            enclosure,
485        })
486    } else {
487        Ok(())
488    }
489}
490
491#[inline]
492fn next_down_ball(value: f64) -> f64 {
493    if value.is_nan() || value == f64::NEG_INFINITY {
494        return value;
495    }
496    if value == 0.0 {
497        return -f64::from_bits(1);
498    }
499    let bits = value.to_bits();
500    f64::from_bits(if value > 0.0 { bits - 1 } else { bits + 1 })
501}
502
503#[inline]
504fn next_up_ball(value: f64) -> f64 {
505    if value.is_nan() || value == f64::INFINITY {
506        return value;
507    }
508    if value == 0.0 {
509        return f64::from_bits(1);
510    }
511    let bits = value.to_bits();
512    f64::from_bits(if value > 0.0 { bits + 1 } else { bits - 1 })
513}
514
515#[inline]
516fn mat_mul(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
517    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
518    for i in 0..m {
519        for j in 0..m {
520            let mut acc = 0.0;
521            for k in 0..m {
522                acc += a[i][k] * b[k][j];
523            }
524            c[i][j] = acc;
525        }
526    }
527    c
528}
529
530#[inline]
531fn mat_t(a: &Mat2, m: usize) -> Mat2 {
532    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
533    for i in 0..m {
534        for j in 0..m {
535            c[i][j] = a[j][i];
536        }
537    }
538    c
539}
540
541#[inline]
542fn mat_vec(a: &Mat2, v: &Vec2, m: usize) -> Vec2 {
543    let mut out = [0.0; MAX_ORDER];
544    for i in 0..m {
545        let mut acc = 0.0;
546        for j in 0..m {
547            acc += a[i][j] * v[j];
548        }
549        out[i] = acc;
550    }
551    out
552}
553
554#[inline]
555fn mat_add(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
556    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
557    for i in 0..m {
558        for j in 0..m {
559            c[i][j] = a[i][j] + b[i][j];
560        }
561    }
562    c
563}
564
565#[inline]
566fn mat_sub(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
567    let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
568    for i in 0..m {
569        for j in 0..m {
570            c[i][j] = a[i][j] - b[i][j];
571        }
572    }
573    c
574}
575
576/// Inverse of an `m × m` (`m ∈ {1, 2, 3}`) with a hard singularity error.
577/// Closed-form cofactor inverses keep the hot-loop arithmetic exact and
578/// branch-free; order 3 is the quintic smoother's state dimension (#1044).
579fn mat_inv(a: &Mat2, m: usize, what: &str) -> Result<Mat2, String> {
580    let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
581    match m {
582        1 => {
583            let d = a[0][0];
584            if !(d.is_finite() && d.abs() > 0.0) {
585                return Err(format!("spline scan: singular 1x1 in {what} (a00={d})"));
586            }
587            out[0][0] = 1.0 / d;
588        }
589        2 => {
590            let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
591            if !(det.is_finite() && det.abs() > 0.0) {
592                return Err(format!("spline scan: singular 2x2 in {what} (det={det})"));
593            }
594            out[0][0] = a[1][1] / det;
595            out[0][1] = -a[0][1] / det;
596            out[1][0] = -a[1][0] / det;
597            out[1][1] = a[0][0] / det;
598        }
599        3 => {
600            // Cofactor / adjugate inverse. Cofactors of the 2×2 minors:
601            let c00 = a[1][1] * a[2][2] - a[1][2] * a[2][1];
602            let c01 = a[1][2] * a[2][0] - a[1][0] * a[2][2];
603            let c02 = a[1][0] * a[2][1] - a[1][1] * a[2][0];
604            let det = a[0][0] * c00 + a[0][1] * c01 + a[0][2] * c02;
605            if !(det.is_finite() && det.abs() > 0.0) {
606                return Err(format!("spline scan: singular 3x3 in {what} (det={det})"));
607            }
608            let inv_det = 1.0 / det;
609            // inv = adj/det = (cofactor matrix)ᵀ / det.
610            out[0][0] = c00 * inv_det;
611            out[0][1] = (a[0][2] * a[2][1] - a[0][1] * a[2][2]) * inv_det;
612            out[0][2] = (a[0][1] * a[1][2] - a[0][2] * a[1][1]) * inv_det;
613            out[1][0] = c01 * inv_det;
614            out[1][1] = (a[0][0] * a[2][2] - a[0][2] * a[2][0]) * inv_det;
615            out[1][2] = (a[0][2] * a[1][0] - a[0][0] * a[1][2]) * inv_det;
616            out[2][0] = c02 * inv_det;
617            out[2][1] = (a[0][1] * a[2][0] - a[0][0] * a[2][1]) * inv_det;
618            out[2][2] = (a[0][0] * a[1][1] - a[0][1] * a[1][0]) * inv_det;
619        }
620        _ => return Err(format!("spline scan: unsupported order {m} in {what}")),
621    }
622    Ok(out)
623}
624
625/// Inverse of a general dense `d × d` SPD matrix via Gauss–Jordan elimination
626/// with partial pivoting, symmetric diagonal (Jacobi) equilibration, and one
627/// iterative-refinement step. Used once per fit by the leading-block diffuse
628/// smoother (dimension `(order−1)·order ≤ 6`), so clarity over speed — it is
629/// NOT on the hot REML grid path (that runs only `run_filter`).
630///
631/// Equilibration matters at order `m ≥ 3`: the IWP process noise `Q(δ)` scales
632/// the `f^{(k)}` state components by `δ^{2m−1}` down to `δ`, so its inverse
633/// `(qQ)⁻¹` — and hence the leading-block precision `Λ` — spans many orders of
634/// magnitude (the f-component carries the `O(w)` observation term, the
635/// high-derivative components carry `O(1/(qδ^{2m−1}))` penalty mass). A bare
636/// Gauss–Jordan inverse of such a `Λ` loses `≈ ε·κ(Λ)` digits, which at heavy
637/// smoothing (small `q`) would corrupt the quintic's leading smoothed nodes.
638/// Rescaling to unit diagonal (`Λ̃ = SΛS`, `s_i = 1/√Λ_ii`) collapses that
639/// scale disparity before the elimination, then `Λ⁻¹ = S Λ̃⁻¹ S`.
640fn dense_spd_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
641    let d = a.len();
642    // Jacobi equilibration scale s_i = 1/√Λ_ii (Λ SPD ⇒ Λ_ii > 0).
643    let s: Vec<f64> = (0..d)
644        .map(|i| {
645            let dii = a[i][i];
646            if dii.is_finite() && dii > 0.0 {
647                1.0 / dii.sqrt()
648            } else {
649                1.0
650            }
651        })
652        .collect();
653    let a_s: Vec<Vec<f64>> = (0..d)
654        .map(|i| (0..d).map(|j| s[i] * a[i][j] * s[j]).collect())
655        .collect();
656    // Gauss–Jordan inverse of the equilibrated matrix.
657    let mut inv_s = gauss_jordan_inverse(&a_s, what)?;
658    // One iterative-refinement step against the equilibrated system:
659    // X ← X + X·(I − Λ̃·X), reducing the residual to near machine precision.
660    let mut resid = vec![vec![0.0_f64; d]; d]; // R = I − Λ̃·X
661    for i in 0..d {
662        for j in 0..d {
663            let mut ax = 0.0;
664            for k in 0..d {
665                ax += a_s[i][k] * inv_s[k][j];
666            }
667            resid[i][j] = f64::from(u8::from(i == j)) - ax;
668        }
669    }
670    let mut delta = vec![vec![0.0_f64; d]; d]; // ΔX = X·R
671    for i in 0..d {
672        for j in 0..d {
673            let mut acc = 0.0;
674            for k in 0..d {
675                acc += inv_s[i][k] * resid[k][j];
676            }
677            delta[i][j] = acc;
678        }
679    }
680    for i in 0..d {
681        for j in 0..d {
682            inv_s[i][j] += delta[i][j];
683        }
684    }
685    // Un-equilibrate: Λ⁻¹ = S·Λ̃⁻¹·S.
686    Ok((0..d)
687        .map(|i| (0..d).map(|j| s[i] * inv_s[i][j] * s[j]).collect())
688        .collect())
689}
690
691/// Gauss–Jordan inverse with partial pivoting (helper for `dense_spd_inverse`).
692fn gauss_jordan_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
693    let d = a.len();
694    let mut aug = a.to_vec();
695    let mut inv = vec![vec![0.0_f64; d]; d];
696    for i in 0..d {
697        inv[i][i] = 1.0;
698    }
699    for col in 0..d {
700        let piv = (col..d)
701            .max_by(|&i, &j| aug[i][col].abs().total_cmp(&aug[j][col].abs()))
702            .ok_or_else(|| {
703                format!("spline scan: no pivot candidate in column {col} of {d} in {what}")
704            })?;
705        let p = aug[piv][col];
706        if !(p.is_finite() && p.abs() > 0.0) {
707            return Err(format!(
708                "spline scan: singular {d}x{d} in {what} (pivot={p})"
709            ));
710        }
711        aug.swap(col, piv);
712        inv.swap(col, piv);
713        let d_piv = aug[col][col];
714        for k in 0..d {
715            aug[col][k] /= d_piv;
716            inv[col][k] /= d_piv;
717        }
718        for r in 0..d {
719            if r == col {
720                continue;
721            }
722            let f = aug[r][col];
723            if f == 0.0 {
724                continue;
725            }
726            for k in 0..d {
727                aug[r][k] -= f * aug[col][k];
728                inv[r][k] -= f * inv[col][k];
729            }
730        }
731    }
732    Ok(inv)
733}
734
735/// Factorials `k!` for `k ≤ 2·MAX_ORDER` — the only ones the order-`m`
736/// transition and process-noise formulas reference.
737#[inline]
738fn factorial(k: usize) -> f64 {
739    (1..=k).map(|v| v as f64).product::<f64>().max(1.0)
740}
741
742/// Transition `F(δ) = exp(δ·A)` of the `m`-th order integrated Wiener process,
743/// `A` the nilpotent shift: `F[i][j] = δ^{j−i}/(j−i)!` for `j ≥ i`, else 0.
744/// `m = 1 ⇒ [[1]]`; `m = 2 ⇒ [[1, δ], [0, 1]]` (the cubic case, unchanged).
745#[inline]
746fn transition(delta: f64, m: usize) -> Mat2 {
747    let mut f = [[0.0; MAX_ORDER]; MAX_ORDER];
748    for i in 0..m {
749        for j in i..m {
750            f[i][j] = delta.powi((j - i) as i32) / factorial(j - i);
751        }
752    }
753    f
754}
755
756/// Process noise `Q(δ) = ∫₀^δ e^{As} b bᵀ e^{Aᵀs} ds` (`b = e_{m−1}`) of the
757/// `m`-th order IWP at unit `q`, scaled by `q`:
758/// `Q[i][j] = q · δ^{2m−1−i−j} / ((m−1−i)! (m−1−j)! (2m−1−i−j))`.
759/// `m = 1 ⇒ [[q·δ]]`; `m = 2 ⇒ [[q·δ³/3, q·δ²/2], [q·δ²/2, q·δ]]` (unchanged).
760#[inline]
761fn process_noise(delta: f64, q: f64, m: usize) -> Mat2 {
762    let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
763    for i in 0..m {
764        for j in 0..m {
765            let p = 2 * m - 1 - i - j;
766            out[i][j] = q * delta.powi(p as i32)
767                / (factorial(m - 1 - i) * factorial(m - 1 - j) * (p as f64));
768        }
769    }
770    out
771}
772
773/// Symmetrize in place against drift from the rank-one update arithmetic.
774#[inline]
775fn symmetrize(a: &mut Mat2, m: usize) {
776    for i in 0..m {
777        for j in (i + 1)..m {
778            let off = 0.5 * (a[i][j] + a[j][i]);
779            a[i][j] = off;
780            a[j][i] = off;
781        }
782    }
783}
784
785#[inline]
786fn ball_mat_mul(a: &BallMat, b: &BallMat, m: usize) -> BallMat {
787    let mut c = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
788    for i in 0..m {
789        for j in 0..m {
790            let mut acc = Ball::ZERO;
791            for k in 0..m {
792                acc = acc.add(a[i][k].mul(b[k][j]));
793            }
794            c[i][j] = acc;
795        }
796    }
797    c
798}
799
800#[inline]
801fn ball_mat_t(a: &BallMat, m: usize) -> BallMat {
802    let mut c = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
803    for i in 0..m {
804        for j in 0..m {
805            c[i][j] = a[j][i];
806        }
807    }
808    c
809}
810
811#[inline]
812fn ball_mat_vec(a: &BallMat, v: &BallVec, m: usize) -> BallVec {
813    let mut out = [Ball::ZERO; MAX_ORDER];
814    for i in 0..m {
815        let mut acc = Ball::ZERO;
816        for j in 0..m {
817            acc = acc.add(a[i][j].mul(v[j]));
818        }
819        out[i] = acc;
820    }
821    out
822}
823
824#[inline]
825fn ball_mat_add(a: &BallMat, b: &BallMat, m: usize) -> BallMat {
826    let mut c = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
827    for i in 0..m {
828        for j in 0..m {
829            c[i][j] = a[i][j].add(b[i][j]);
830        }
831    }
832    c
833}
834
835#[inline]
836fn ball_mat_sub(a: &BallMat, b: &BallMat, m: usize) -> BallMat {
837    let mut c = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
838    for i in 0..m {
839        for j in 0..m {
840            c[i][j] = a[i][j].sub(b[i][j]);
841        }
842    }
843    c
844}
845
846#[inline]
847fn ball_transition(delta: Ball, m: usize) -> BallMat {
848    let mut f = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
849    for i in 0..m {
850        let mut power = Ball::ONE;
851        for j in i..m {
852            if j > i {
853                power = power.mul(delta);
854            }
855            f[i][j] = power.div_positive(Ball::exact(factorial(j - i)));
856        }
857    }
858    f
859}
860
861#[inline]
862fn ball_unit_process_noise(delta: Ball, m: usize) -> BallMat {
863    let mut powers = [Ball::ONE; 2 * MAX_ORDER];
864    for exponent in 1..powers.len() {
865        powers[exponent] = powers[exponent - 1].mul(delta);
866    }
867    let mut out = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
868    for i in 0..m {
869        for j in 0..m {
870            let exponent = 2 * m - 1 - i - j;
871            let denominator = factorial(m - 1 - i) * factorial(m - 1 - j) * exponent as f64;
872            out[i][j] = powers[exponent].div_positive(Ball::exact(denominator));
873        }
874    }
875    out
876}
877
878/// Taylor-model decomposition of one process-noise injection.
879///
880/// At a fixed certified score evaluation, `q` is one exact real number in its
881/// ball, shared by every node. Write
882///
883/// ```text
884/// q = q₀ + r_q θ_q,       θ_q ∈ [-1, 1],
885/// Q_ij = c₀,ij + ε_ij,
886/// q Q_ij = q₀ c₀,ij + (r_q c₀,ij) θ_q + q ε_ij.
887/// ```
888///
889/// The first term is the zonotope centre, the second is one distinguished
890/// generator accumulated across the whole recursion, and only `q ε` plus the
891/// floating-point error in forming the first two coefficients is an
892/// independent remainder. Treating the full interval `qQ` as a fresh constant
893/// at every node discards the identity of `θ_q`; after enough Riccati steps its
894/// box hull is wider than the score resolution even though the signed
895/// recursion is contracting.
896struct ProcessNoiseTaylor {
897    /// Ordinary interval enclosure used by the independent componentwise path.
898    enclosure: BallMat,
899    /// Centre plus deterministic-arithmetic/nonlinear remainder, with the
900    /// first-order common-`q` uncertainty removed.
901    constant: [Ball; COVARIANCE_D1_DIM],
902    /// Coefficient added to the one common normalized `q` generator.
903    shared_q: [f64; COVARIANCE_D1_DIM],
904}
905
906#[inline]
907fn ball_process_noise_taylor(delta: Ball, q: Ball, m: usize) -> ProcessNoiseTaylor {
908    let unit = ball_unit_process_noise(delta, m);
909    let q_radius = ball_radius_about_value(q);
910    let mut enclosure = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
911    let mut constant = [Ball::ZERO; COVARIANCE_D1_DIM];
912    let mut shared_q = [0.0_f64; COVARIANCE_D1_DIM];
913
914    for i in 0..m {
915        for j in 0..m {
916            let index = i * m + j;
917            let coefficient = unit[i][j];
918            enclosure[i][j] = q.mul(coefficient);
919
920            let coefficient_center = Ball::exact(coefficient.value);
921            let center_product = Ball::exact(q.value).mul(coefficient_center);
922            let shared_product = Ball::exact(q_radius).mul(coefficient_center);
923
924            // `q·(c-c₀)` is the nonlinear/deterministic interval remainder.
925            // The other two terms charge rounding of the stored centre and
926            // shared-generator coefficients. The latter is multiplied by an
927            // unknown `θ_q`, so its signed error is symmetrized.
928            let coefficient_error = coefficient.sub(coefficient_center);
929            let center_error = center_product.sub(Ball::exact(center_product.value));
930            let shared_error = shared_product.sub(Ball::exact(shared_product.value));
931            let shared_error_radius = ball_radius_about_value(shared_error);
932            let shared_error_symmetric = Ball {
933                value: 0.0,
934                lo: -shared_error_radius,
935                hi: shared_error_radius,
936            };
937            let remainder = q
938                .mul(coefficient_error)
939                .add(center_error)
940                .add(shared_error_symmetric);
941
942            constant[index] = Ball::exact(center_product.value).add(remainder);
943            shared_q[index] = shared_product.value;
944        }
945    }
946
947    ProcessNoiseTaylor {
948        enclosure,
949        constant,
950        shared_q,
951    }
952}
953
954#[inline]
955fn ball_symmetrize(a: &mut BallMat, m: usize) {
956    for i in 0..m {
957        for j in (i + 1)..m {
958            let off = a[i][j].add(a[j][i]).scale(0.5);
959            a[i][j] = off;
960            a[j][i] = off;
961        }
962    }
963}
964
965/// `A = I − K e₀ᵀ`, built so its `(0,0)` entry is never a subtraction (#2614).
966///
967/// The expanded per-entry form of the congruence `A X Aᵀ` evaluates
968/// `X[0][0]·(1 − 2K₀ + K₀²)` — that is `X[0][0]·(1 − K₀)²` written as three
969/// terms. In the saturated regime `K₀ = P₀₀/F → 1`, so it is `1 − 1` twice
970/// over: the value collapses and the interval width does not. Measured
971/// consequence: the third covariance-derivative entry reached an enclosure of
972/// `+/-4.1e247` by node 62, growing about `10^4` per node, while `d1` and `d2`
973/// — which carry fewer such terms — stayed clean.
974///
975/// The identity that removes it is exact and involves no subtraction:
976///
977/// ```text
978///   A[0][0] = 1 − K₀ = 1 − P₀₀/F = (F − P₀₀)/F = R/F
979/// ```
980///
981/// since `F = P₀₀ + R` by construction. So that entry is `r·inv_f` directly.
982/// Away from the first column `A` is the identity; below the diagonal in the
983/// first column it is `−K_i`. Neither cancels, so building `A` and multiplying
984/// is ordinary arithmetic on well-conditioned entries.
985#[inline]
986fn ball_update_operator(gain: &BallVec, a_diag_zero: Ball, order: usize) -> BallMat {
987    let mut a = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
988    for (i, row) in a.iter_mut().enumerate().take(order) {
989        row[i] = Ball::ONE;
990    }
991    a[0][0] = a_diag_zero;
992    for i in 1..order {
993        a[i][0] = gain[i].neg();
994    }
995    a
996}
997
998/// `A⁽ᵏ⁾ = −K⁽ᵏ⁾ e₀ᵀ`, the ρ-derivative of [`ball_update_operator`].
999///
1000/// The identity part differentiates away and only the first column survives, so
1001/// there is no cancelling entry here at all.
1002#[inline]
1003fn ball_update_operator_derivative(gain_jet: &BallVec, order: usize) -> BallMat {
1004    let mut a = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
1005    for i in 0..order {
1006        a[i][0] = gain_jet[i].neg();
1007    }
1008    a
1009}
1010
1011/// `L · X · Rᵀ` for `Ball` matrices.
1012#[inline]
1013fn ball_congruence(l: &BallMat, x: &BallMat, r_side: &BallMat, order: usize) -> BallMat {
1014    ball_mat_mul(
1015        &ball_mat_mul(l, x, order),
1016        &ball_mat_t(r_side, order),
1017        order,
1018    )
1019}
1020
1021/// Intersect the proper innovation variance with its exact lower bound `R_t`.
1022///
1023/// `F̃_t = H P*_t Hᵀ + R_t`, and `P*` is a covariance, so `H P* Hᵀ ≥ 0` exactly
1024/// and therefore `F̃_t ≥ R_t > 0`. The componentwise interval evaluation of `P*`
1025/// forgets that: after enough rank-one updates its diagonal enclosure widens,
1026/// and once `P*[0][0].lo` drops below zero the enclosure of `F̃` reaches down
1027/// toward zero even though the true value cannot. `inv_f = 1/F̃` then has an
1028/// enclosure reaching `+∞` — and EVERY gain, every `log F̃`, and every one of
1029/// the three derivative recursions multiplies by it.
1030///
1031/// That is the observed failure, on three surfaces: `gam-solve`'s own
1032/// `spline_scan` tests refuse with `InvalidArithmetic{"diffuse filter
1033/// accumulator"}` carrying `[-inf, +inf]` derivative enclosures around exact
1034/// centre values; `gam-models`' `spline_scan_payload_round_trips_and_validates`
1035/// dies on its first line; and the Python surface reports `IntegrationError:
1036/// spline scan: non-finite interval arithmetic in proper covariance PSD
1037/// intersection` (#2614, #2616).
1038///
1039/// Restoring this bound is the same move [`intersect_proper_covariance_psd`]
1040/// already makes for the covariance diagonal, and its own comment states the
1041/// principle: "This intersection restores proof information supplied by the
1042/// statistical model; it is not a numerical tolerance." Here the information is
1043/// stronger, because `R_t = 1/w_t` is an exact input rather than a computed
1044/// quantity — the observation variance is data, not arithmetic.
1045///
1046/// The floor is capped at the ball's own `value` and `hi` so the result stays a
1047/// well-formed enclosure (`lo ≤ value ≤ hi`) even if the centre has itself gone
1048/// non-positive; an inconsistent enclosure is left inconsistent for the finite
1049/// checks downstream to reject, rather than being papered into consistency here.
1050#[inline]
1051fn intersect_innovation_above_observation_variance(
1052    innovation: &mut Ball,
1053    observation_variance: Ball,
1054) {
1055    let floor = observation_variance
1056        .lo
1057        .min(innovation.value)
1058        .min(innovation.hi);
1059    if floor.is_finite() && innovation.lo < floor {
1060        innovation.lo = floor;
1061    }
1062}
1063
1064/// Directed square root of a nonnegative enclosure.
1065#[inline]
1066fn ball_sqrt(value: Ball) -> Option<Ball> {
1067    if !(value.lo >= 0.0 && value.hi.is_finite() && value.lo <= value.hi) {
1068        return None;
1069    }
1070    Some(Ball {
1071        value: value.value.max(0.0).sqrt(),
1072        lo: next_down_ball(value.lo.sqrt()).max(0.0),
1073        hi: next_up_ball(value.hi.sqrt()),
1074    })
1075}
1076
1077/// Lower-triangular Cholesky factor `L` with `P = L Lᵀ`, in directed arithmetic.
1078///
1079/// `None` when a pivot enclosure fails to be strictly positive, which is a
1080/// statement about the enclosure and not about the matrix — the caller treats a
1081/// missing factor as an absence of evidence, never as a refusal.
1082fn ball_cholesky(covariance: &BallMat, order: usize) -> Option<BallMat> {
1083    let mut factor = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
1084    for i in 0..order {
1085        for j in 0..=i {
1086            let mut accumulator = covariance[i][j];
1087            for k in 0..j {
1088                accumulator = accumulator.sub(factor[i][k].mul(factor[j][k]));
1089            }
1090            if i == j {
1091                factor[i][j] = ball_sqrt(accumulator)?;
1092                if !(factor[i][j].lo > 0.0) {
1093                    return None;
1094                }
1095            } else {
1096                if !(factor[j][j].lo > 0.0) {
1097                    return None;
1098                }
1099                factor[i][j] = accumulator.div_positive(factor[j][j]);
1100            }
1101        }
1102    }
1103    Some(factor)
1104}
1105
1106/// How many accumulators the per-node divergence check scans.
1107///
1108/// The second- and third-order accumulators are ordered last and left OUT of
1109/// the scan: each has a closed-form global bound the certificate substitutes
1110/// (see [`BoundSource`]), so neither can justify discarding a value and slope
1111/// that are finite. Divergence in the VALUE or in the FIRST derivative still
1112/// refuses, at the node it happened — those have no substitute.
1113const GLOBALLY_BOUNDED_FROM: usize = 4;
1114
1115/// Number of columns in the prediction prearray `[F·L, L_Q]`.
1116const PREARRAY_COLUMNS: usize = 2 * MAX_ORDER;
1117
1118/// The measurement update, performed on a CARRIED FACTOR of the covariance
1119/// instead of on the covariance.
1120///
1121/// With `P⁻ = L Lᵀ` and `L` lower triangular, `Lᵀe₀` has a single nonzero, so
1122/// the whole Kalman update is one column scaling:
1123///
1124/// ```text
1125/// L⁺ = L · diag(β, 1, …, 1),      β = √(R/F),
1126/// ```
1127///
1128/// and `P⁺ = L⁺L⁺ᵀ` is EXACTLY `P⁻ − M Mᵀ/F`. The check: `P[i][0] = L[i][0]L₀₀`
1129/// because `L[0][k] = 0` for `k ≥ 1`, so
1130///
1131/// ```text
1132/// (L⁺L⁺ᵀ)[i][j] = P[i][j] − (1 − β²)L[i][0]L[j][0]
1133///               = P[i][j] − (P₀₀/F)·M[i]M[j]/P₀₀
1134///               = P[i][j] − M[i]M[j]/F.
1135/// ```
1136///
1137/// CARRIED is the load-bearing word. Recomputing the factorization per node
1138/// from the componentwise covariance was measured and is INERT — bit-identical
1139/// divergence nodes `44/44/44/45/50/88/164` at order 2 — because the Cholesky's
1140/// own Schur complement `L₁₁² = P₁₁ − P₀₁²/P₀₀` IS the cancelling subtraction it
1141/// was meant to avoid, so factoring and immediately reconstructing recomputes
1142/// exactly the quantity whose width is the problem. Carried across nodes, that
1143/// difference is never re-formed: `L₁₁` is scaled and rotated, never subtracted,
1144/// so its enclosure tracks the size of the RESULT instead of the size of the
1145/// operands it would have been differenced out of.
1146fn ball_factor_update(factor: &BallMat, beta: Ball, order: usize) -> BallMat {
1147    let mut updated = *factor;
1148    for row in updated.iter_mut().take(order) {
1149        row[0] = row[0].mul(beta);
1150    }
1151    updated
1152}
1153
1154/// `L Lᵀ` for a lower-triangular factor.
1155fn ball_factor_gram(factor: &BallMat, order: usize) -> BallMat {
1156    let mut gram = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
1157    for i in 0..order {
1158        for j in 0..order {
1159            let mut accumulator = Ball::ZERO;
1160            for k in 0..=i.min(j) {
1161                accumulator = accumulator.add(factor[i][k].mul(factor[j][k]));
1162            }
1163            gram[i][j] = accumulator;
1164        }
1165    }
1166    gram
1167}
1168
1169/// Re-triangularize the prediction prearray `A = [F·L⁺, L_Q]` so that the
1170/// carried factor stays `order`-wide, WITHOUT assuming exact orthogonality.
1171///
1172/// Givens rotations are applied to pairs of COLUMNS with `c` and `s` taken as
1173/// floating-point points, never intervals. That is what makes the result sound:
1174/// a 2×2 `Θ = [[c, −s], [s, c]]` built from points satisfies `ΘΘᵀ = (c²+s²)I`
1175/// EXACTLY, so applying it to every row rescales the Gram by the scalar
1176/// `c²+s²` and introduces no other error — a computed rotation that is not
1177/// quite orthogonal is a similarity scaling, not a general perturbation. The
1178/// product of those scalars is returned so the caller can divide it back out.
1179/// Had `c` and `s` been intervals, the enclosure would have ranged over
1180/// non-orthogonal `Θ`s and `L Lᵀ` would no longer have enclosed `A Aᵀ`.
1181///
1182/// The trailing columns are not exactly zeroed either, so their outer product
1183/// `D = Σ_{k ≥ order} A[:,k]A[:,k]ᵀ` is returned as `trace(D)`, which bounds
1184/// every entry of `D` because `|D[i][j]| ≤ √(D_ii·D_jj) ≤ trace(D)`.
1185fn ball_retriangularize(
1186    prearray: &mut [[Ball; PREARRAY_COLUMNS]; MAX_ORDER],
1187    order: usize,
1188    columns: usize,
1189) -> (BallMat, f64, f64) {
1190    let mut gram_scale = 1.0_f64;
1191    for i in 0..order {
1192        for k in (i + 1)..columns {
1193            let a = prearray[i][i].value;
1194            let b = prearray[i][k].value;
1195            let radius = (a * a + b * b).sqrt();
1196            if !(radius.is_finite() && radius > 0.0) {
1197                continue;
1198            }
1199            let cosine = a / radius;
1200            let sine = b / radius;
1201            gram_scale = next_up_ball(gram_scale * (cosine * cosine + sine * sine));
1202            for row in prearray.iter_mut().take(order) {
1203                let x = row[i];
1204                let y = row[k];
1205                row[i] = x.scale(cosine).add(y.scale(sine));
1206                row[k] = y.scale(cosine).sub(x.scale(sine));
1207            }
1208        }
1209    }
1210    let mut factor = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
1211    for i in 0..order {
1212        for j in 0..=i {
1213            factor[i][j] = prearray[i][j];
1214        }
1215    }
1216    let mut trailing = 0.0_f64;
1217    for row in prearray.iter().take(order) {
1218        for entry in row.iter().take(columns).skip(order) {
1219            let magnitude = entry.lo.abs().max(entry.hi.abs());
1220            trailing = next_up_ball(trailing + next_up_ball(magnitude * magnitude));
1221        }
1222    }
1223    (factor, trailing, gram_scale)
1224}
1225
1226/// Intersect an enclosure with an independently derived enclosure of the same/// Intersect an enclosure with an independently derived enclosure of the same
1227/// quantity.
1228///
1229/// Both bound the same real number, so their intersection does too, and it is
1230/// no wider than either. A bound is never moved past the nearest-rounded
1231/// `value`, which keeps `lo <= value <= hi` an invariant of the type rather
1232/// than something each caller has to re-establish.
1233#[inline]
1234fn intersect_with_independent_enclosure(entry: &mut Ball, evidence: Ball) {
1235    let floor = evidence.lo.min(entry.value);
1236    if floor.is_finite() && entry.lo < floor {
1237        entry.lo = floor;
1238    }
1239    let ceiling = evidence.hi.max(entry.value);
1240    if ceiling.is_finite() && entry.hi > ceiling {
1241        entry.hi = ceiling;
1242    }
1243}
1244
1245/// Intersect the FIRST-derivative covariance with the two-sided range the
1246/// covariance itself gives it: `0 ⪯ −dP/dρ ⪯ P`.
1247///
1248/// The file already uses the left half — "`P` is operator monotone increasing in
1249/// the process-noise scale `q = e^{−ρ}`, so `dP/dρ ⪯ 0`" — to carry a Cholesky
1250/// factor of `−dP/dρ`. The right half comes from the same map being operator
1251/// CONCAVE in `q`, which is what actually bounds the derivative:
1252///
1253/// * the measurement update `P ↦ P − PHᵀ(HPHᵀ+R)⁻¹HP = (P⁻¹ + HᵀR⁻¹H)⁻¹` is the
1254///   parallel sum, which is operator concave and operator monotone;
1255/// * the prediction `P ↦ FPFᵀ + qQ` is affine and jointly monotone in `(P, q)`;
1256/// * the seed is affine in `q`.
1257///
1258/// A composition of operator-concave monotone maps with affine inner maps is
1259/// operator concave, so `q ↦ P_t(q)` is. Concavity at `q` against `0` gives
1260/// `P_t(0) ⪰ P_t(q) − q·dP_t/dq`, i.e.
1261///
1262/// ```text
1263///   0 ⪯ −dP/dρ = q·dP/dq ⪯ P(q) − P(0) ⪯ P(q),
1264/// ```
1265///
1266/// the last step because `P_t(0) ⪰ 0` is a covariance. Only the DIAGONAL of a
1267/// semidefinite ordering transfers entrywise, so that is what is intersected
1268/// here; [`intersect_covariance_minors`] then carries it to the off-diagonals
1269/// through `|X_ij| ≤ √(X_ii X_jj)`, applied to `−dP/dρ`, which is the PSD
1270/// matrix of the pair.
1271///
1272/// Measured need (#2614, order 3, ρ = −16.6135, dgp_2300): at node 40 the value
1273/// covariance `P⁺₀₀` holds width `8.4e−3` while `dP⁺₀₀/dρ` — a quantity this
1274/// bound pins into `[−0.74, 0]` — carries width `4.97e5`, and by node 80 it is
1275/// `2.5e149`. Every gain jet is a column of that matrix divided by `R`, so the
1276/// mean jet, `v′`, and `Σ v²/F̃`'s derivative inherit it directly.
1277fn intersect_derivative_covariance_below_its_own_covariance(
1278    derivative: &mut BallMat,
1279    covariance: &BallMat,
1280    order: usize,
1281) {
1282    for i in 0..order {
1283        intersect_with_exact_range(&mut derivative[i][i], -covariance[i][i].hi, 0.0);
1284    }
1285    let mut negated = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
1286    for i in 0..order {
1287        for j in 0..order {
1288            negated[i][j] = derivative[i][j].neg();
1289        }
1290    }
1291    intersect_covariance_minors(&mut negated, order);
1292    for i in 0..order {
1293        for j in 0..order {
1294            intersect_with_independent_enclosure(&mut derivative[i][j], negated[i][j].neg());
1295        }
1296    }
1297}
1298
1299/// Intersect an enclosure with an exact two-sided range of the same quantity.
1300///
1301/// Same rule as [`intersect_with_independent_enclosure`], for evidence that is a
1302/// pair of bounds rather than a computed ball: bounds move only inward and never
1303/// past the nearest-rounded `value`, so `lo <= value <= hi` stays an invariant of
1304/// the type.
1305#[inline]
1306fn intersect_with_exact_range(entry: &mut Ball, lo: f64, hi: f64) {
1307    let floor = lo.min(entry.value);
1308    if floor.is_finite() && entry.lo < floor {
1309        entry.lo = floor;
1310    }
1311    let ceiling = hi.max(entry.value);
1312    if ceiling.is_finite() && entry.hi > ceiling {
1313        entry.hi = ceiling;
1314    }
1315}
1316
1317/// Intersect the two ORDER-ONE accumulators with the exact ranges the Gaussian
1318/// model gives them, at every node.
1319///
1320/// Once the diffuse rank is consumed, the accumulated pair over the data prefix
1321/// `y` seen so far IS the restricted Gaussian pair for that prefix: with
1322/// `V = R + qG` (`R = diag(1/w)`, `G ⪰ 0` the process-noise Gram, `q = e^{−ρ}`),
1323/// `X` the diffuse polynomial design and
1324/// `P = V⁻¹ − V⁻¹X(XᵀV⁻¹X)⁻¹XᵀV⁻¹` its restricted inverse,
1325///
1326/// ```text
1327///   Σ v²/F̃ = yᵀPy,     Σ log F̃ = log|V| + log|XᵀV⁻¹X|.
1328/// ```
1329///
1330/// `dV/dρ = −qG = −(V − R)` and `dP/dρ = −P(dV/dρ)P = P − PRP`, so
1331///
1332/// ```text
1333///   d/dρ Σ v²/F̃  = yᵀPy − yᵀPRPy,
1334///   d/dρ Σ log F̃ = tr(P dV/dρ) = −(tr(PV) − tr(PR)).
1335/// ```
1336///
1337/// Every piece of that is signed by a semidefinite fact and nothing else:
1338/// `R ⪰ 0` gives `yᵀPRPy ≥ 0`; `V − R = qG ⪰ 0` with `PVP = P` gives
1339/// `yᵀPRPy ≤ yᵀPVPy = yᵀPy` and `tr(PR) ≤ tr(PV)`; and `tr(PV) = n_proper`
1340/// exactly. Hence, EXACTLY,
1341///
1342/// ```text
1343///   0 ≤ Σ v²/F̃ ≤ yᵀR⁻¹y = Σ w y²,     0 ≤ d/dρ Σ v²/F̃ ≤ Σ v²/F̃,
1344///   −n_proper ≤ d/dρ Σ log F̃ ≤ 0,
1345/// ```
1346///
1347/// the first ceiling because `P ⪯ V⁻¹ ⪯ R⁻¹`.
1348///
1349/// Why this is the intersection that matters (#2614, measured): at smoothing
1350/// order 2 on the #2300 nodes the search refuses at `ρ = −18` with a certified
1351/// criterion DERIVATIVE ball of `±1.95e91` and a cell value enclosure of
1352/// `±1.58e83` — both finite, so no accumulator check fires, and both useless:
1353/// `strict_sign` cannot sign a `±1e91` interval, so the search can neither
1354/// bracket a stationary point nor exclude one, and reports `Unresolved`. The
1355/// true derivative is bounded by `½(n_proper + ν)`, i.e. by `178` on that
1356/// fixture. The entire `1e89` excess is dependency loss in the jet recursions,
1357/// and it is the running SUM that carries it forward, so the bound is applied
1358/// per node rather than once at the end: a partial sum is the same quantity for
1359/// its own prefix, which is what makes that legitimate.
1360///
1361/// This restores proof information supplied by the statistical model; it is not
1362/// a numerical tolerance, and it cannot make a false statement true, because
1363/// each bound is a property of the exact real number the enclosure encloses.
1364#[inline]
1365fn intersect_first_order_accumulator_exact_ranges(
1366    quadratic: &mut Ball,
1367    quadratic_d1: &mut Ball,
1368    log_determinant_d1: &mut Ball,
1369    weighted_energy: Ball,
1370    n_proper: usize,
1371) {
1372    intersect_with_exact_range(quadratic, 0.0, weighted_energy.hi);
1373    intersect_with_exact_range(quadratic_d1, 0.0, quadratic.hi);
1374    intersect_with_exact_range(log_determinant_d1, -(n_proper as f64), 0.0);
1375}
1376
1377/// Intersect the observed coordinate of the UPDATED covariance with the exact
1378/// range of its own defining map.
1379///
1380/// At the observed coordinate the Kalman update is the scalar map
1381///
1382/// ```text
1383/// P⁺₀₀ = P₀₀·R/(P₀₀ + R),
1384/// ```
1385///
1386/// whose partial derivatives `(R/F)²` and `(P₀₀/F)²` are both strictly positive
1387/// on `P₀₀ ≥ 0 < R`. A function monotone in each argument attains its range over
1388/// a box at the corners, so `[g(P.lo, R.lo), g(P.hi, R.hi)]` is the EXACT range
1389/// of this update over the input enclosure. Anything wider is dependency loss,
1390/// not information about the filter.
1391///
1392/// The componentwise Joseph evaluation loses exactly that dependency: `A = R/F`
1393/// and `K = P₀₀/F` are functions of the same `P₀₀` that appears in the middle
1394/// factor, and interval arithmetic ranges the three independently. Measured on
1395/// the #2300 nodes (order 1, ρ = 0, node 136, where `R = 1/9`, `P₀₀ = 0.062`,
1396/// `F = 0.173`): the map's true width factor is `(R/F)² = 0.41`, while the
1397/// componentwise width factor is
1398///
1399/// ```text
1400/// (R/F)² + 4·P₀₀·R²/F³ = 0.41 + 0.59 = 1.00,
1401/// ```
1402///
1403/// so the contraction is cancelled EXACTLY and the per-node rounding then
1404/// accumulates without ever being pulled back — the measured 1.28× per node
1405/// that carries `P⁺₀₀`'s width from `1.6e-14` at node 8 to `2.4e2` at node 140
1406/// on a value of `0.04`. With the range form the width contracts by `0.41` per
1407/// node against an additive `Q` term, which has a bounded fixed point.
1408///
1409/// Only `lo`/`hi` move, and only inward, and never past `value`: this is an
1410/// intersection of two valid enclosures of one quantity, so it cannot make a
1411/// false statement true.
1412#[inline]
1413fn intersect_observed_covariance_exact_range(
1414    updated: &mut Ball,
1415    predicted: Ball,
1416    observation_variance: Ball,
1417) {
1418    let corner = |p: f64, r: f64| -> Option<Ball> {
1419        let p = Ball::exact(p.max(0.0));
1420        let r = Ball::exact(r);
1421        let f = p.add(r);
1422        (f.lo > 0.0).then(|| p.mul(r).div_positive(f))
1423    };
1424    if let Some(low) = corner(predicted.lo, observation_variance.lo) {
1425        let floor = low.lo.min(updated.value);
1426        if floor.is_finite() && updated.lo < floor {
1427            updated.lo = floor;
1428        }
1429    }
1430    if let Some(high) = corner(predicted.hi, observation_variance.hi) {
1431        let ceiling = high.hi.max(updated.value);
1432        if ceiling.is_finite() && updated.hi > ceiling {
1433            updated.hi = ceiling;
1434        }
1435    }
1436}
1437
1438/// Intersect a general entry of the UPDATED covariance with the exact range of
1439/// the Schur complement that defines it.
1440///
1441/// [`intersect_observed_covariance_exact_range`] is this argument at the
1442/// observed coordinate, where the map collapses to one scalar variable. The
1443/// general entry is
1444///
1445/// ```text
1446/// P⁺[i][j] = P[i][j] − P[i][0]·P[0][j] / (P[0][0] + R),
1447/// ```
1448///
1449/// whose partials with respect to `(a, b, c, d) = (P[i][j], P[i][0], P[0][j],
1450/// P[0][0])` are `1`, `−c/F`, `−b/F` and `bc/F²`. Whenever `b` and `c` have
1451/// DEFINITE sign the sign of every partial is fixed over the whole box, so the
1452/// range is attained at two corners and two evaluations bound it exactly. `R`
1453/// enters like `d` and moves with it.
1454///
1455/// Treating the four as independent is conservative — they are entries of one
1456/// PSD matrix — so this is a valid enclosure, and it is the tightest available
1457/// without carrying that correlation. What it removes is the evaluation-order
1458/// dependency: `F` appears in three factors of the componentwise form and
1459/// `P[0][0]` in four, and interval arithmetic ranges each occurrence
1460/// separately. When `b` or `c` straddles zero the monotonicity argument does
1461/// not hold and nothing is claimed.
1462fn intersect_updated_covariance_exact_range(
1463    updated: &mut Ball,
1464    entry: Ball,
1465    row: Ball,
1466    column: Ball,
1467    observed: Ball,
1468    observation_variance: Ball,
1469) {
1470    // `Some(true)` = nonnegative throughout, `Some(false)` = nonpositive
1471    // throughout, `None` = straddles zero and the partials change sign.
1472    let definite_sign = |ball: Ball| -> Option<bool> {
1473        if ball.lo >= 0.0 {
1474            Some(true)
1475        } else if ball.hi <= 0.0 {
1476            Some(false)
1477        } else {
1478            None
1479        }
1480    };
1481    let (Some(row_nonnegative), Some(column_nonnegative)) =
1482        (definite_sign(row), definite_sign(column))
1483    else {
1484        return;
1485    };
1486    // `∂/∂d = bc/F²` is nonnegative exactly when `b` and `c` agree in sign.
1487    let product_nonnegative = row_nonnegative == column_nonnegative;
1488    let corner = |minimizing: bool| -> Option<Ball> {
1489        // `∂/∂a = 1`.
1490        let a = Ball::exact(if minimizing { entry.lo } else { entry.hi });
1491        // `∂/∂b = −c/F`: decreasing in `b` when `c ≥ 0`.
1492        let b = Ball::exact(if minimizing == column_nonnegative {
1493            row.hi
1494        } else {
1495            row.lo
1496        });
1497        // `∂/∂c = −b/F`, symmetrically.
1498        let c = Ball::exact(if minimizing == row_nonnegative {
1499            column.hi
1500        } else {
1501            column.lo
1502        });
1503        let take_low = minimizing == product_nonnegative;
1504        let d = Ball::exact(if take_low { observed.lo } else { observed.hi }.max(0.0));
1505        let variance = Ball::exact(if take_low {
1506            observation_variance.lo
1507        } else {
1508            observation_variance.hi
1509        });
1510        let f = d.add(variance);
1511        (f.lo > 0.0).then(|| a.sub(b.mul(c).div_positive(f)))
1512    };
1513    if let Some(low) = corner(true) {
1514        let floor = low.lo.min(updated.value);
1515        if floor.is_finite() && updated.lo < floor {
1516            updated.lo = floor;
1517        }
1518    }
1519    if let Some(high) = corner(false) {
1520        let ceiling = high.hi.max(updated.value);
1521        if ceiling.is_finite() && updated.hi > ceiling {
1522            updated.hi = ceiling;
1523        }
1524    }
1525}
1526
1527/// Intersect a covariance enclosure with the exact 2×2 minors of the PSD
1528/// constraint it satisfies.
1529///
1530/// [`intersect_proper_covariance_psd`] uses the 1×1 minors — `P[i][i] ≥ 0` —
1531/// and stops there. The 2×2 minors are equally exact and two-sided:
1532///
1533/// ```text
1534/// P[i][i]·P[j][j] − P[i][j]² ≥ 0   ⇒   |P[i][j]| ≤ √(P[i][i]·P[j][j]),
1535/// ```
1536///
1537/// which bounds every off-diagonal by the diagonals rather than letting it
1538/// drift on its own. This matters because only the OBSERVED direction is
1539/// contracted by an update: the componentwise recursion has nothing that pulls
1540/// an unobserved covariance back, and the transition then mixes that drift into
1541/// the observed entry as `P₀₀ + 2δP₀₁ + δ²P₁₁`.
1542///
1543/// The bound is rounded up by one ulp so that a rounded square root can never
1544/// claim more than the minor supports.
1545fn intersect_covariance_minors(covariance: &mut BallMat, order: usize) {
1546    let sqrt_upper = |value: f64| -> f64 {
1547        if !(value.is_finite() && value > 0.0) {
1548            return value;
1549        }
1550        let root = value.sqrt();
1551        if root * root >= value {
1552            root
1553        } else {
1554            f64::from_bits(root.to_bits() + 1)
1555        }
1556    };
1557    for i in 0..order {
1558        for j in 0..order {
1559            if i == j {
1560                continue;
1561            }
1562            let diagonal_product = Ball::exact(covariance[i][i].hi.max(0.0))
1563                .mul(Ball::exact(covariance[j][j].hi.max(0.0)));
1564            if !diagonal_product.is_finite() {
1565                continue;
1566            }
1567            let bound = sqrt_upper(diagonal_product.hi);
1568            if !bound.is_finite() {
1569                continue;
1570            }
1571            let entry = &mut covariance[i][j];
1572            let floor = (-bound).min(entry.value);
1573            if entry.lo < floor {
1574                entry.lo = floor;
1575            }
1576            let ceiling = bound.max(entry.value);
1577            if entry.hi > ceiling {
1578                entry.hi = ceiling;
1579            }
1580        }
1581    }
1582}
1583
1584/// Intersect a proper covariance enclosure with its exact PSD invariant.
1585///
1586/// Once the diffuse rank is exhausted, `P*` is the conditional covariance of
1587/// the state. Its diagonal is therefore nonnegative at every measurement
1588/// update and prediction. A componentwise interval evaluation of
1589/// `P - PH'(HPH' + R)⁻¹HP` forgets that dependency and can widen a diagonal
1590/// through zero after repeated rank-one subtractions, even though the exact
1591/// innovation is bounded below by the positive observation variance `R`.
1592///
1593/// This intersection restores proof information supplied by the statistical
1594/// model; it is not a numerical tolerance. A wholly negative or non-finite
1595/// diagonal still signals an inconsistent enclosure and fails closed.
1596#[inline]
1597fn intersect_proper_covariance_psd(
1598    covariance: &mut BallMat,
1599    order: usize,
1600) -> Result<(), SplineScoreProofError> {
1601    for (index, row) in covariance.iter_mut().enumerate().take(order) {
1602        let diagonal = &mut row[index];
1603        if !diagonal.is_finite() || diagonal.hi < 0.0 {
1604            return Err(SplineScoreProofError::InvalidArithmetic {
1605                context: "proper covariance PSD intersection",
1606            });
1607        }
1608        diagonal.lo = diagonal.lo.max(0.0);
1609    }
1610    Ok(())
1611}
1612
1613/// Per-node filter storage needed by the RTS backward pass.
1614struct FilterStep {
1615    /// Filtered mean `a_{t|t}` and proper covariance `P*_{t|t}`.
1616    a_filt: Vec2,
1617    p_filt: Mat2,
1618    /// One-step prediction `a_{t|t-1}`, proper covariance `P*_{t|t-1}` (for t ≥ 1).
1619    a_pred: Vec2,
1620    p_pred: Mat2,
1621}
1622
1623/// Output of one full filter pass at a fixed `q = 1/λ` (run at unit σ²).
1624struct FilterPass {
1625    steps: Vec<FilterStep>,
1626    /// Σ over proper steps of `log F̃_t` (innovation variances at σ²=1).
1627    sum_log_f: f64,
1628    /// First three analytic derivatives of `sum_log_f` with respect to
1629    /// `rho = log lambda` (`q = exp(-rho)`). Endpoint pairs linearly
1630    /// interpolate the third order under a global `L5` bound, certifying the
1631    /// λ→∞ tail at fourth-order width `(|V′|/L₅)^{1/4}` (#2300/#2614).
1632    sum_log_f_d1: f64,
1633    sum_log_f_d2: f64,
1634    sum_log_f_d3: f64,
1635    /// Σ over proper steps of `v_t² / F̃_t`.
1636    sum_v2_over_f: f64,
1637    /// First three analytic `rho` derivatives of `sum_v2_over_f`.
1638    sum_v2_over_f_d1: f64,
1639    sum_v2_over_f_d2: f64,
1640    sum_v2_over_f_d3: f64,
1641    /// Number of proper (non-diffuse) innovations.
1642    n_proper: usize,
1643}
1644
1645/// The scalar criterion accumulators with directed-rounding enclosures.
1646#[derive(Debug)]
1647struct BallFilterPass {
1648    sum_log_f: Ball,
1649    sum_log_f_d1: Ball,
1650    sum_log_f_d2: Ball,
1651    sum_log_f_d3: Ball,
1652    sum_v2_over_f: Ball,
1653    sum_v2_over_f_d1: Ball,
1654    sum_v2_over_f_d2: Ball,
1655    sum_v2_over_f_d3: Ball,
1656    n_proper: usize,
1657}
1658
1659/// One forward pass of the exact diffuse filter.
1660///
1661/// `RECORD_STEPS` selects whether the per-node filtered/predicted states are
1662/// retained. They are needed ONLY by the RTS backward smoother in
1663/// [`fit_spline_scan_at`]; the profiled REML criterion
1664/// ([`concentrated_criterion_jet`]) reads nothing but the scalar accumulators.
1665/// Recording them unconditionally made every criterion evaluation of the
1666/// certified log-lambda search allocate, fill, and immediately drop
1667/// `n * size_of::<FilterStep>()` bytes — at the biobank scale this fast path
1668/// exists for (n = 1e6, 192 B per node) that is 192 MB of write traffic per
1669/// evaluation, thrown away.
1670fn run_filter<const RECORD_STEPS: bool>(
1671    nodes: &[PooledNode],
1672    q: f64,
1673    order: usize,
1674) -> Result<FilterPass, String> {
1675    let n = nodes.len();
1676    let mut steps = Vec::with_capacity(if RECORD_STEPS { n } else { 0 });
1677    // Exact diffuse initialization (Durbin–Koopman): P = P* + κ·P_∞, κ → ∞.
1678    // The order-`m` polynomial null space (degree < m) is fully diffuse: the
1679    // diffuse rank starts at `order`, consumed by the first `order` distinct
1680    // abscissae.
1681    let mut a: Vec2 = [0.0; MAX_ORDER];
1682    let mut a_d1: Vec2 = [0.0; MAX_ORDER];
1683    let mut a_d2: Vec2 = [0.0; MAX_ORDER];
1684    let mut a_d3: Vec2 = [0.0; MAX_ORDER];
1685    let mut p_star: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
1686    let mut p_star_d1: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
1687    let mut p_star_d2: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
1688    let mut p_star_d3: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
1689    let mut p_inf: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
1690    for i in 0..order {
1691        p_inf[i][i] = 1.0;
1692    }
1693    let mut diffuse_rank = order;
1694    let mut sum_log_f = 0.0;
1695    let mut sum_log_f_d1 = 0.0;
1696    let mut sum_log_f_d2 = 0.0;
1697    let mut sum_log_f_d3 = 0.0;
1698    let mut sum_v2_over_f = 0.0;
1699    let mut sum_v2_over_f_d1 = 0.0;
1700    let mut sum_v2_over_f_d2 = 0.0;
1701    let mut sum_v2_over_f_d3 = 0.0;
1702    let mut n_proper = 0usize;
1703    for t in 0..n {
1704        let a_pred = a;
1705        let p_pred = p_star;
1706        let r = 1.0 / nodes[t].w;
1707        let v = nodes[t].y - a[0];
1708        let v_d1 = -a_d1[0];
1709        let v_d2 = -a_d2[0];
1710        let v_d3 = -a_d3[0];
1711        // H = [1 0 … 0] ⇒ M = P·H' is the first column, F = M[0] (+ r).
1712        let mut m_star: Vec2 = [0.0; MAX_ORDER];
1713        let mut m_star_d1: Vec2 = [0.0; MAX_ORDER];
1714        let mut m_star_d2: Vec2 = [0.0; MAX_ORDER];
1715        let mut m_star_d3: Vec2 = [0.0; MAX_ORDER];
1716        for i in 0..order {
1717            m_star[i] = p_star[i][0];
1718            m_star_d1[i] = p_star_d1[i][0];
1719            m_star_d2[i] = p_star_d2[i][0];
1720            m_star_d3[i] = p_star_d3[i][0];
1721        }
1722        let f_star = m_star[0] + r;
1723        let f_star_d1 = m_star_d1[0];
1724        let f_star_d2 = m_star_d2[0];
1725        let f_star_d3 = m_star_d3[0];
1726        let mut proper_update = diffuse_rank == 0;
1727        if diffuse_rank > 0 {
1728            let mut m_inf: Vec2 = [0.0; MAX_ORDER];
1729            for i in 0..order {
1730                m_inf[i] = p_inf[i][0];
1731            }
1732            let f_inf = m_inf[0];
1733            if !f_inf.is_finite() {
1734                return Err(format!(
1735                    "spline scan: non-finite diffuse innovation variance at node {t}: {f_inf}"
1736                ));
1737            } else if f_inf > 0.0 {
1738                // Exact diffuse update (Koopman 1997): the κ→∞ limit of the
1739                // standard update; the diffuse step contributes −½·log F_∞ to
1740                // the restricted likelihood and consumes one diffuse dimension.
1741                for i in 0..order {
1742                    let k_inf = m_inf[i] / f_inf;
1743                    a[i] += k_inf * v;
1744                    a_d1[i] += k_inf * v_d1;
1745                    a_d2[i] += k_inf * v_d2;
1746                    a_d3[i] += k_inf * v_d3;
1747                }
1748                let mut p_new = p_star;
1749                let mut p_new_d1 = p_star_d1;
1750                let mut p_new_d2 = p_star_d2;
1751                let mut p_new_d3 = p_star_d3;
1752                for i in 0..order {
1753                    for j in 0..order {
1754                        p_new[i][j] += -m_inf[i] * m_star[j] / f_inf - m_star[i] * m_inf[j] / f_inf
1755                            + m_inf[i] * m_inf[j] * f_star / (f_inf * f_inf);
1756                        p_new_d1[i][j] += -m_inf[i] * m_star_d1[j] / f_inf
1757                            - m_star_d1[i] * m_inf[j] / f_inf
1758                            + m_inf[i] * m_inf[j] * f_star_d1 / (f_inf * f_inf);
1759                        p_new_d2[i][j] += -m_inf[i] * m_star_d2[j] / f_inf
1760                            - m_star_d2[i] * m_inf[j] / f_inf
1761                            + m_inf[i] * m_inf[j] * f_star_d2 / (f_inf * f_inf);
1762                        p_new_d3[i][j] += -m_inf[i] * m_star_d3[j] / f_inf
1763                            - m_star_d3[i] * m_inf[j] / f_inf
1764                            + m_inf[i] * m_inf[j] * f_star_d3 / (f_inf * f_inf);
1765                    }
1766                }
1767                p_star = p_new;
1768                p_star_d1 = p_new_d1;
1769                p_star_d2 = p_new_d2;
1770                p_star_d3 = p_new_d3;
1771                symmetrize(&mut p_star, order);
1772                symmetrize(&mut p_star_d1, order);
1773                symmetrize(&mut p_star_d2, order);
1774                symmetrize(&mut p_star_d3, order);
1775                for i in 0..order {
1776                    for j in 0..order {
1777                        p_inf[i][j] -= m_inf[i] * m_inf[j] / f_inf;
1778                    }
1779                }
1780                symmetrize(&mut p_inf, order);
1781                diffuse_rank -= 1;
1782                if diffuse_rank == 0 {
1783                    p_inf = [[0.0; MAX_ORDER]; MAX_ORDER];
1784                }
1785            } else if f_inf == 0.0 {
1786                // Diffuse direction orthogonal to H: this observation is an
1787                // ordinary proper update of P* even though diffuse rank remains.
1788                proper_update = true;
1789            } else {
1790                return Err(format!(
1791                    "spline scan: non-positive diffuse innovation variance at node {t}: {f_inf}"
1792                ));
1793            }
1794        }
1795        if proper_update {
1796            if !(f_star.is_finite() && f_star > 0.0) {
1797                return Err(format!(
1798                    "spline scan: non-positive or non-finite proper innovation variance \
1799                     at node {t}: {f_star}"
1800                ));
1801            }
1802            let inv_f = 1.0 / f_star;
1803            // Quotient jets in the recursive Leibniz form: for s = num/f,
1804            //   s_k = (num_k − Σ_{j=1..k} C(k,j)·s_{k−j}·f_j) / f,
1805            // which is exactly the closed inv_f² / inv_f³ expansion used
1806            // before, extended to third order.
1807            let mut gain = [0.0; MAX_ORDER];
1808            let mut gain_d1 = [0.0; MAX_ORDER];
1809            let mut gain_d2 = [0.0; MAX_ORDER];
1810            let mut gain_d3 = [0.0; MAX_ORDER];
1811            for i in 0..order {
1812                gain[i] = m_star[i] * inv_f;
1813                gain_d1[i] = (m_star_d1[i] - gain[i] * f_star_d1) * inv_f;
1814                gain_d2[i] =
1815                    (m_star_d2[i] - 2.0 * gain_d1[i] * f_star_d1 - gain[i] * f_star_d2) * inv_f;
1816                gain_d3[i] = (m_star_d3[i]
1817                    - 3.0 * gain_d2[i] * f_star_d1
1818                    - 3.0 * gain_d1[i] * f_star_d2
1819                    - gain[i] * f_star_d3)
1820                    * inv_f;
1821            }
1822            let a_old_d1 = a_d1;
1823            let a_old_d2 = a_d2;
1824            let a_old_d3 = a_d3;
1825            for i in 0..order {
1826                a[i] += gain[i] * v;
1827                a_d1[i] = a_old_d1[i] + gain_d1[i] * v + gain[i] * v_d1;
1828                a_d2[i] = a_old_d2[i] + gain_d2[i] * v + 2.0 * gain_d1[i] * v_d1 + gain[i] * v_d2;
1829                a_d3[i] = a_old_d3[i]
1830                    + gain_d3[i] * v
1831                    + 3.0 * gain_d2[i] * v_d1
1832                    + 3.0 * gain_d1[i] * v_d2
1833                    + gain[i] * v_d3;
1834            }
1835            // The VALUE covariance through the Joseph form, the same expression
1836            // the certified pass runs (#2614).
1837            //
1838            // `P − M Mᵀ/F` subtracts near-equal quantities: on the #2300 nodes
1839            // at `ρ = −24` both terms are `1.84e5` and their difference is `1`,
1840            // so the result is accurate to `F/R` times the rounding of its own
1841            // size. `P⁺ = A P Aᵀ + R K Kᵀ` with `A = I − K e₀ᵀ` is the same
1842            // quantity as a sum of positive contributions at the observed
1843            // coordinate. This is not only the certified pass's concern: the two
1844            // passes must run the same expression, or the enclosure would be
1845            // certifying arithmetic the scalar path does not perform.
1846            let mut a_operator = [[0.0; MAX_ORDER]; MAX_ORDER];
1847            for (i, row) in a_operator.iter_mut().enumerate().take(order) {
1848                row[i] = 1.0;
1849            }
1850            a_operator[0][0] = r * inv_f;
1851            for i in 1..order {
1852                a_operator[i][0] = -gain[i];
1853            }
1854            let a_operator_t = mat_t(&a_operator, order);
1855            let mut p_new = mat_mul(&mat_mul(&a_operator, &p_star, order), &a_operator_t, order);
1856            for i in 0..order {
1857                for j in 0..order {
1858                    p_new[i][j] += gain[i] * gain[j] * r;
1859                }
1860            }
1861            let mut p_new_d1 = p_star_d1;
1862            let mut p_new_d2 = p_star_d2;
1863            let mut p_new_d3 = p_star_d3;
1864            for i in 0..order {
1865                for j in 0..order {
1866                    let mm = m_star[i] * m_star[j];
1867                    let mm_d1 = m_star_d1[i] * m_star[j] + m_star[i] * m_star_d1[j];
1868                    let mm_d2 = m_star_d2[i] * m_star[j]
1869                        + 2.0 * m_star_d1[i] * m_star_d1[j]
1870                        + m_star[i] * m_star_d2[j];
1871                    let mm_d3 = m_star_d3[i] * m_star[j]
1872                        + 3.0 * m_star_d2[i] * m_star_d1[j]
1873                        + 3.0 * m_star_d1[i] * m_star_d2[j]
1874                        + m_star[i] * m_star_d3[j];
1875                    let s0 = mm * inv_f;
1876                    let s1 = (mm_d1 - s0 * f_star_d1) * inv_f;
1877                    let s2 = (mm_d2 - 2.0 * s1 * f_star_d1 - s0 * f_star_d2) * inv_f;
1878                    let s3 = (mm_d3 - 3.0 * s2 * f_star_d1 - 3.0 * s1 * f_star_d2 - s0 * f_star_d3)
1879                        * inv_f;
1880                    p_new_d1[i][j] -= s1;
1881                    p_new_d2[i][j] -= s2;
1882                    p_new_d3[i][j] -= s3;
1883                }
1884            }
1885            p_star = p_new;
1886            p_star_d1 = p_new_d1;
1887            p_star_d2 = p_new_d2;
1888            p_star_d3 = p_new_d3;
1889            symmetrize(&mut p_star, order);
1890            symmetrize(&mut p_star_d1, order);
1891            symmetrize(&mut p_star_d2, order);
1892            symmetrize(&mut p_star_d3, order);
1893
1894            let vv = v * v;
1895            let vv_d1 = 2.0 * v * v_d1;
1896            let vv_d2 = 2.0 * (v_d1 * v_d1 + v * v_d2);
1897            let vv_d3 = 2.0 * (v * v_d3 + 3.0 * v_d1 * v_d2);
1898            let logf_d1 = f_star_d1 * inv_f;
1899            let logf_d2 = f_star_d2 * inv_f - logf_d1 * logf_d1;
1900            let logf_d3 = f_star_d3 * inv_f - 3.0 * (f_star_d2 * inv_f) * logf_d1
1901                + 2.0 * logf_d1 * logf_d1 * logf_d1;
1902            sum_log_f += f_star.ln();
1903            sum_log_f_d1 += logf_d1;
1904            sum_log_f_d2 += logf_d2;
1905            sum_log_f_d3 += logf_d3;
1906            let t0 = vv * inv_f;
1907            let t1 = (vv_d1 - t0 * f_star_d1) * inv_f;
1908            let t2 = (vv_d2 - 2.0 * t1 * f_star_d1 - t0 * f_star_d2) * inv_f;
1909            let t3 = (vv_d3 - 3.0 * t2 * f_star_d1 - 3.0 * t1 * f_star_d2 - t0 * f_star_d3) * inv_f;
1910            sum_v2_over_f += t0;
1911            sum_v2_over_f_d1 += t1;
1912            sum_v2_over_f_d2 += t2;
1913            sum_v2_over_f_d3 += t3;
1914            n_proper += 1;
1915        }
1916        if RECORD_STEPS {
1917            steps.push(FilterStep {
1918                a_filt: a,
1919                p_filt: p_star,
1920                a_pred,
1921                p_pred,
1922            });
1923        }
1924        // Predict to the next node.
1925        if t + 1 < n {
1926            let delta = nodes[t + 1].x - nodes[t].x;
1927            let f_t = transition(delta, order);
1928            a = mat_vec(&f_t, &a, order);
1929            a_d1 = mat_vec(&f_t, &a_d1, order);
1930            a_d2 = mat_vec(&f_t, &a_d2, order);
1931            a_d3 = mat_vec(&f_t, &a_d3, order);
1932            let f_t_t = mat_t(&f_t, order);
1933            let q_noise = process_noise(delta, q, order);
1934            let mut p_next = mat_add(
1935                &mat_mul(&mat_mul(&f_t, &p_star, order), &f_t_t, order),
1936                &q_noise,
1937                order,
1938            );
1939            let mut p_next_d1 = mat_sub(
1940                &mat_mul(&mat_mul(&f_t, &p_star_d1, order), &f_t_t, order),
1941                &q_noise,
1942                order,
1943            );
1944            let mut p_next_d2 = mat_add(
1945                &mat_mul(&mat_mul(&f_t, &p_star_d2, order), &f_t_t, order),
1946                &q_noise,
1947                order,
1948            );
1949            // d^k q / d rho^k = (−1)^k q, so the noise term alternates sign.
1950            let mut p_next_d3 = mat_sub(
1951                &mat_mul(&mat_mul(&f_t, &p_star_d3, order), &f_t_t, order),
1952                &q_noise,
1953                order,
1954            );
1955            symmetrize(&mut p_next, order);
1956            symmetrize(&mut p_next_d1, order);
1957            symmetrize(&mut p_next_d2, order);
1958            symmetrize(&mut p_next_d3, order);
1959            p_star = p_next;
1960            p_star_d1 = p_next_d1;
1961            p_star_d2 = p_next_d2;
1962            p_star_d3 = p_next_d3;
1963            if diffuse_rank > 0 {
1964                let mut pi_next =
1965                    mat_mul(&mat_mul(&f_t, &p_inf, order), &mat_t(&f_t, order), order);
1966                symmetrize(&mut pi_next, order);
1967                p_inf = pi_next;
1968            }
1969        }
1970    }
1971    Ok(FilterPass {
1972        steps,
1973        sum_log_f,
1974        sum_log_f_d1,
1975        sum_log_f_d2,
1976        sum_log_f_d3,
1977        sum_v2_over_f,
1978        sum_v2_over_f_d1,
1979        sum_v2_over_f_d2,
1980        sum_v2_over_f_d3,
1981        n_proper,
1982    })
1983}
1984
1985/// Directed-rounding twin of [`run_filter`] used by automatic REML selection.
1986///
1987/// The recurrence follows the production diffuse filter operation for
1988/// operation, but every scalar carries an outward interval. Its cost is
1989/// `O(n·order³)` (the same fixed-size covariance propagations as the scalar
1990/// pass), with no data-dependent refinement knobs. A denominator is used only
1991/// after its innovation ball is proved strictly positive; if the ball contains
1992/// zero, the typed proof refusal is returned at that exact node.
1993fn run_filter_ball(
1994    nodes: &[PooledNode],
1995    q: Ball,
1996    order: usize,
1997) -> Result<BallFilterPass, SplineScoreProofError> {
1998    run_filter_ball_traced(nodes, q, order, None)
1999}
2000
2001/// One `(node, quantity, ball)` record of the `d3` recursion, for the caller
2002/// that asked to see it.
2003///
2004/// A refusal names the accumulator that left the finite range; it cannot show
2005/// how the width GOT there, and that is the difference between "the recursion
2006/// grows" and "the interval evaluation cannot see a cancellation the exact
2007/// arithmetic has". Only a caller that passes a sink pays for this.
2008type BallTraceRecord = (usize, &'static str, Ball);
2009
2010/// Stacked mean blocks carried as ONE zonotope: `(a, a′)`.
2011const MEAN_BLOCKS: usize = 2;
2012/// Capacity of that stacked state; the ACTIVE dimension is `2·order`.
2013const MEAN_DIM: usize = MEAN_BLOCKS * MAX_ORDER;
2014/// Capacity of the `vec(dP/dρ)` state; the ACTIVE dimension is `order²`.
2015const COVARIANCE_D1_DIM: usize = MAX_ORDER * MAX_ORDER;
2016/// Generators retained before the lowest-correlation directions are folded
2017/// into an axis-aligned set.
2018///
2019/// Folding is a sound outer reduction: the axis box contains the discarded
2020/// generators and is itself a zonotope, so it keeps being transformed by the
2021/// true map rather than by its absolute value. The cap only bounds the work:
2022/// `dim²·CAP` per node, `O(n)` overall. Reduction chooses the generators for
2023/// which a box loses the least signed correlation; it never assumes that age
2024/// alone implies contraction.
2025const ZONOTOPE_GENERATOR_CAP: usize = 240;
2026/// `γ_{dim+2}` with room to spare: `2·(d+2)·u` with `u = ε/2` is `11ε` at
2027/// `d = 9`, and this charges `32ε` for every floating-point dot product a
2028/// zonotope forms.
2029const ZONOTOPE_ROUNDOFF: f64 = 32.0 * f64::EPSILON;
2030
2031/// Radius of a ball ABOUT ITS REPRESENTATIVE, which is what a zonotope centred
2032/// on that representative must absorb. Not `(hi−lo)/2`: the representative is
2033/// not required to be the midpoint.
2034#[inline]
2035fn ball_radius_about_value(ball: Ball) -> f64 {
2036    let above = ball.hi - ball.value;
2037    let below = ball.value - ball.lo;
2038    next_up_ball(above.max(below).max(0.0))
2039}
2040
2041/// A linear recursion's state as a ZONOTOPE — a centre plus a list of error
2042/// GENERATORS — rather than as componentwise intervals.
2043///
2044/// This is not a tightening heuristic. Two of this filter's recursions cannot
2045/// be carried in a box AT ANY WIDTH, and the reason is arithmetic rather than
2046/// numerical. Per node the mean does update-then-predict, `a ← T(A a + K y)`
2047/// with `A = I − K e₀ᵀ`, so the fused per-node map at order 2 is
2048///
2049/// ```text
2050///   B = T A = [[R/F − δ·K₁ ,  δ ],
2051///              [   −K₁     ,  1 ]]
2052/// ```
2053///
2054/// Measured on the #2300 nodes at the ρ the certified search refused at
2055/// (`−13.841116908`): `R/F = 0.0754`, `K₁ ≈ 41.5`, `δ = 4/179`, so `δ·K₁ = 0.930`
2056/// and
2057///
2058/// ```text
2059///   true B : trace 0.145 , det 0.0754  ⇒ |eigenvalues| = √0.0754 = 0.2746
2060///   |B|    : trace 1.855 , det −0.0754 ⇒ ρ(|B|)        = 1.894
2061/// ```
2062///
2063/// **The map contracts at 0.27 per node and its entrywise absolute value
2064/// expands at 1.894.** A componentwise interval carries widths through `|B|`,
2065/// because `a₀` reaches the next `a₀` by two paths — the row-0 update, and row 1
2066/// followed by the transition — whose widths ADD as `0.0754 + 0.930` where the
2067/// exact map SUBTRACTS to `−0.855`. Over 180 nodes that is `1.894¹⁸⁰ ≈ 10⁵⁰`,
2068/// and the traced widths reproduced it exactly: `w(a₀)` ran `1e−11 → 1e20` from
2069/// node 10 to node 113 at a clean factor of 2.0 per node, while every
2070/// covariance quantity in the same pass stayed flat (`w(P₀₀) = 5.6e−13`,
2071/// `w(F) = 1e−10`, `w(Σ log F) = 7.4e−10`).
2072///
2073/// The covariance's FIRST DERIVATIVE has the same defect one tensor rank up.
2074/// `dP⁺ = A·dP·Aᵀ` and `dP⁻ = F·dP·Fᵀ − Q` make `vec(dP)` a linear recursion
2075/// with map `B ⊗ B`, whose true spectral radius is `0.27² = 0.075` and whose
2076/// componentwise companion `|B| ⊗ |B|` is `1.894² = 3.59`. Measured at
2077/// `ρ = −12.5466`, order 2: `w(dP₁₁)` runs `1.4e−2 → 7.5e2` over nodes 40..80
2078/// while `w(P₀₀)` holds at `7e−12`.
2079///
2080/// Two rearrangements do NOT help and are recorded so they are not retried:
2081/// fusing `T` and `A` first leaves `ρ(|TA|)` at the same 1.894 — the
2082/// cancellation is between entries of the product, not between the factors —
2083/// and rescaling the state cannot help either, since `ρ(|D⁻¹BD|) ≥ ρ(|B|)` for
2084/// every diagonal `D` and the natural step scaling `D = diag(1, δ)` attains it
2085/// exactly.
2086///
2087/// A zonotope keeps the cancellation because it transforms each GENERATOR by
2088/// the true map, so generator norms follow `0.27` per node and a generator is
2089/// below `ε` relative after ~35 nodes. The value covariance escaped the same
2090/// problem by being carried as a Cholesky factor; neither the mean nor `dP`
2091/// has PSD structure enough to exploit that way, but both are exactly LINEAR
2092/// with coefficients this filter already encloses tightly, which is the
2093/// hypothesis a zonotope needs and the Riccati update itself does not satisfy.
2094///
2095/// `N` is the capacity; `dim` is how much of it the current smoothing order
2096/// uses, and every loop stops there, so an order-1 scan pays `1`-dimensional
2097/// work out of a `9`-wide array.
2098#[derive(Clone, Debug)]
2099struct Zonotope<const N: usize> {
2100    center: [f64; N],
2101    /// Coefficient of the one normalized uncertainty variable shared by every
2102    /// occurrence of the certified process-noise scale `q`.
2103    ///
2104    /// This generator is structural, not part of the disposable remainder
2105    /// basis: compaction may fold independent roundoff generators, but it must
2106    /// never turn one common scalar into independent per-node errors.
2107    shared_q: [f64; N],
2108    generators: Vec<[f64; N]>,
2109    dim: usize,
2110}
2111
2112impl<const N: usize> Zonotope<N> {
2113    fn zeroed(dim: usize) -> Self {
2114        // `N` is the array capacity and `dim` indexes into it, so a `dim > N`
2115        // is an out-of-bounds every loop in this file would then commit. A
2116        // `debug_assert!` compiles to nothing in the release profile the
2117        // scan actually ships in, which is precisely where the bound stops
2118        // being checked by anything else -- hence the workspace ban.
2119        assert!(dim <= N, "zonotope dim {dim} exceeds its capacity {N}");
2120        Self {
2121            center: [0.0; N],
2122            shared_q: [0.0; N],
2123            generators: Vec::new(),
2124            dim,
2125        }
2126    }
2127
2128    /// One coordinate as an ordinary ball, for the consumers that need a scalar.
2129    fn coordinate(&self, index: usize) -> Ball {
2130        let mut radius = self.shared_q[index].abs();
2131        for generator in &self.generators {
2132            radius = next_up_ball(radius + generator[index].abs());
2133        }
2134        let value = self.center[index];
2135        Ball {
2136            value,
2137            lo: next_down_ball(value - radius),
2138            hi: next_up_ball(value + radius),
2139        }
2140    }
2141
2142    /// `x ← M x + b`, exactly on the generators, with every floating-point and
2143    /// interval radius charged into FRESH axis-aligned generators.
2144    ///
2145    /// The fresh radii are appended as `radius·eᵢ` rather than held in a
2146    /// separate box field, because a box propagated as `|M|·box` would grow at
2147    /// `ρ(|M|)` per node — reintroducing the exact defect this type exists to
2148    /// remove, on a quantity too small to notice until it is `1e11`.
2149    fn apply(&mut self, map: &[[Ball; N]; N], constant: &[Ball; N]) -> bool {
2150        self.apply_with_shared_q(map, constant, &[0.0; N])
2151    }
2152
2153    /// `x ← Mx + b + g_q θ_q`, where the SAME `θ_q ∈ [-1, 1]` is carried by
2154    /// every process-noise injection in the complete filter pass.
2155    ///
2156    /// The interval part of `b` contains only nonlinear and floating-point
2157    /// remainder. It therefore enters as fresh independent generators, while
2158    /// `g_q` is accumulated onto the distinguished generator after that
2159    /// generator has followed the signed map `M`.
2160    fn apply_with_shared_q(
2161        &mut self,
2162        map: &[[Ball; N]; N],
2163        constant: &[Ball; N],
2164        shared_q_constant: &[f64; N],
2165    ) -> bool {
2166        let dim = self.dim;
2167        let mut generator_column_sum = [0.0f64; N];
2168        for (sum, &coordinate) in generator_column_sum
2169            .iter_mut()
2170            .zip(self.shared_q.iter())
2171            .take(dim)
2172        {
2173            *sum = coordinate.abs();
2174        }
2175        for generator in &self.generators {
2176            for j in 0..dim {
2177                generator_column_sum[j] =
2178                    next_up_ball(generator_column_sum[j] + generator[j].abs());
2179            }
2180        }
2181
2182        let mut next_center = [0.0f64; N];
2183        let mut next_shared_q = [0.0f64; N];
2184        let mut fresh_radius = [0.0f64; N];
2185        for i in 0..dim {
2186            let mut center = constant[i].value;
2187            let mut shared_q = shared_q_constant[i];
2188            // Everything the roundoff of the dot products — the centre's and
2189            // every generator's — is charged against, summed once.
2190            let mut magnitude = constant[i].value.abs() + shared_q_constant[i].abs();
2191            let mut radius = ball_radius_about_value(constant[i]);
2192            for j in 0..dim {
2193                let coefficient = map[i][j].value;
2194                center += coefficient * self.center[j];
2195                shared_q += coefficient * self.shared_q[j];
2196                magnitude = next_up_ball(
2197                    magnitude
2198                        + (coefficient * self.center[j]).abs()
2199                        + coefficient.abs() * generator_column_sum[j],
2200                );
2201                radius = next_up_ball(
2202                    radius
2203                        + ball_radius_about_value(map[i][j])
2204                            * (self.center[j].abs() + generator_column_sum[j]),
2205                );
2206            }
2207            next_center[i] = center;
2208            next_shared_q[i] = shared_q;
2209            fresh_radius[i] = next_up_ball(
2210                (radius + ZONOTOPE_ROUNDOFF * magnitude) * (1.0 + 64.0 * f64::EPSILON),
2211            );
2212        }
2213
2214        for generator in self.generators.iter_mut() {
2215            let previous = *generator;
2216            for i in 0..dim {
2217                let mut coordinate = 0.0f64;
2218                for j in 0..dim {
2219                    coordinate += map[i][j].value * previous[j];
2220                }
2221                generator[i] = coordinate;
2222            }
2223        }
2224
2225        self.center = next_center;
2226        self.shared_q = next_shared_q;
2227        for i in 0..dim {
2228            if fresh_radius[i] > 0.0 {
2229                let mut axis = [0.0f64; N];
2230                axis[i] = fresh_radius[i];
2231                self.generators.push(axis);
2232            }
2233        }
2234        self.compact();
2235        self.center[..dim].iter().all(|value| value.is_finite())
2236            && self.shared_q[..dim].iter().all(|value| value.is_finite())
2237            && self
2238                .generators
2239                .iter()
2240                .all(|generator| generator[..dim].iter().all(|value| value.is_finite()))
2241    }
2242
2243    /// Reduce the lowest-correlation generators to one axis-aligned set once
2244    /// the list is over the cap.
2245    ///
2246    /// Age is not a sound proxy for dispensability here.  The order-3 closed
2247    /// loop contracts through a rotation of its dominant directions; after ten
2248    /// nodes an old generator can still be large and strongly non-axis-aligned.
2249    /// Folding it merely because it is old discards precisely that correlation
2250    /// and sends its width through `|M|`, recreating the wrapping effect this
2251    /// zonotope exists to avoid.
2252    ///
2253    /// The reduction score `||g||₁ - ||g||∞` is zero for an axis generator and
2254    /// grows with the correlation that an axis box would discard.  Therefore
2255    /// the lowest-scoring generators are the loss-minimizing ones to fold, and
2256    /// the correlation-bearing generators remain explicit.  The folded box is
2257    /// still an outer zonotope: each coordinate radius is the outward-rounded
2258    /// sum of the folded generators' absolute coordinates.
2259    fn compact(&mut self) {
2260        if self.generators.len() <= ZONOTOPE_GENERATOR_CAP {
2261            return;
2262        }
2263        let dim = self.dim;
2264        let reduction_score = |generator: &[f64; N]| {
2265            let mut l1 = 0.0_f64;
2266            let mut linf = 0.0_f64;
2267            for &coordinate in generator.iter().take(dim) {
2268                let magnitude = coordinate.abs();
2269                l1 += magnitude;
2270                linf = linf.max(magnitude);
2271            }
2272            (l1 - linf).max(0.0)
2273        };
2274        self.generators
2275            .sort_by(|left, right| reduction_score(left).total_cmp(&reduction_score(right)));
2276        let fold = self.generators.len() - ZONOTOPE_GENERATOR_CAP / 2;
2277        let retained = self.generators.split_off(fold);
2278        let mut folded = [0.0f64; N];
2279        for generator in &self.generators {
2280            for i in 0..dim {
2281                folded[i] = next_up_ball(folded[i] + generator[i].abs());
2282            }
2283        }
2284        let mut next = Vec::with_capacity(retained.len() + dim);
2285        for i in 0..dim {
2286            if folded[i] > 0.0 {
2287                let mut axis = [0.0f64; N];
2288                axis[i] = folded[i];
2289                next.push(axis);
2290            }
2291        }
2292        next.extend(retained);
2293        self.generators = next;
2294    }
2295}
2296
2297/// The identity map over the first `dim` coordinates.
2298fn zonotope_identity_map<const N: usize>(dim: usize) -> [[Ball; N]; N] {
2299    let mut map = [[Ball::ZERO; N]; N];
2300    for (i, row) in map.iter_mut().enumerate().take(dim) {
2301        row[i] = Ball::ONE;
2302    }
2303    map
2304}
2305
2306/// Write an `order × order` block of the stacked MEAN map at block row/column
2307/// `(block_row, block_column)`. The mean layout is `block·order + i`.
2308fn mean_set_block(
2309    map: &mut [[Ball; MEAN_DIM]; MEAN_DIM],
2310    block_row: usize,
2311    block_column: usize,
2312    block: &BallMat,
2313    order: usize,
2314) {
2315    for i in 0..order {
2316        for j in 0..order {
2317            map[block_row * order + i][block_column * order + j] = block[i][j];
2318        }
2319    }
2320}
2321
2322/// The congruence `X ↦ L·X·Rᵀ` as a linear map on `vec(X)`, i.e. `L ⊗ R`.
2323///
2324/// This is what makes `dP` carryable: the derivative's update and prediction
2325/// are congruences by matrices built from the VALUE covariance, which this
2326/// filter already encloses to `1e−12`, so the map's own entries are tight and
2327/// only the state needs the generators. The `vec` layout is `i·order + j`.
2328fn zonotope_congruence_map(
2329    left: &BallMat,
2330    right: &BallMat,
2331    order: usize,
2332) -> [[Ball; COVARIANCE_D1_DIM]; COVARIANCE_D1_DIM] {
2333    let mut map = [[Ball::ZERO; COVARIANCE_D1_DIM]; COVARIANCE_D1_DIM];
2334    for i in 0..order {
2335        for j in 0..order {
2336            for k in 0..order {
2337                for l in 0..order {
2338                    map[i * order + j][k * order + l] = left[i][k].mul(right[j][l]);
2339                }
2340            }
2341        }
2342    }
2343    map
2344}
2345
2346/// Project a matrix-valued zonotope onto the exact symmetric subspace.
2347///
2348/// Covariances and every one of their parameter derivatives are symmetric
2349/// exact-real matrices. If `x` is the witness point in the incoming zonotope,
2350/// then `Sx = x` for the symmetrizer `S`; applying `S` to every generator
2351/// therefore preserves that witness while deleting enclosure directions that
2352/// violate a model identity.
2353fn project_symmetric_zonotope(state: &mut Zonotope<COVARIANCE_D1_DIM>, order: usize) -> bool {
2354    if order == 1 {
2355        return true;
2356    }
2357    let mut projection = [[Ball::ZERO; COVARIANCE_D1_DIM]; COVARIANCE_D1_DIM];
2358    for i in 0..order {
2359        for j in 0..order {
2360            let row = i * order + j;
2361            if i == j {
2362                projection[row][row] = Ball::ONE;
2363            } else {
2364                projection[row][i * order + j] = Ball::exact(0.5);
2365                projection[row][j * order + i] = Ball::exact(0.5);
2366            }
2367        }
2368    }
2369    state.apply(&projection, &[Ball::ZERO; COVARIANCE_D1_DIM])
2370}
2371
2372/// Read `vec(X)` back out as a matrix.
2373fn zonotope_to_matrix(state: &Zonotope<COVARIANCE_D1_DIM>, order: usize) -> BallMat {
2374    let mut out = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
2375    for i in 0..order {
2376        for j in 0..order {
2377            out[i][j] = state.coordinate(i * order + j);
2378        }
2379    }
2380    out
2381}
2382
2383/// Apply one scalar-observation Riccati update to a covariance zonotope.
2384///
2385/// Applying the Joseph map with an interval-valued gain is sound but useless:
2386/// the gain is a function of the SAME covariance, while a generic interval
2387/// matrix application ranges the two independently.  Its resulting first-order
2388/// error is fed back into the next gain and recreates componentwise Riccati
2389/// wrapping.
2390///
2391/// Instead linearize the Riccati map at the zonotope centre and enclose its
2392/// exact, signed second-order remainder.  With centre column `c`, centre
2393/// innovation `f`, `k = c/f`, covariance perturbation `E`, observation-variance
2394/// perturbation `dr`, `d = E₀₀ + dr`, and
2395///
2396/// ```text
2397/// u = E e₀ - k d,
2398/// ```
2399///
2400/// direct expansion gives the IDENTITY
2401///
2402/// ```text
2403/// U(C + E, r + dr)
2404///   = A (C + E) Aᵀ + (r + dr) k kᵀ - u uᵀ / (f + d),
2405///   A = I - k e₀ᵀ.
2406/// ```
2407///
2408/// Thus every existing generator follows the contracting derivative map
2409/// `E ↦ AEAᵀ`; only the genuinely quadratic remainder becomes fresh interval
2410/// error.  This preserves the covariance/gain dependency rather than treating
2411/// it as uncertainty.
2412fn covariance_zonotope_measurement_update(
2413    state: &mut Zonotope<COVARIANCE_D1_DIM>,
2414    r: Ball,
2415    order: usize,
2416) -> bool {
2417    let centre_r = Ball::exact(r.value);
2418    let centre_f = Ball::exact(state.center[0]).add(centre_r);
2419    if !(centre_f.is_finite() && centre_f.lo > 0.0) {
2420        return false;
2421    }
2422
2423    let mut centre_gain = [Ball::ZERO; MAX_ORDER];
2424    let mut column_error = [Ball::ZERO; MAX_ORDER];
2425    for i in 0..order {
2426        centre_gain[i] = Ball::exact(state.center[i * order]).div_positive(centre_f);
2427        let coordinate = state.coordinate(i * order);
2428        let radius = ball_radius_about_value(coordinate);
2429        column_error[i] = Ball {
2430            value: 0.0,
2431            lo: -radius,
2432            hi: radius,
2433        };
2434    }
2435    let r_error = Ball {
2436        value: 0.0,
2437        lo: next_down_ball(r.lo - r.value),
2438        hi: next_up_ball(r.hi - r.value),
2439    };
2440    let denominator_error = column_error[0].add(r_error);
2441    let denominator = centre_f.add(denominator_error);
2442    if !(denominator.is_finite() && denominator.lo > 0.0) {
2443        return false;
2444    }
2445
2446    let mut remainder_vector = [Ball::ZERO; MAX_ORDER];
2447    for i in 0..order {
2448        remainder_vector[i] = column_error[i].sub(centre_gain[i].mul(denominator_error));
2449    }
2450    let operator = ball_update_operator(&centre_gain, centre_r.div_positive(centre_f), order);
2451    let mut constant = [Ball::ZERO; COVARIANCE_D1_DIM];
2452    for i in 0..order {
2453        for j in 0..order {
2454            let quadratic_remainder = remainder_vector[i]
2455                .mul(remainder_vector[j])
2456                .div_positive(denominator)
2457                .neg();
2458            constant[i * order + j] = r
2459                .mul(centre_gain[i])
2460                .mul(centre_gain[j])
2461                .add(quadratic_remainder);
2462        }
2463    }
2464    state.apply(
2465        &zonotope_congruence_map(&operator, &operator, order),
2466        &constant,
2467    )
2468}
2469
2470/// Diagonal names for the traced covariance jets, indexed by state coordinate.
2471const D3_DIAGONAL_NAMES: [&str; MAX_ORDER] = ["d3_upd_00", "d3_upd_11", "d3_upd_22"];
2472const D2_DIAGONAL_NAMES: [&str; MAX_ORDER] = ["d2_upd_00", "d2_upd_11", "d2_upd_22"];
2473const D1_DIAGONAL_NAMES: [&str; MAX_ORDER] = ["d1_upd_00", "d1_upd_11", "d1_upd_22"];
2474const P_DIAGONAL_NAMES: [&str; MAX_ORDER] = ["p_upd_00", "p_upd_11", "p_upd_22"];
2475/// Kalman gain coordinates, so a caller can rebuild the closed-loop map.
2476const GAIN_NAMES: [&str; MAX_ORDER] = ["gain_0", "gain_1", "gain_2"];
2477/// Full PREDICTED covariance, so a caller can weigh that map by the matrix the
2478/// Riccati recursion makes a Lyapunov matrix for it.
2479const P_NEXT_ENTRY_NAMES: [[&str; MAX_ORDER]; MAX_ORDER] = [
2480    ["p_next_00", "p_next_01", "p_next_02"],
2481    ["p_next_10", "p_next_11", "p_next_12"],
2482    ["p_next_20", "p_next_21", "p_next_22"],
2483];
2484
2485fn run_filter_ball_traced(
2486    nodes: &[PooledNode],
2487    q: Ball,
2488    order: usize,
2489    mut trace: Option<&mut Vec<BallTraceRecord>>,
2490) -> Result<BallFilterPass, SplineScoreProofError> {
2491    // `(a, a′)` together, `vec(P)` once the covariance becomes proper, and
2492    // `vec(dP/dρ)`, each as a zonotope; see `Zonotope` for why a componentwise
2493    // enclosure of these contracting signed recursions is impossible at any
2494    // width.
2495    let mut mean = Zonotope::<MEAN_DIM>::zeroed(MEAN_BLOCKS * order);
2496    // Start at the exact zero proper covariance and carry it even while the
2497    // diffuse rank is being consumed. Seeding only after the diffuse phase
2498    // would already have boxed the first `order - 1` occurrences of the common
2499    // process-noise scale, so their correlation could never be recovered.
2500    let mut covariance = Zonotope::<COVARIANCE_D1_DIM>::zeroed(order * order);
2501    let mut covariance_d1 = Zonotope::<COVARIANCE_D1_DIM>::zeroed(order * order);
2502    let mut a_d2: BallVec = [Ball::ZERO; MAX_ORDER];
2503    let mut a_d3: BallVec = [Ball::ZERO; MAX_ORDER];
2504    let mut p_star: BallMat = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
2505    let mut p_star_d2: BallMat = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
2506    let mut p_star_d3: BallMat = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
2507    let mut p_inf: BallMat = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
2508    for i in 0..order {
2509        p_inf[i][i] = Ball::ONE;
2510    }
2511    let mut diffuse_rank = order;
2512    // A CARRIED Cholesky factor of the proper covariance, once the diffuse rank
2513    // is consumed and there is a proper covariance to factor. The componentwise
2514    // recursion still runs; this is a second, independent enclosure of the same
2515    // matrix whose update and prediction contain no cancelling subtraction, and
2516    // the two are intersected. `None` means no evidence, never a refusal.
2517    let mut carried_factor: Option<BallMat> = None;
2518    let mut sum_log_f = Ball::ZERO;
2519    let mut sum_log_f_d1 = Ball::ZERO;
2520    let mut sum_log_f_d2 = Ball::ZERO;
2521    let mut sum_log_f_d3 = Ball::ZERO;
2522    let mut sum_v2_over_f = Ball::ZERO;
2523    let mut sum_v2_over_f_d1 = Ball::ZERO;
2524    let mut sum_v2_over_f_d2 = Ball::ZERO;
2525    let mut sum_v2_over_f_d3 = Ball::ZERO;
2526    let mut n_proper = 0usize;
2527    // `Σ w y²` over the prefix — the exact ceiling on the accumulated
2528    // innovations quadratic (see
2529    // [`intersect_first_order_accumulator_exact_ranges`]). Accumulated at EVERY
2530    // node, including the ones that consume diffuse rank, because the quadratic
2531    // it bounds is the restricted form for the whole prefix and not only for the
2532    // nodes that contributed a proper innovation.
2533    let mut weighted_energy = Ball::ZERO;
2534
2535    for t in 0..nodes.len() {
2536        let r = Ball::ONE.div_positive(Ball::exact(nodes[t].w));
2537        weighted_energy = weighted_energy.add(
2538            Ball::exact(nodes[t].y)
2539                .square()
2540                .mul(Ball::exact(nodes[t].w)),
2541        );
2542        let v = Ball::exact(nodes[t].y).sub(mean.coordinate(0));
2543        let v_d1 = mean.coordinate(order).neg();
2544        let v_d2 = a_d2[0].neg();
2545        let v_d3 = a_d3[0].neg();
2546        // `dP/dρ` materialized from its zonotope for this node's consumers.
2547        let p_star_d1 = zonotope_to_matrix(&covariance_d1, order);
2548        let mut m_star: BallVec = [Ball::ZERO; MAX_ORDER];
2549        let mut m_star_d1: BallVec = [Ball::ZERO; MAX_ORDER];
2550        let mut m_star_d2: BallVec = [Ball::ZERO; MAX_ORDER];
2551        let mut m_star_d3: BallVec = [Ball::ZERO; MAX_ORDER];
2552        for i in 0..order {
2553            m_star[i] = p_star[i][0];
2554            m_star_d1[i] = p_star_d1[i][0];
2555            m_star_d2[i] = p_star_d2[i][0];
2556            m_star_d3[i] = p_star_d3[i][0];
2557        }
2558        let mut f_star = m_star[0].add(r);
2559        intersect_innovation_above_observation_variance(&mut f_star, r);
2560        let f_star_d1 = m_star_d1[0];
2561        let f_star_d2 = m_star_d2[0];
2562        let f_star_d3 = m_star_d3[0];
2563
2564        let mut proper_update = diffuse_rank == 0;
2565        if diffuse_rank > 0 {
2566            let mut m_inf: BallVec = [Ball::ZERO; MAX_ORDER];
2567            for i in 0..order {
2568                m_inf[i] = p_inf[i][0];
2569            }
2570            let f_inf = m_inf[0];
2571            if f_inf.lo == 0.0 && f_inf.hi == 0.0 {
2572                // The observation is exactly orthogonal to the remaining
2573                // diffuse subspace. It receives an ordinary proper update
2574                // without consuming diffuse rank.
2575                proper_update = true;
2576            } else {
2577                require_positive_innovation(t, SplineInnovationKind::Diffuse, f_inf)?;
2578            }
2579            if !proper_update {
2580                let inv_f_inf = Ball::ONE.div_positive(f_inf);
2581                let inv_f_inf_sq = inv_f_inf.square();
2582                let mut gain_inf: BallVec = [Ball::ZERO; MAX_ORDER];
2583                for i in 0..order {
2584                    gain_inf[i] = m_inf[i].mul(inv_f_inf);
2585                    a_d2[i] = a_d2[i].add(gain_inf[i].mul(v_d2));
2586                    a_d3[i] = a_d3[i].add(gain_inf[i].mul(v_d3));
2587                }
2588                // `a⁺ = A_inf·a + K_inf·y` and `a′⁺ = A_inf·a′`, with
2589                // `A_inf = I − K_inf e₀ᵀ`: the diffuse update is the same
2590                // operator on both blocks and the jet block carries no `y`,
2591                // because `v′ = −a′₀` has no data term to pick up.
2592                // `K_inf[0] = M_inf[0]/F_inf = 1` identically, since `F_inf` IS
2593                // `M_inf[0]`, so the operator's own diagonal entry is an exact
2594                // zero rather than a `1 − K₀` subtraction.
2595                let a_inf = ball_update_operator(&gain_inf, Ball::ZERO, order);
2596                let mut diffuse_map = zonotope_identity_map::<MEAN_DIM>(MEAN_BLOCKS * order);
2597                mean_set_block(&mut diffuse_map, 0, 0, &a_inf, order);
2598                mean_set_block(&mut diffuse_map, 1, 1, &a_inf, order);
2599                let mut diffuse_constant = [Ball::ZERO; MEAN_DIM];
2600                let y_node = Ball::exact(nodes[t].y);
2601                for i in 0..order {
2602                    diffuse_constant[i] = gain_inf[i].mul(y_node);
2603                }
2604                if !mean.apply(&diffuse_map, &diffuse_constant) {
2605                    return Err(SplineScoreProofError::InvalidArithmetic {
2606                        context: "diffuse mean zonotope",
2607                    });
2608                }
2609                let mut p_new = p_star;
2610                let mut p_new_d2 = p_star_d2;
2611                let mut p_new_d3 = p_star_d3;
2612                for i in 0..order {
2613                    for j in 0..order {
2614                        let inf_product = m_inf[i].mul(m_inf[j]);
2615                        let subtract_left = m_inf[i].mul(m_star[j]).mul(inv_f_inf);
2616                        let subtract_right = m_star[i].mul(m_inf[j]).mul(inv_f_inf);
2617                        let add_star = inf_product.mul(f_star).mul(inv_f_inf_sq);
2618                        p_new[i][j] = p_new[i][j]
2619                            .sub(subtract_left)
2620                            .sub(subtract_right)
2621                            .add(add_star);
2622
2623                        let subtract_left_d2 = m_inf[i].mul(m_star_d2[j]).mul(inv_f_inf);
2624                        let subtract_right_d2 = m_star_d2[i].mul(m_inf[j]).mul(inv_f_inf);
2625                        p_new_d2[i][j] = p_new_d2[i][j]
2626                            .sub(subtract_left_d2)
2627                            .sub(subtract_right_d2)
2628                            .add(inf_product.mul(f_star_d2).mul(inv_f_inf_sq));
2629
2630                        let subtract_left_d3 = m_inf[i].mul(m_star_d3[j]).mul(inv_f_inf);
2631                        let subtract_right_d3 = m_star_d3[i].mul(m_inf[j]).mul(inv_f_inf);
2632                        p_new_d3[i][j] = p_new_d3[i][j]
2633                            .sub(subtract_left_d3)
2634                            .sub(subtract_right_d3)
2635                            .add(inf_product.mul(f_star_d3).mul(inv_f_inf_sq));
2636                    }
2637                }
2638                // The diffuse covariance update is the fixed-gain Joseph map
2639                //
2640                //     P⁺ = A_inf P A_infᵀ + r K_inf K_infᵀ.
2641                //
2642                // It is affine in the proper covariance, so the value
2643                // zonotope can and must follow it from the exact zero state.
2644                // Waiting until diffuse rank reaches zero would box the first
2645                // process-noise injections before the shared-q generator even
2646                // existed.
2647                let mut covariance_constant = [Ball::ZERO; COVARIANCE_D1_DIM];
2648                for i in 0..order {
2649                    for j in 0..order {
2650                        covariance_constant[i * order + j] = r.mul(gain_inf[i]).mul(gain_inf[j]);
2651                    }
2652                }
2653                if !covariance.apply(
2654                    &zonotope_congruence_map(&a_inf, &a_inf, order),
2655                    &covariance_constant,
2656                ) || !project_symmetric_zonotope(&mut covariance, order)
2657                {
2658                    return Err(SplineScoreProofError::InvalidArithmetic {
2659                        context: "diffuse covariance zonotope",
2660                    });
2661                }
2662                let covariance_p_new = zonotope_to_matrix(&covariance, order);
2663                for i in 0..order {
2664                    for j in 0..order {
2665                        intersect_with_independent_enclosure(
2666                            &mut p_new[i][j],
2667                            covariance_p_new[i][j],
2668                        );
2669                    }
2670                }
2671                // `D1+ = A_inf*D1*A_inf'` EXACTLY: expanding that congruence
2672                // gives `D1[i][j] - c_j D1[i][0] - c_i D1[0][j] + c_i c_j D1[0][0]`
2673                // with `c = M_inf/F_inf`, which is the diffuse update term for
2674                // term. The other jets keep the expanded form because they are
2675                // not carried as zonotopes.
2676                if !covariance_d1.apply(
2677                    &zonotope_congruence_map(&a_inf, &a_inf, order),
2678                    &[Ball::ZERO; COVARIANCE_D1_DIM],
2679                ) || !project_symmetric_zonotope(&mut covariance_d1, order)
2680                {
2681                    return Err(SplineScoreProofError::InvalidArithmetic {
2682                        context: "diffuse covariance-derivative zonotope",
2683                    });
2684                }
2685                p_star = p_new;
2686                p_star_d2 = p_new_d2;
2687                p_star_d3 = p_new_d3;
2688                ball_symmetrize(&mut p_star, order);
2689                ball_symmetrize(&mut p_star_d2, order);
2690                ball_symmetrize(&mut p_star_d3, order);
2691                for i in 0..order {
2692                    for j in 0..order {
2693                        p_inf[i][j] = p_inf[i][j].sub(m_inf[i].mul(m_inf[j]).mul(inv_f_inf));
2694                    }
2695                }
2696                ball_symmetrize(&mut p_inf, order);
2697                diffuse_rank -= 1;
2698                if diffuse_rank == 0 {
2699                    p_inf = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
2700                    intersect_proper_covariance_psd(&mut p_star, order)?;
2701                    carried_factor = ball_cholesky(&p_star, order);
2702                }
2703            }
2704        }
2705
2706        if proper_update {
2707            require_positive_innovation(t, SplineInnovationKind::Proper, f_star)?;
2708            let inv_f = Ball::ONE.div_positive(f_star);
2709            let mut gain = [Ball::ZERO; MAX_ORDER];
2710            for i in 0..order {
2711                gain[i] = m_star[i].mul(inv_f);
2712            }
2713            // The VALUE covariance through the same Joseph form as its jets.
2714            //
2715            // `P⁺ = P − M Mᵀ/F` is the textbook update and it is a subtraction
2716            // of near-equal quantities exactly where this filter lives. At
2717            // `ρ = −24` on the #2300 nodes the predicted `P₀₀` is `1.84e5` and
2718            // `M₀²/F` is `1.84e5`; their difference is `1.0`, so the enclosure
2719            // of a quantity of size one is charged the rounding of quantities
2720            // `1.8e5` times larger, and the ratio is `F/R = P₀₀/R + 1`, which
2721            // GROWS with the process noise.
2722            //
2723            // The Joseph form `P⁺ = A P Aᵀ + R K Kᵀ` (`A = I − K e₀ᵀ`) is the
2724            // same quantity, and at the observed coordinate it is
2725            //
2726            //     (A P Aᵀ)₀₀ + R K₀² = (R/F)²P₀₀ + R P₀₀²/F²
2727            //                        = P₀₀ R (R + P₀₀)/F² = P₀₀ R/F,
2728            //
2729            // a sum of two POSITIVE terms — the exact `P⁺₀₀`, with no
2730            // subtraction anywhere in it. `A[0][0] = R/F` is itself formed
2731            // exactly rather than as `1 − K₀` (see `ball_update_operator`).
2732            //
2733            // The measurement that forced this (#2614, per-node trace at
2734            // `ρ = −24`, order 2). `P⁺₀₀` holds the VALUE `0.99999456` at every
2735            // node while its enclosure WIDTH runs `1.6e-1, 2.3e0, 3.1e1, 3.8e2,
2736            // 4.7e3, 5.9e4, 9.2e5` over nodes 6..12 — a factor of ~13 per node,
2737            // on a quantity of size one. `F` inherits it, so `inv_f`'s enclosure
2738            // width reaches `1.0` at node 13 against a value of `5.4e-6`; at
2739            // that node `A[0][0]` stops being a contraction and EVERY derivative
2740            // jet leaves the finite range in a single step (`d3⁺₀₀` width
2741            // `1.7e-3 → 4.5e19`). The `d3` recursion was never the defect: its
2742            // own congruence contracts by `(R/F)² ≈ 3e-11` per node exactly as
2743            // intended, and its VALUES are bit-stable throughout. It diverges
2744            // because the value covariance it multiplies by had already lost its
2745            // enclosure.
2746            let a_operator = ball_update_operator(&gain, r.mul(inv_f), order);
2747            let mut component_p_new = ball_congruence(&a_operator, &p_star, &a_operator, order);
2748            for i in 0..order {
2749                for j in 0..order {
2750                    component_p_new[i][j] = component_p_new[i][j].add(gain[i].mul(gain[j]).mul(r));
2751                }
2752            }
2753            // Carry the covariance through the Riccati map's exact centred
2754            // Taylor form. Its linear part is the signed Joseph congruence and
2755            // its remainder is quadratic in the covariance radius; see
2756            // `covariance_zonotope_measurement_update`.
2757            if !covariance_zonotope_measurement_update(&mut covariance, r, order)
2758                || !project_symmetric_zonotope(&mut covariance, order)
2759            {
2760                return Err(SplineScoreProofError::InvalidArithmetic {
2761                    context: "proper covariance zonotope update",
2762                });
2763            }
2764            let mut p_new = zonotope_to_matrix(&covariance, order);
2765            for i in 0..order {
2766                for j in 0..order {
2767                    intersect_with_independent_enclosure(&mut p_new[i][j], component_p_new[i][j]);
2768                }
2769            }
2770            // ... and then tightened to the EXACT range of the map each entry
2771            // is, which the componentwise evaluation above cannot see because
2772            // `A`, `K` and the middle factor all carry the same `P₀₀`.
2773            for i in 0..order {
2774                for j in 0..order {
2775                    if i == j {
2776                        // On the DIAGONAL the subtracted term is `M[i]²/F`, a
2777                        // SQUARE. The general corner form ranges `P[i][0]` and
2778                        // `P[0][j]` independently, so when that entry straddles
2779                        // zero it not only doubles the width but admits a
2780                        // NEGATIVE product — an upper bound larger than
2781                        // `P[i][i]` itself — and its monotonicity argument then
2782                        // bails out entirely. `Ball::square` returns `[0, max²]`
2783                        // for a straddling interval, which is the exact range.
2784                        intersect_with_independent_enclosure(
2785                            &mut p_new[i][i],
2786                            p_star[i][i].sub(m_star[i].square().div_positive(f_star)),
2787                        );
2788                    }
2789                    intersect_updated_covariance_exact_range(
2790                        &mut p_new[i][j],
2791                        p_star[i][j],
2792                        m_star[i],
2793                        m_star[j],
2794                        m_star[0],
2795                        r,
2796                    );
2797                }
2798            }
2799            // The first column of the UPDATED covariance is EXACTLY `R·K`, and
2800            // the file already relies on that identity for every derivative
2801            // gain: `M⁺ = P⁺e₀ = M − M·P₀₀/F = M·(F − P₀₀)/F = M·R/F = R·K`.
2802            // The VALUE covariance was not using it. `P⁺[0][j] = P[0][j]·R/F`
2803            // is a PRODUCT — no subtraction, and no shared variable appearing
2804            // twice, which is what the corner form above cannot exploit: it
2805            // ranges `P[0][j]` as both `a` and `c` independently even though
2806            // they are one entry, and so cannot see the factorization at all.
2807            //
2808            // Measured at order 2, ρ = −13.8411 (the log-lambda the #2300
2809            // search refuses at), node 31: `P⁺₀₁` carries value `41.56` with
2810            // width `1.06e3`, and `P⁺₁₁` value `1.14e4` with width `3.55e8`,
2811            // while `P⁺₀₀` — the one entry that already had its exact range —
2812            // is `0.92 ± 0.99`. The unobserved entries are the runaway, and the
2813            // transition feeds them straight back into the observed one as
2814            // `P₀₀ + 2δP₀₁ + δ²P₁₁`.
2815            for i in 0..order {
2816                let exact = gain[i].mul(r);
2817                intersect_with_independent_enclosure(&mut p_new[i][0], exact);
2818                if i != 0 {
2819                    intersect_with_independent_enclosure(&mut p_new[0][i], exact);
2820                }
2821            }
2822            intersect_observed_covariance_exact_range(&mut p_new[0][0], m_star[0], r);
2823            // An update can only remove variance: `P⁺ = P⁻ − M Mᵀ/F ⪯ P⁻`, so
2824            // every diagonal is bounded above by the one it came from. The
2825            // corner form above cannot see this, because it ranges `P[i][0]`
2826            // and `P[0][j]` independently even when they are the same entry.
2827            for i in 0..order {
2828                let ceiling = p_star[i][i].hi.max(p_new[i][i].value);
2829                if p_new[i][i].hi > ceiling {
2830                    p_new[i][i].hi = ceiling;
2831                }
2832            }
2833            intersect_covariance_minors(&mut p_new, order);
2834            // The same update through the CARRIED factor, where it is one
2835            // column scaling and contains no subtraction at all.
2836            if carried_factor.is_none() {
2837                carried_factor = ball_cholesky(&p_star, order);
2838            }
2839            if let (Some(factor), Some(beta)) = (carried_factor, ball_sqrt(r.mul(inv_f))) {
2840                let updated_factor = ball_factor_update(&factor, beta, order);
2841                let gram = ball_factor_gram(&updated_factor, order);
2842                for i in 0..order {
2843                    for j in 0..order {
2844                        intersect_with_independent_enclosure(&mut p_new[i][j], gram[i][j]);
2845                    }
2846                }
2847                carried_factor = Some(updated_factor);
2848            } else {
2849                carried_factor = None;
2850            }
2851
2852            // Derivative covariances through the JOSEPH form, which for the
2853            // derivative jets is not a reformulation but an exact cancellation
2854            // (#2614).
2855            //
2856            // Writing `A = I − K e₀ᵀ`, the update is `P⁺ = A P` and the Joseph
2857            // form `P⁺ = A P Aᵀ + R K Kᵀ` is algebraically identical. `R` is
2858            // `1/wₜ` — data, not a function of ρ — so differentiating the
2859            // Joseph form gives
2860            //
2861            //     dP⁺ = A dP Aᵀ + dK·(R Kᵀ − M⁺ᵀ) + (R K − M⁺)·dKᵀ
2862            //
2863            // and the first column of the UPDATED covariance is
2864            //
2865            //     M⁺ = P⁺e₀ = M − M·P₀₀/F = M·(F − P₀₀)/F = M·R/F = R·K
2866            //
2867            // so `R K − M⁺ = 0` EXACTLY and both `dK` terms vanish:
2868            //
2869            //     dP⁺ = A · dP · Aᵀ
2870            //
2871            // The first derivative is a pure congruence of the derivative, and
2872            // `dK` does not enter it at all. That matters here because the
2873            // subtractive form this replaces built each `s_k` from products of
2874            // derivative quantities divided by `F`, so an interval evaluation
2875            // widened multiplicatively at every one of the ~180 updates until
2876            // the accumulators saturated at `[−∞, +∞]` while their centres
2877            // stayed exact. The congruence enters the derivative LINEARLY with
2878            // value-side coefficients, and in the scalar case reads
2879            // `dP⁺/dρ = (R/F)²·dP⁻/dρ` with `0 < R/F < 1` — a contraction where
2880            // the old form accumulated a cancelling difference.
2881            //
2882            // Nothing here is a bound, a tolerance or a restored invariant: it
2883            // is the same quantity through an expression that does not cancel,
2884            // so it cannot make a non-stationary point certify.
2885            let d1_pred = p_star_d1;
2886            let d2_pred = p_star_d2;
2887            let d3_pred = p_star_d3;
2888            // Gain jets from the UPDATED covariance, not from a subtractive
2889            // recursion (#2614, second measurement).
2890            //
2891            // The identity that made `dP+ = A dP A^T` exact gives the gain
2892            // derivatives for free. `M+ = R*K` with `R = 1/w_t` constant in rho,
2893            // so `K = M+/R` and
2894            //
2895            //     d^k K / drho^k  =  (d^k M+ / drho^k) / R
2896            //
2897            // Every gain jet is a column of the corresponding UPDATED covariance
2898            // jet divided by a constant: no subtraction, no division by `F`, no
2899            // accumulation of derivative-times-derivative products.
2900            //
2901            // The measurement that forced this. After the covariance jets became
2902            // congruences, the refusal -- now carrying its own evidence -- named
2903            // the first accumulator to leave the finite range as
2904            // `sum_v2_over_f_d3`, at node 63 of 179, q = 1.641e7, value
2905            // 6.766e-5, enclosure [-inf, +inf]. That accumulator is fed by the
2906            // MEAN derivative chain `a_d3 -> v_d3 -> vv_d3`, which consumes
2907            // `gain_d3` -- the one recursion the congruence rewrite left alone,
2908            // still built as three subtractions of derivative products divided
2909            // by `F`, once per node.
2910            //
2911            // The staging is well founded, not circular: `d1` needs only `gain`;
2912            // `gain_d1` is then `p_new_d1`'s first column; `d2` needs `gain_d1`;
2913            // and so on. Each jet exists exactly when the next one needs it,
2914            // which is also why the mean update now follows this block.
2915            // `A[0][0] = 1 − K₀ = R/F` EXACTLY — never `1 − K₀` as a
2916            // subtraction. See `ball_update_operator`: that single entry is
2917            // where the `d3` jet was losing everything, and the measurement
2918            // that found it is on #2614 (`F'''` at `+/-4.1e247` by node 62,
2919            // ~`10^4` per node). The same operator carries the VALUE covariance
2920            // above, for the same reason.
2921            //
2922            // That measurement also reported `d1` and `d2` as clean, and this
2923            // comment used to say so without qualification. THAT IS FALSE as a
2924            // general statement, and it stood long enough to send a later lane
2925            // looking inside the `d3` recursion for a defect that was never
2926            // there. It was a reading at ONE rho. Measured at `rho = -13.8411`
2927            // on the same nodes at order 2, the per-node enclosure WIDTHS over
2928            // nodes 40..43 are
2929            //
2930            //     d1: 1.36e46  -> 2.34e49  -> 4.43e52   -> 9.18e55    (x1.7e3)
2931            //     d2: 1.06e93  -> 3.12e99  -> 1.11e106  -> 4.80e112   (x3e6)
2932            //     d3: 1.24e140 -> 6.23e149 -> 4.21e159  -> 3.76e169   (x6e9)
2933            //
2934            // — exponents exactly 1 : 2 : 3, so `d3` is `d1`'s growth CUBED and
2935            // not a defect of its own. At node 40 the congruence `A D3 Aᵀ` is
2936            // 5.17e133 against a predicted `d3` of 1.68e135, i.e. it CONTRACTS
2937            // by 32x, while the two terms carrying `D1` are the large ones.
2938            // Every value is stable to 15 digits throughout. The repair that
2939            // followed is the carried factor below, not anything in this block.
2940
2941            // dP⁺ = A · dP · Aᵀ  (the `dK` terms cancel exactly, since M⁺ = R·K)
2942            //
2943            // Applied to the ZONOTOPE, not to a matrix of intervals. This is
2944            // the same recursion the carried `−dP` Cholesky factor used to
2945            // guard, and the factor is gone with it: a factor can only be
2946            // re-seeded from an enclosure that still PROVES `−dP ⪰ 0`, so once
2947            // the componentwise widths grew past that it could not come back,
2948            // which is exactly the window the trace shows (`w(dP₁₁)` 1.4e−2 at
2949            // node 40, 7.5e2 at node 80, back to 4e−9 at node 160 when a
2950            // re-seed finally succeeded). A zonotope needs no re-seeding
2951            // because it never loses the structure in the first place.
2952            if !covariance_d1.apply(
2953                &zonotope_congruence_map(&a_operator, &a_operator, order),
2954                &[Ball::ZERO; COVARIANCE_D1_DIM],
2955            ) || !project_symmetric_zonotope(&mut covariance_d1, order)
2956            {
2957                return Err(SplineScoreProofError::InvalidArithmetic {
2958                    context: "covariance-derivative zonotope update",
2959                });
2960            }
2961            let mut p_new_d1 = zonotope_to_matrix(&covariance_d1, order);
2962            // `0 ⪯ −dP/dρ ⪯ P`, applied to the MATERIALIZED view the consumers
2963            // below read rather than to the zonotope that carries it: the bound
2964            // is a fact about the matrix, the zonotope is a representation of
2965            // it, and every consumer here — `gain_d1`, and through it every
2966            // higher jet — reads this matrix.
2967            //
2968            // Only once the diffuse rank is consumed: until then `P*` is the
2969            // proper PART of a two-matrix decomposition and not the filtered
2970            // covariance the concavity argument is about.
2971            if diffuse_rank == 0 {
2972                intersect_derivative_covariance_below_its_own_covariance(
2973                    &mut p_new_d1,
2974                    &p_new,
2975                    order,
2976                );
2977            }
2978            let mut gain_d1 = [Ball::ZERO; MAX_ORDER];
2979            for i in 0..order {
2980                gain_d1[i] = p_new_d1[i][0].div_positive(r);
2981            }
2982            let a_d1_operator = ball_update_operator_derivative(&gain_d1, order);
2983
2984            // d²P⁺ = A D₂ Aᵀ + A′D₁Aᵀ + A D₁A′ᵀ
2985            let mut p_new_d2 = ball_congruence(&a_operator, &d2_pred, &a_operator, order);
2986            let d2_cross = ball_congruence(&a_d1_operator, &d1_pred, &a_operator, order);
2987            for i in 0..order {
2988                for j in 0..order {
2989                    p_new_d2[i][j] = p_new_d2[i][j].add(d2_cross[i][j]).add(d2_cross[j][i]);
2990                }
2991            }
2992            let mut gain_d2 = [Ball::ZERO; MAX_ORDER];
2993            for i in 0..order {
2994                gain_d2[i] = p_new_d2[i][0].div_positive(r);
2995            }
2996            let a_d2_operator = ball_update_operator_derivative(&gain_d2, order);
2997
2998            // d³P⁺ = A D₃ Aᵀ + A″D₁Aᵀ + A D₁A″ᵀ + 2A′D₂Aᵀ + 2A D₂A′ᵀ + 2A′D₁A′ᵀ
2999            let d3_congruence = ball_congruence(&a_operator, &d3_pred, &a_operator, order);
3000            let mut p_new_d3 = d3_congruence;
3001            let d3_second = ball_congruence(&a_d2_operator, &d1_pred, &a_operator, order);
3002            let d3_first = ball_congruence(&a_d1_operator, &d2_pred, &a_operator, order);
3003            let d3_both = ball_congruence(&a_d1_operator, &d1_pred, &a_d1_operator, order);
3004            for i in 0..order {
3005                for j in 0..order {
3006                    p_new_d3[i][j] = p_new_d3[i][j]
3007                        .add(d3_second[i][j])
3008                        .add(d3_second[j][i])
3009                        .add(d3_first[i][j].scale(2.0))
3010                        .add(d3_first[j][i].scale(2.0))
3011                        .add(d3_both[i][j].scale(2.0));
3012                }
3013            }
3014            let mut gain_d3 = [Ball::ZERO; MAX_ORDER];
3015            for i in 0..order {
3016                gain_d3[i] = p_new_d3[i][0].div_positive(r);
3017            }
3018
3019            if let Some(sink) = trace.as_mut() {
3020                for record in [
3021                    ("f_star", f_star),
3022                    ("inv_f", inv_f),
3023                    ("a_operator_00", a_operator[0][0]),
3024                    ("gain_d1_0", gain_d1[0]),
3025                    ("gain_d2_0", gain_d2[0]),
3026                    ("gain_d3_0", gain_d3[0]),
3027                    ("d3_pred_00", d3_pred[0][0]),
3028                    ("d3_congruence_00", d3_congruence[0][0]),
3029                    ("d3_term_a2_d1_at", d3_second[0][0]),
3030                    ("d3_term_a1_d2_at", d3_first[0][0]),
3031                    ("d3_term_a1_d1_a1t", d3_both[0][0]),
3032                ] {
3033                    sink.push((t, record.0, record.1));
3034                }
3035                if order > 1 {
3036                    sink.push((t, "p_upd_01", p_new[0][1]));
3037                    sink.push((t, "d1_upd_01", p_new_d1[0][1]));
3038                    sink.push((t, "d2_upd_01", p_new_d2[0][1]));
3039                    sink.push((t, "d3_upd_01", p_new_d3[0][1]));
3040                }
3041                for i in 0..order {
3042                    sink.push((t, GAIN_NAMES[i], gain[i]));
3043                    sink.push((t, P_DIAGONAL_NAMES[i], p_new[i][i]));
3044                    sink.push((t, D1_DIAGONAL_NAMES[i], p_new_d1[i][i]));
3045                    sink.push((t, D2_DIAGONAL_NAMES[i], p_new_d2[i][i]));
3046                    sink.push((t, D3_DIAGONAL_NAMES[i], p_new_d3[i][i]));
3047                }
3048            }
3049
3050            // Mean update as ONE linear map on the stacked `(a, a′)`.
3051            //
3052            // The jets follow from `a⁺⁽ᵏ⁾ = Σ_j C(k,j)·A⁽ʲ⁾a⁽ᵏ⁻ʲ⁾ + K⁽ᵏ⁾y` with
3053            // `A⁽ʲ⁾ = −K⁽ʲ⁾e₀ᵀ` for `j ≥ 1` and `v⁽ᵐ⁾ = −a⁽ᵐ⁾₀`:
3054            //
3055            //     a⁺    = A a    + K y
3056            //     a⁺′   = A a′   + K′v  = A a′ − K′e₀ᵀa + K′y
3057            //     a⁺″   = A a″   + 2K′v′  + K″v
3058            //     a⁺‴   = A a‴   + 3K′v″  + 3K″v′ + K‴v
3059            //
3060            // The first two are BLOCK LOWER-TRIANGULAR in `(a, a′)` — `a` never
3061            // reads its own jet — which is what lets one zonotope carry both.
3062            //
3063            // `64778c4e0` wrote the first line for every row and was corrected
3064            // by a row split, because under BOX arithmetic `A a + K y` costs
3065            // `(|a₀| + |y|)·w(K_i)` on rows `i ≥ 1` where the innovation form
3066            // `a_i + K_i v` costs only `|v|·w(K_i)`. Under affine arithmetic
3067            // that distinction is gone: the two are the same linear map, and a
3068            // zonotope applies a linear map exactly, so writing it as a matrix
3069            // costs nothing and is what the generators need. This is NOT a
3070            // reinstatement of that commit's claim — its form is used because
3071            // the enclosure it was wrong about is no longer a box.
3072            let y_node = Ball::exact(nodes[t].y);
3073            let mut update_map = zonotope_identity_map::<MEAN_DIM>(MEAN_BLOCKS * order);
3074            mean_set_block(&mut update_map, 0, 0, &a_operator, order);
3075            mean_set_block(&mut update_map, 1, 1, &a_operator, order);
3076            mean_set_block(&mut update_map, 1, 0, &a_d1_operator, order);
3077            let mut update_constant = [Ball::ZERO; MEAN_DIM];
3078            for i in 0..order {
3079                update_constant[i] = gain[i].mul(y_node);
3080                update_constant[order + i] = gain_d1[i].mul(y_node);
3081            }
3082            if !mean.apply(&update_map, &update_constant) {
3083                return Err(SplineScoreProofError::InvalidArithmetic {
3084                    context: "proper mean zonotope",
3085                });
3086            }
3087            let a_d2_contracted = ball_mat_vec(&a_operator, &a_d2, order);
3088            let a_d3_contracted = ball_mat_vec(&a_operator, &a_d3, order);
3089            for i in 0..order {
3090                a_d2[i] = a_d2_contracted[i]
3091                    .add(gain_d1[i].mul(v_d1).scale(2.0))
3092                    .add(gain_d2[i].mul(v));
3093                a_d3[i] = a_d3_contracted[i]
3094                    .add(gain_d1[i].mul(v_d2).scale(3.0))
3095                    .add(gain_d2[i].mul(v_d1).scale(3.0))
3096                    .add(gain_d3[i].mul(v));
3097            }
3098
3099            p_star = p_new;
3100            p_star_d2 = p_new_d2;
3101            p_star_d3 = p_new_d3;
3102            ball_symmetrize(&mut p_star, order);
3103            ball_symmetrize(&mut p_star_d2, order);
3104            ball_symmetrize(&mut p_star_d3, order);
3105            intersect_proper_covariance_psd(&mut p_star, order)?;
3106
3107            let vv = v.square();
3108            let vv_d1 = v.mul(v_d1).scale(2.0);
3109            let vv_d2 = v_d1.square().add(v.mul(v_d2)).scale(2.0);
3110            let vv_d3 = v.mul(v_d3).add(v_d1.mul(v_d2).scale(3.0)).scale(2.0);
3111            let logf_d1 = f_star_d1.mul(inv_f);
3112            let logf_d2 = f_star_d2.mul(inv_f).sub(logf_d1.square());
3113            let logf_d3 = f_star_d3
3114                .mul(inv_f)
3115                .sub(f_star_d2.mul(inv_f).mul(logf_d1).scale(3.0))
3116                .add(logf_d1.square().mul(logf_d1).scale(2.0));
3117            sum_log_f = sum_log_f.add(f_star.ln_positive());
3118            sum_log_f_d1 = sum_log_f_d1.add(logf_d1);
3119            sum_log_f_d2 = sum_log_f_d2.add(logf_d2);
3120            sum_log_f_d3 = sum_log_f_d3.add(logf_d3);
3121            let t0 = vv.mul(inv_f);
3122            let t1 = vv_d1.sub(t0.mul(f_star_d1)).mul(inv_f);
3123            let t2 = vv_d2
3124                .sub(t1.mul(f_star_d1).scale(2.0))
3125                .sub(t0.mul(f_star_d2))
3126                .mul(inv_f);
3127            let t3 = vv_d3
3128                .sub(t2.mul(f_star_d1).scale(3.0))
3129                .sub(t1.mul(f_star_d2).scale(3.0))
3130                .sub(t0.mul(f_star_d3))
3131                .mul(inv_f);
3132            sum_v2_over_f = sum_v2_over_f.add(t0);
3133            sum_v2_over_f_d1 = sum_v2_over_f_d1.add(t1);
3134            sum_v2_over_f_d2 = sum_v2_over_f_d2.add(t2);
3135            sum_v2_over_f_d3 = sum_v2_over_f_d3.add(t3);
3136            n_proper += 1;
3137            if diffuse_rank == 0 {
3138                intersect_first_order_accumulator_exact_ranges(
3139                    &mut sum_v2_over_f,
3140                    &mut sum_v2_over_f_d1,
3141                    &mut sum_log_f_d1,
3142                    weighted_energy,
3143                    n_proper,
3144                );
3145            }
3146            if let Some(sink) = trace.as_mut() {
3147                for i in 0..order {
3148                    sink.push((t, GAIN_NAMES[i], gain[i]));
3149                }
3150                for record in [
3151                    ("mean_a0", mean.coordinate(0)),
3152                    ("mean_a0_d1", mean.coordinate(MAX_ORDER)),
3153                    ("innovation_v", v),
3154                    ("innovation_v_d1", v_d1),
3155                    ("logf_d1", logf_d1),
3156                    ("term_t0", t0),
3157                    ("term_t1", t1),
3158                    ("acc_sum_log_f", sum_log_f),
3159                    ("acc_sum_log_f_d1", sum_log_f_d1),
3160                    ("acc_sum_v2", sum_v2_over_f),
3161                    ("acc_sum_v2_d1", sum_v2_over_f_d1),
3162                ] {
3163                    sink.push((t, record.0, record.1));
3164                }
3165            }
3166            // Refuse AT the node that diverged, not at the end of the pass.
3167            //
3168            // The end-of-pass check below reports that some accumulator is
3169            // non-finite and nothing more, which is what made #2614 expensive:
3170            // two exact repairs were aimed at the wrong term because the
3171            // refusal could not say WHICH accumulator went, WHERE, or how wide
3172            // it was. Checking here costs eight `is_finite` calls per proper
3173            // node and turns the refusal into the measurement.
3174            if let Some((accumulator, ball, contribution)) = [
3175                ("sum_log_f", sum_log_f, f_star.ln_positive()),
3176                ("sum_log_f_d1", sum_log_f_d1, logf_d1),
3177                ("sum_v2_over_f", sum_v2_over_f, t0),
3178                ("sum_v2_over_f_d1", sum_v2_over_f_d1, t1),
3179                // Second and third order last, and deliberately outside the
3180                // scan below: each has a closed-form global bound the
3181                // certificate substitutes, so neither can justify discarding a
3182                // value and slope that are finite.
3183                ("sum_log_f_d2", sum_log_f_d2, logf_d2),
3184                ("sum_v2_over_f_d2", sum_v2_over_f_d2, t2),
3185                ("sum_log_f_d3", sum_log_f_d3, logf_d3),
3186                ("sum_v2_over_f_d3", sum_v2_over_f_d3, t3),
3187            ]
3188            .into_iter()
3189            .take(GLOBALLY_BOUNDED_FROM)
3190            .find(|(_, ball, _)| !ball.is_finite())
3191            {
3192                return Err(SplineScoreProofError::AccumulatorDiverged {
3193                    node: t,
3194                    n_proper,
3195                    accumulator,
3196                    value: ball.value,
3197                    lo: ball.lo,
3198                    hi: ball.hi,
3199                    q_value: q.value,
3200                    contribution_lo: contribution.lo,
3201                    contribution_hi: contribution.hi,
3202                    f_star_d3_lo: f_star_d3.lo,
3203                    f_star_d3_hi: f_star_d3.hi,
3204                    updated_d3_lo: p_star_d3[0][0].lo,
3205                    updated_d3_hi: p_star_d3[0][0].hi,
3206                });
3207            }
3208        }
3209
3210        if t + 1 < nodes.len() {
3211            let delta = Ball::exact(nodes[t + 1].x).sub(Ball::exact(nodes[t].x));
3212            let f_t = ball_transition(delta, order);
3213            // The transition is block diagonal on the stacked mean: every jet
3214            // is transported by the same `T`, since `T` does not depend on ρ.
3215            let mut transition_map = zonotope_identity_map::<MEAN_DIM>(MEAN_BLOCKS * order);
3216            mean_set_block(&mut transition_map, 0, 0, &f_t, order);
3217            mean_set_block(&mut transition_map, 1, 1, &f_t, order);
3218            if !mean.apply(&transition_map, &[Ball::ZERO; MEAN_DIM]) {
3219                return Err(SplineScoreProofError::InvalidArithmetic {
3220                    context: "mean zonotope transition",
3221                });
3222            }
3223            a_d2 = ball_mat_vec(&f_t, &a_d2, order);
3224            a_d3 = ball_mat_vec(&f_t, &a_d3, order);
3225            let f_t_t = ball_mat_t(&f_t, order);
3226            let ProcessNoiseTaylor {
3227                enclosure: q_noise,
3228                constant: q_noise_constant,
3229                shared_q: q_noise_shared_q,
3230            } = ball_process_noise_taylor(delta, q, order);
3231            let component_p_next = ball_mat_add(
3232                &ball_mat_mul(&ball_mat_mul(&f_t, &p_star, order), &f_t_t, order),
3233                &q_noise,
3234                order,
3235            );
3236            if !covariance.apply_with_shared_q(
3237                &zonotope_congruence_map(&f_t, &f_t, order),
3238                &q_noise_constant,
3239                &q_noise_shared_q,
3240            ) || !project_symmetric_zonotope(&mut covariance, order)
3241            {
3242                return Err(SplineScoreProofError::InvalidArithmetic {
3243                    context: "proper covariance zonotope transition",
3244                });
3245            }
3246            let mut p_next = zonotope_to_matrix(&covariance, order);
3247            for i in 0..order {
3248                for j in 0..order {
3249                    intersect_with_independent_enclosure(&mut p_next[i][j], component_p_next[i][j]);
3250                }
3251            }
3252            let mut p_next_d2 = ball_mat_add(
3253                &ball_mat_mul(&ball_mat_mul(&f_t, &p_star_d2, order), &f_t_t, order),
3254                &q_noise,
3255                order,
3256            );
3257            let mut p_next_d3 = ball_mat_sub(
3258                &ball_mat_mul(&ball_mat_mul(&f_t, &p_star_d3, order), &f_t_t, order),
3259                &q_noise,
3260                order,
3261            );
3262            // `dP⁻ = F·dP·Fᵀ − Q`, since `dQ/dρ = −Q`: a congruence plus an
3263            // EXACT constant, so the whole `dP` recursion is affine in `dP`
3264            // with coefficients built from the value covariance.
3265            let mut prediction_constant = [Ball::ZERO; COVARIANCE_D1_DIM];
3266            let mut prediction_shared_q = [0.0_f64; COVARIANCE_D1_DIM];
3267            for i in 0..order {
3268                for j in 0..order {
3269                    let index = i * order + j;
3270                    prediction_constant[index] = q_noise_constant[index].neg();
3271                    prediction_shared_q[index] = -q_noise_shared_q[index];
3272                }
3273            }
3274            if !covariance_d1.apply_with_shared_q(
3275                &zonotope_congruence_map(&f_t, &f_t, order),
3276                &prediction_constant,
3277                &prediction_shared_q,
3278            ) || !project_symmetric_zonotope(&mut covariance_d1, order)
3279            {
3280                return Err(SplineScoreProofError::InvalidArithmetic {
3281                    context: "covariance-derivative zonotope transition",
3282                });
3283            }
3284            ball_symmetrize(&mut p_next, order);
3285            ball_symmetrize(&mut p_next_d2, order);
3286            ball_symmetrize(&mut p_next_d3, order);
3287            // NOTE: `0 ⪯ −dP/dρ ⪯ P` is applied at the UPDATE, where the
3288            // derivative covariance is materialized from its zonotope, and not
3289            // here. Across the prediction the zonotope carries `dP/dρ` in
3290            // factored form and never forms the matrix, which is the whole
3291            // point of carrying it that way; the bound is a fact about the
3292            // matrix, so it belongs at the materialization its consumers read.
3293            p_star = p_next;
3294            p_star_d2 = p_next_d2;
3295            p_star_d3 = p_next_d3;
3296            if let Some(sink) = trace.as_mut() {
3297                for i in 0..order {
3298                    for j in 0..order {
3299                        sink.push((t, P_NEXT_ENTRY_NAMES[i][j], p_star[i][j]));
3300                    }
3301                }
3302                sink.push((t, "d1_next_00", covariance_d1.coordinate(0)));
3303                sink.push((t, "d2_next_00", p_star_d2[0][0]));
3304                sink.push((t, "d3_next_00", p_star_d3[0][0]));
3305            }
3306            if diffuse_rank > 0 {
3307                let mut pi_next = ball_mat_mul(&ball_mat_mul(&f_t, &p_inf, order), &f_t_t, order);
3308                ball_symmetrize(&mut pi_next, order);
3309                p_inf = pi_next;
3310            } else {
3311                intersect_proper_covariance_psd(&mut p_star, order)?;
3312            }
3313            // Carry the factor across the transition: `P⁻ = (F L)(F L)ᵀ + Q`,
3314            // so the prearray is `[F·L, L_Q]` and re-triangularizing it keeps
3315            // the factor `order`-wide. Nothing here subtracts.
3316            carried_factor = carried_factor.and_then(|factor| {
3317                let transported = ball_mat_mul(&f_t, &factor, order);
3318                let noise_factor = ball_cholesky(&q_noise, order)?;
3319                let mut prearray = [[Ball::ZERO; PREARRAY_COLUMNS]; MAX_ORDER];
3320                for i in 0..order {
3321                    for j in 0..order {
3322                        prearray[i][j] = transported[i][j];
3323                        prearray[i][order + j] = noise_factor[i][j];
3324                    }
3325                }
3326                let (next_factor, trailing, gram_scale) =
3327                    ball_retriangularize(&mut prearray, order, 2 * order);
3328                let gram = ball_factor_gram(&next_factor, order);
3329                // `P⁻ = (L Lᵀ + D)/gram_scale` with `0 ⪯ D` and every entry of
3330                // `D` bounded by `trace(D)`; `gram_scale` is `1 + O(eps)`.
3331                let slack = Ball {
3332                    value: 0.0,
3333                    lo: -trailing,
3334                    hi: trailing,
3335                };
3336                let scale = Ball {
3337                    value: 1.0,
3338                    lo: next_down_ball(1.0 / gram_scale),
3339                    hi: next_up_ball(gram_scale),
3340                };
3341                for i in 0..order {
3342                    for j in 0..order {
3343                        let evidence = gram[i][j].add(slack).mul(scale);
3344                        intersect_with_independent_enclosure(&mut p_star[i][j], evidence);
3345                    }
3346                }
3347                Some(next_factor)
3348            });
3349        }
3350    }
3351
3352    let pass = BallFilterPass {
3353        sum_log_f,
3354        sum_log_f_d1,
3355        sum_log_f_d2,
3356        sum_log_f_d3,
3357        sum_v2_over_f,
3358        sum_v2_over_f_d1,
3359        sum_v2_over_f_d2,
3360        sum_v2_over_f_d3,
3361        n_proper,
3362    };
3363    if [
3364        pass.sum_log_f,
3365        pass.sum_log_f_d1,
3366        pass.sum_v2_over_f,
3367        pass.sum_v2_over_f_d1,
3368    ]
3369    .into_iter()
3370    .any(|ball| !ball.is_finite())
3371    {
3372        return Err(SplineScoreProofError::InvalidArithmetic {
3373            context: "diffuse filter accumulator",
3374        });
3375    }
3376    Ok(pass)
3377}
3378
3379/// Fitted exact smoothing-spline posterior on the pooled knots.
3380#[derive(Clone, Debug)]
3381pub struct SplineScanFit {
3382    /// Smoothing-spline order `m` (penalize `∫(f^{(m)})²`); state dimension.
3383    /// `m = 1` is the random-walk/linear smoother, `m = 2` the cubic smoother,
3384    /// `m = 3` the quintic smoother.
3385    pub order: usize,
3386    /// Distinct sorted abscissae (pooled knots).
3387    pub knots: Vec<f64>,
3388    /// Smoothed posterior mean of `f` at each knot.
3389    pub mean: Vec<f64>,
3390    /// Smoothed posterior mean of `f′` at each knot, present only for order
3391    /// `m ≥ 2`. At `m = 1` the latent process is Brownian motion, which has NO
3392    /// pointwise derivative state (it is a.s. nondifferentiable), so this is
3393    /// `None` rather than a fabricated zero.
3394    pub deriv: Option<Vec<f64>>,
3395    /// Posterior variance of `f` at each knot (scaled by `sigma2`).
3396    pub var: Vec<f64>,
3397    /// Selected (or supplied) log smoothing parameter `log λ`.
3398    log_lambda: f64,
3399    /// Profiled (or supplied) observation variance σ².
3400    pub sigma2: f64,
3401    /// Concentrated diffuse restricted log-likelihood at the optimum, up to a
3402    /// λ- and data-independent additive constant. Differences across λ are
3403    /// exact REML criterion differences.
3404    pub restricted_loglik: f64,
3405    /// Original training row count (pre-pooling; ties collapse to fewer
3406    /// knots), retained for every sample-size-based post-fit calculation.
3407    training_sample_size: std::num::NonZeroUsize,
3408    /// Weighted DATA residual sum of squares `Σ wᵢ (yᵢ − f̂(xᵢ))²` at the
3409    /// smoothed posterior mean. Stored explicitly because the profiled
3410    /// innovations quadratic `σ̂²·(n − order)` is the REML objective's
3411    /// quadratic — data residual energy PLUS process/roughness energy at the
3412    /// posterior mode — and is therefore NOT the Gaussian deviance.
3413    pub data_sse: f64,
3414    /// Smoothed full states `(f, f′)` per knot.
3415    smoothed_state: Vec<Vec2>,
3416    /// Smoothed full state covariances per knot (unit-σ² scale).
3417    smoothed_cov: Vec<Mat2>,
3418    /// RTS backward gains `G_t` (lag-one cross-covariance is `G_t · P^s_{t+1}`).
3419    rts_gain: Vec<Mat2>,
3420    /// q = 1/λ used by the pass (unit-σ² scale).
3421    q: f64,
3422    /// Pooled observation weight per knot (sum of tied raw weights).
3423    node_weight: Vec<f64>,
3424}
3425
3426/// Pool tied abscissae and validate inputs. Returns nodes plus the within-tie
3427/// weighted residual sum and the raw observation count.
3428fn pool_nodes(
3429    x: &[f64],
3430    y: &[f64],
3431    w: &[f64],
3432    order: usize,
3433) -> Result<(Vec<PooledNode>, f64, usize), String> {
3434    let n = x.len();
3435    if y.len() != n || w.len() != n {
3436        return Err(format!(
3437            "spline scan: length mismatch x={n}, y={}, w={}",
3438            y.len(),
3439            w.len()
3440        ));
3441    }
3442    for i in 0..n {
3443        if !(x[i].is_finite() && y[i].is_finite() && w[i].is_finite() && w[i] > 0.0) {
3444            return Err(format!(
3445                "spline scan: non-finite or non-positive input at row {i} (x={}, y={}, w={})",
3446                x[i], y[i], w[i]
3447            ));
3448        }
3449    }
3450    let mut perm: Vec<usize> = (0..n).collect();
3451    perm.sort_by(|&i, &j| x[i].total_cmp(&x[j]));
3452    let mut nodes: Vec<PooledNode> = Vec::new();
3453    for &i in &perm {
3454        match nodes.last_mut() {
3455            Some(last) if last.x == x[i] => {
3456                let w_new = last.w + w[i];
3457                last.y = (last.y * last.w + y[i] * w[i]) / w_new;
3458                last.w = w_new;
3459            }
3460            _ => nodes.push(PooledNode {
3461                x: x[i],
3462                y: y[i],
3463                w: w[i],
3464            }),
3465        }
3466    }
3467    // Need the `order` diffuse dimensions plus at least one proper innovation.
3468    if nodes.len() < order + 1 {
3469        return Err(format!(
3470            "spline scan: order {order} needs at least {} distinct abscissae, got {}",
3471            order + 1,
3472            nodes.len()
3473        ));
3474    }
3475    // Within-tie residual sum Σ w_i (y_i − ȳ_group)², part of the profiled σ².
3476    let mut ssr_within = 0.0;
3477    let mut k = 0usize;
3478    for &i in &perm {
3479        while nodes[k].x != x[i] {
3480            k += 1;
3481        }
3482        let d = y[i] - nodes[k].y;
3483        ssr_within += w[i] * d * d;
3484    }
3485    Ok((nodes, ssr_within, n))
3486}
3487
3488/// Concentrated diffuse restricted log-likelihood and its exact first three
3489/// derivatives with respect to `log λ` (σ² profiled). The derivatives are
3490/// propagated through the same diffuse Kalman recursion as the value; no
3491/// finite differencing or surrogate objective is involved. The third order
3492/// exists solely to anchor the certified-search enclosure on endpoint pairs
3493/// (#2300/#2614 fourth-order tail).
3494fn concentrated_criterion_jet(
3495    nodes: &[PooledNode],
3496    ssr_within: f64,
3497    n_obs: usize,
3498    log_lambda: f64,
3499    order: usize,
3500) -> Result<(f64, f64, f64, f64), String> {
3501    let q = gam_problem::checked_exp_log_strength(-log_lambda)
3502        .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
3503    let pass = run_filter::<false>(nodes, q, order)?;
3504    // Profiled σ̂² over the proper innovations plus within-tie residuals;
3505    // the restricted degrees of freedom subtract the diffuse dimension `order`.
3506    let dof = (n_obs - order) as f64;
3507    let rss = pass.sum_v2_over_f + ssr_within;
3508    if rss <= 0.0 {
3509        return Err("spline scan: degenerate zero residual sum".to_string());
3510    }
3511    let sigma2 = rss / dof;
3512    if pass.n_proper != nodes.len() - order {
3513        return Err(format!(
3514            "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
3515            nodes.len() - order,
3516            pass.n_proper
3517        ));
3518    }
3519    let rss_d1 = pass.sum_v2_over_f_d1;
3520    let rss_d2 = pass.sum_v2_over_f_d2;
3521    let rss_d3 = pass.sum_v2_over_f_d3;
3522    let rss_log_d1 = rss_d1 / rss;
3523    let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
3524    let rss_log_d3 = rss_d3 / rss - 3.0 * (rss_d2 / rss) * rss_log_d1
3525        + 2.0 * rss_log_d1 * rss_log_d1 * rss_log_d1;
3526    Ok((
3527        -0.5 * (pass.sum_log_f + dof * sigma2.ln()),
3528        -0.5 * (pass.sum_log_f_d1 + dof * rss_log_d1),
3529        -0.5 * (pass.sum_log_f_d2 + dof * rss_log_d2),
3530        -0.5 * (pass.sum_log_f_d3 + dof * rss_log_d3),
3531    ))
3532}
3533
3534#[derive(Clone, Copy, Debug)]
3535struct CertifiedCriterionJet {
3536    jet: ScoreJet,
3537    value: Ball,
3538    derivative: Ball,
3539    curvature: Ball,
3540    third: Ball,
3541    /// Where the curvature and the third order came from. A search that quietly
3542    /// got weaker is the same defect class as a criterion that quietly drifted,
3543    /// so a certificate that fell back to a global constant names itself.
3544    curvature_source: BoundSource,
3545    third_source: BoundSource,
3546}
3547
3548impl CertifiedCriterionJet {
3549    /// Which bounds anchored this endpoint, when either is not the exact jet.
3550    ///
3551    /// `curvature_source` / `third_source` exist so a certificate that fell back
3552    /// to a closed-form global constant NAMES ITSELF instead of quietly getting
3553    /// wider. A field nobody reads cannot do that: written-and-never-read IS the
3554    /// silent degradation these fields were added to prevent, and it is also a
3555    /// hard `-D dead-code` failure in any build of this crate as a plain library
3556    /// rather than a test target -- which `-p gam-solve --lib` never exercises,
3557    /// so it broke every integration binary while the usual measurement stayed
3558    /// green.
3559    ///
3560    /// `None` on the common path, so a reader only hears about a weakened anchor.
3561    fn weakened_anchor(self) -> Option<(BoundSource, BoundSource)> {
3562        if matches!(
3563            (self.curvature_source, self.third_source),
3564            (BoundSource::EndpointJet, BoundSource::EndpointJet)
3565        ) {
3566            None
3567        } else {
3568            Some((self.curvature_source, self.third_source))
3569        }
3570    }
3571}
3572
3573/// Which bound anchored a derivative at this endpoint.
3574#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3575enum BoundSource {
3576    /// The exact endpoint `V‴` jet — the fourth-order tail of #2300/#2614.
3577    EndpointJet,
3578    /// The closed-form global bound `½(r/4 + 6ν)`, taken because the endpoint
3579    /// jet's enclosure left the finite range. The search stays CERTIFIED and
3580    /// its tail cells are merely wider: `(|V′|/L₃)^{1/2}` in place of
3581    /// the endpoint-pair `(|V′|/L₅)^{1/4}` rate. The third order exists SOLELY
3582    /// to anchor that radius, so losing it costs cells, never soundness.
3583    ///
3584    /// The measured boundary that makes this reachable, recorded where a reader
3585    /// meets it rather than left to be rediscovered: before #2614's centred
3586    /// Riccati/shared-`q` repair, smoothing order 3 refused throughout
3587    /// `-20 <= ρ <= -10` when the covariance-derivative zonotope overflowed.
3588    /// The repaired representation reaches the exact endpoint jets throughout
3589    /// that measured domain. This fallback remains the sound terminal bound for
3590    /// other inputs whose endpoint jet genuinely carries less information.
3591    AnalyticGlobalBound,
3592}
3593
3594/// `|V″| ≤ ½(r/4 + 2ν)` and `|V‴| ≤ ½(r/4 + 6ν)`, the closed-form derivative
3595/// bounds derived in [`concentrated_criterion_enclosure`]'s own documentation,
3596/// in the same family as the fourth-order bound the radius already uses.
3597fn curvature_global_bound(proper_modes: f64, residual_dof: f64) -> f64 {
3598    0.5 * (0.25 * proper_modes + 2.0 * residual_dof)
3599}
3600
3601fn third_derivative_global_bound(proper_modes: f64, residual_dof: f64) -> f64 {
3602    0.5 * (0.25 * proper_modes + 6.0 * residual_dof)
3603}
3604
3605/// Conservative closed-form bound on the concentrated criterion's fifth
3606/// derivative with respect to `rho = log(lambda)`.
3607///
3608/// For a determinant mode `u in [0,1]`, the fifth derivative is
3609///
3610/// `u(1-u)(1 - 14u + 36u² - 24u³)`,
3611///
3612/// up to sign. Absolute coefficient summation and `u(1-u) <= 1/4` bound it by
3613/// `(1+14+36+24)/4 = 18.75`. For one normalized residual kernel,
3614///
3615/// `t⁽⁵⁾/t = u(1 - 30u + 150u² - 240u³ + 120u⁴)`,
3616///
3617/// up to sign, hence `|t⁽⁵⁾/t| <= 541`; the ratios through order four are each
3618/// bounded by one as documented on [`concentrated_criterion_enclosure`].
3619/// Faa di Bruno for `(log R)⁽⁵⁾` adds absolute coefficients
3620/// `5+10+20+30+60+24 = 149` from those lower ratios, for `541+149 = 690`.
3621///
3622/// These deliberately elementary coefficient bounds are wider than the exact
3623/// polynomial ranges but need no spectral decomposition or data-dependent
3624/// tail assumption. Their consumer integrates the bound four times, so the
3625/// resulting remainder is still far below endpoint evaluator error.
3626fn fifth_derivative_global_bound(proper_modes: Ball, residual_dof: Ball) -> Ball {
3627    proper_modes
3628        .scale(18.75)
3629        .add(residual_dof.scale(690.0))
3630        .scale(0.5)
3631}
3632
3633/// Intersect a derivative enclosure with its closed-form global bound.
3634///
3635/// ONE rule at every order, not a branch taken only on failure: the minimum of
3636/// two valid upper bounds on the same quantity is a valid upper bound, so this
3637/// is sound wherever it applies and strictly tighter than the endpoint jet
3638/// whenever the jet is the wider of the two. The fallback is then the special
3639/// case where the jet carries no information at all.
3640///
3641/// The thing being replaced in that case is `[-inf, +inf]`, which is not a
3642/// stronger object than a finite global bound — the search cannot bracket on
3643/// it. A certificate that took the global bound says so through its
3644/// [`BoundSource`], because a search that silently got weaker is the defect
3645/// class this whole issue is about.
3646fn intersect_with_global_bound(ball: Ball, bound: f64) -> (Ball, BoundSource) {
3647    if ball.is_finite() {
3648        let lo = ball.lo.max(-bound);
3649        let hi = ball.hi.min(bound);
3650        if lo <= hi {
3651            return (
3652                Ball {
3653                    value: ball.value.clamp(lo, hi),
3654                    lo,
3655                    hi,
3656                },
3657                BoundSource::EndpointJet,
3658            );
3659        }
3660    }
3661    (
3662        Ball {
3663            value: ball.value.clamp(-bound, bound),
3664            lo: -bound,
3665            hi: bound,
3666        },
3667        BoundSource::AnalyticGlobalBound,
3668    )
3669}
3670
3671/// The concentrated criterion evaluated once with a simultaneous
3672/// directed-rounding proof of all four returned components.
3673fn certified_concentrated_criterion_jet(
3674    nodes: &[PooledNode],
3675    ssr_within: f64,
3676    n_obs: usize,
3677    log_lambda: f64,
3678    order: usize,
3679) -> Result<CertifiedCriterionJet, SplineScoreProofError> {
3680    let q_value = gam_problem::checked_exp_log_strength(-log_lambda).map_err(|error| {
3681        SplineScoreProofError::InvalidInput(format!("spline scan inverse log strength: {error}"))
3682    })?;
3683    let q_enclosure = gam_math::score_opt::certified_exp(-log_lambda).ok_or(
3684        SplineScoreProofError::InvalidArithmetic {
3685            context: "inverse log-strength exponential",
3686        },
3687    )?;
3688    let q = Ball::certified(q_value, q_enclosure);
3689    let pass = run_filter_ball(nodes, q, order)?;
3690    if pass.n_proper != nodes.len() - order {
3691        return Err(SplineScoreProofError::InvalidInput(format!(
3692            "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
3693            nodes.len() - order,
3694            pass.n_proper
3695        )));
3696    }
3697
3698    let dof = Ball::exact((n_obs - order) as f64);
3699    let rss = pass.sum_v2_over_f.add(Ball::exact(ssr_within));
3700    if !(rss.lo > 0.0) {
3701        return Err(SplineScoreProofError::NonPositiveProfileResidual {
3702            enclosure: rss.interval(),
3703        });
3704    }
3705    let sigma2 = rss.div_positive(dof);
3706    let rss_d1 = pass.sum_v2_over_f_d1;
3707    let rss_d2 = pass.sum_v2_over_f_d2;
3708    let rss_d3 = pass.sum_v2_over_f_d3;
3709    let mut rss_log_d1 = rss_d1.div_positive(rss);
3710    // `0 ≤ (Σ v²/F̃)′ ≤ Σ v²/F̃ ≤ rss` bounds this RATIO by one, and the division
3711    // above cannot see that: it ranges numerator and denominator independently,
3712    // so the same dependency loss the accumulator ranges just removed reappears
3713    // one line later. Measured at order 3, ρ = −16.6135: the accumulator pair is
3714    // `(21.2 ± 34.8, 0.494 ± 38.6)` — both inside their ranges — and the
3715    // quotient still reaches `2.8e2`, carrying the certified derivative to
3716    // `[−2.51e4, 88.5]` when the model fixes it at `[−ν/2, r/2]`. The upper end
3717    // is already exactly `r/2 = 88.5`; only the quotient's lower end was loose.
3718    intersect_with_exact_range(&mut rss_log_d1, 0.0, 1.0);
3719    let rss_log_d2 = rss_d2.div_positive(rss).sub(rss_log_d1.square());
3720    let rss_log_d3 = rss_d3
3721        .div_positive(rss)
3722        .sub(rss_d2.div_positive(rss).mul(rss_log_d1).scale(3.0))
3723        .add(rss_log_d1.square().mul(rss_log_d1).scale(2.0));
3724    let value = pass
3725        .sum_log_f
3726        .add(dof.mul(sigma2.ln_positive()))
3727        .scale(-0.5);
3728    let derivative = pass.sum_log_f_d1.add(dof.mul(rss_log_d1)).scale(-0.5);
3729    let curvature = pass.sum_log_f_d2.add(dof.mul(rss_log_d2)).scale(-0.5);
3730    let third = pass.sum_log_f_d3.add(dof.mul(rss_log_d3)).scale(-0.5);
3731    if [value, derivative]
3732        .into_iter()
3733        .any(|ball| !ball.is_finite())
3734    {
3735        return Err(SplineScoreProofError::InvalidArithmetic {
3736            context: "concentrated criterion",
3737        });
3738    }
3739    // The third order is an OPTIMISATION, not a requirement: endpoint pairs
3740    // linearly interpolate it so the #2300/#2614 tail remainder is fourth
3741    // order. When its enclosure leaves the finite range, the closed-form global
3742    // bound keeps the certificate valid and costs only a wider tail cell.
3743    // Refusing the whole jet instead discards a value, slope and curvature that
3744    // are all finite, which is what the divergence refusal used to do at every
3745    // order-3 rho below -6.
3746    let proper_modes = (nodes.len() - order) as f64;
3747    let residual_dof = (n_obs - order) as f64;
3748    let (curvature, curvature_source) = intersect_with_global_bound(
3749        curvature,
3750        curvature_global_bound(proper_modes, residual_dof),
3751    );
3752    let (third, third_source) = intersect_with_global_bound(
3753        third,
3754        third_derivative_global_bound(proper_modes, residual_dof),
3755    );
3756    Ok(CertifiedCriterionJet {
3757        jet: ScoreJet {
3758            value: value.value,
3759            derivative: derivative.value,
3760            curvature: curvature.value,
3761            third: third.value,
3762        },
3763        value,
3764        derivative,
3765        curvature,
3766        third,
3767        curvature_source,
3768        third_source,
3769    })
3770}
3771
3772/// Rigorous interval enclosure of the score's first two derivatives.
3773///
3774/// After eliminating the diffuse polynomial null space, the Gaussian profile
3775/// is an affine covariance pencil. Every determinant mode has response
3776/// `u in [0,1]`; every normalized profiled-residual derivative is a convex
3777/// average of the same kernels. Consequently
3778///
3779/// `|L'| <= 1/2 (r/4 + nu)`, `|L''| <= 1/2 (r/4 + 2 nu)`,
3780/// `|L'''| <= 1/2 (r/4 + 6 nu)`, `|L''''| <= 1/2 (r/4 + 26 nu)`, and
3781/// `|L'''''| <= 1/2 (18.75 r + 690 nu)`,
3782///
3783/// where `r` is the number of proper innovation modes and `nu=n-order` is the
3784/// residual d.f. For one normalized determinant contribution, the fourth
3785/// derivative is `u(1-u)(1-6u+6u^2)`, whose magnitude is at most `1/4` on
3786/// `u in [0,1]`; so is the FIRST derivative `u(1-u)`, which is why every order
3787/// carries the same `r/4` term. For each residual kernel `t = z^2 (1-u)`,
3788/// every ratio `|t^{(k)}/t| <= 1` for `k <= 4`, so Faa di Bruno on `log R`
3789/// gives `1 = 1` at first order, `1+1 = 2` at second, `1+3+2 = 6` at third and
3790/// `1+4+3+12+6 = 26` at fourth. Within-tie residual energy is
3791/// lambda-independent and only tightens these bounds.
3792/// Endpoint jets plus these analytic Lipschitz bounds therefore enclose the
3793/// entire interval without a sampling lattice.
3794///
3795/// The cell is the union of its two half-cells. Every point is within
3796/// `h=(hi-lo)/2` of its nearest endpoint, so the left endpoint anchors signed
3797/// displacements `[0,h]` and the right endpoint anchors `[-h,0]`. The two
3798/// certified endpoint `V'''` balls define a linear interpolant. The standard
3799/// interpolation error
3800///
3801/// `|V'''(x) - linear(V'''(lo), V'''(hi))| <= L5 (x-lo)(hi-x)/2`
3802///
3803/// integrates from either endpoint to give maximum half-cell remainders
3804/// `L5*w^5/960`, `L5*w^4/128`, and `L5*w^3/24` for `V`, `V'`, and `V''`.
3805/// Evaluating the resulting quartic polynomial over each signed half-cell and
3806/// hulling the two results gives one theorem uniformly for all three channels.
3807/// Independently, integrating over the full cell from EACH endpoint gives
3808/// value remainders `L5*w^5/80`. Both full-cell Taylor ranges contain every
3809/// score in the cell, so their intersection with the half-cell hull removes
3810/// endpoint roundoff asymmetry without weakening the theorem.
3811///
3812/// Nearest-endpoint geometry first removes factors 16, 8, and 4 of false
3813/// fourth-derivative uncertainty. Even then, a data-independent global `L4`
3814/// dominates an exponentially saturated endpoint jet. Interpolating the
3815/// endpoint third derivatives removes that constant floor without a fourth
3816/// filter jet: only the globally bounded interpolation error remains, one
3817/// asymptotic order smaller.
3818///
3819/// A second independent curvature theorem protects stationary isolation from
3820/// a loose covariance second-derivative recurrence. The derivative endpoint
3821/// balls give a secant `s=(V'(hi)-V'(lo))/w`; the mean-value theorem supplies
3822/// `xi` in the cell with `V''(xi)=s`, and the global `|V'''|` bound then gives
3823/// `V''(x) in s ± L3*w` everywhere in the cell. Intersecting this range with
3824/// the endpoint-third range can only tighten a valid outer enclosure. It is
3825/// especially decisive near a root, where endpoint `V'` remains sharp even
3826/// when direct interval propagation has lost the sign of `V''`.
3827///
3828/// Once that whole-cell curvature range `C` is known, integrating it from both
3829/// endpoints gives two further derivative theorems:
3830/// `V'(x) in V'(lo)+C*[0,w]` and `V'(x) in V'(hi)+C*[-w,0]`. Their intersection
3831/// with the endpoint-third derivative range preserves the same exact-real
3832/// derivative while recovering local root information from tight endpoint
3833/// balls. A disjoint intersection is an internal certificate contradiction,
3834/// never a reason to widen or fall back.
3835///
3836/// All polynomial operations below use endpoint BALLS and outward interval
3837/// arithmetic. The search caches those balls alongside each endpoint jet, so
3838/// this function performs no filter pass of its own and includes the endpoint
3839/// evaluator's directed-rounding error in every returned channel.
3840fn concentrated_criterion_enclosure(
3841    n_nodes: usize,
3842    n_obs: usize,
3843    left: ScoreSample,
3844    right: ScoreSample,
3845    left_certificate: CertifiedCriterionJet,
3846    right_certificate: CertifiedCriterionJet,
3847    order: usize,
3848) -> Result<DerivativeEnclosure, SplineScoreProofError> {
3849    let (lo, hi) = (left.x, right.x);
3850    if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
3851        return Err(SplineScoreProofError::InvalidInput(format!(
3852            "spline scan: invalid score-enclosure interval [{lo}, {hi}]"
3853        )));
3854    }
3855    if lo == hi {
3856        return Ok(DerivativeEnclosure {
3857            score: ScoreValueEnclosure {
3858                value: ClosedInterval::new(
3859                    left_certificate.value.lo.min(right_certificate.value.lo),
3860                    left_certificate.value.hi.max(right_certificate.value.hi),
3861                ),
3862                evaluation_error: left_certificate
3863                    .value
3864                    .forward_error()
3865                    .max(right_certificate.value.forward_error()),
3866            },
3867            derivative: ClosedInterval::new(
3868                left_certificate
3869                    .derivative
3870                    .lo
3871                    .min(right_certificate.derivative.lo),
3872                left_certificate
3873                    .derivative
3874                    .hi
3875                    .max(right_certificate.derivative.hi),
3876            ),
3877            curvature: ClosedInterval::new(
3878                left_certificate
3879                    .curvature
3880                    .lo
3881                    .min(right_certificate.curvature.lo),
3882                left_certificate
3883                    .curvature
3884                    .hi
3885                    .max(right_certificate.curvature.hi),
3886            ),
3887        });
3888    }
3889    let width = Ball::exact(hi).sub(Ball::exact(lo));
3890    if !(width.lo > 0.0) {
3891        return Err(SplineScoreProofError::InvalidArithmetic {
3892            context: "positive score-enclosure width",
3893        });
3894    }
3895    let proper_modes = Ball::exact((n_nodes - order) as f64);
3896    let residual_dof = Ball::exact((n_obs - order) as f64);
3897    let fifth_abs_bound = fifth_derivative_global_bound(proper_modes, residual_dof);
3898    let third_abs_bound = proper_modes
3899        .scale(0.25)
3900        .add(residual_dof.scale(6.0))
3901        .scale(0.5);
3902    // Announce a weakened anchor at the point it anchors.
3903    //
3904    // Both endpoints feed their nearest half-cell, so either one falling back
3905    // to a global constant widens that half. Reported here rather than at
3906    // construction so the message names the consequence, not just the fact.
3907    for (side, weakened) in [
3908        ("left", left_certificate.weakened_anchor()),
3909        ("right", right_certificate.weakened_anchor()),
3910    ] {
3911        if let Some((curvature_source, third_source)) = weakened {
3912            log::debug!(
3913                "spline scan enclosure: {side} endpoint curvature anchored by \
3914                 {curvature_source:?}, third order by {third_source:?}. A global-bound \
3915                 anchor keeps the search CERTIFIED and widens its tail cells -- half rate \
3916                 in place of fourth-order rate -- so it costs cells, never soundness."
3917            );
3918        }
3919    }
3920    let half_width = width.scale(0.5);
3921    let width2 = width.square();
3922    let width3 = width2.mul(width);
3923    let width4 = width2.square();
3924    let width5 = width4.mul(width);
3925    let value_remainder = fifth_abs_bound
3926        .mul(width5)
3927        .div_positive(Ball::exact(960.0))
3928        .hi;
3929    let derivative_remainder = fifth_abs_bound
3930        .mul(width4)
3931        .div_positive(Ball::exact(128.0))
3932        .hi;
3933    let curvature_remainder = fifth_abs_bound
3934        .mul(width3)
3935        .div_positive(Ball::exact(24.0))
3936        .hi;
3937    let third_slope = right_certificate
3938        .third
3939        .sub(left_certificate.third)
3940        .div_positive(width);
3941
3942    // Integrate the endpoint-pair linear interpolant of V''' from either
3943    // endpoint over a signed displacement. Keeping the sign of `d` is materially
3944    // tighter than replacing every term by an absolute-value radius, while
3945    // ordinary interval arithmetic still gives an outer range despite
3946    // dependencies among powers of `d`.
3947    let endpoint_enclosure = |certificate: CertifiedCriterionJet,
3948                              displacement: ClosedInterval,
3949                              value_remainder: f64,
3950                              derivative_remainder: f64,
3951                              curvature_remainder: f64| {
3952        let d = Ball::certified(0.0, displacement);
3953        let d2 = d.square();
3954        let d3 = d2.mul(d);
3955        let d4 = d2.square();
3956        let value = certificate
3957            .value
3958            .add(certificate.derivative.mul(d))
3959            .add(certificate.curvature.mul(d2).scale(0.5))
3960            .add(certificate.third.mul(d3).div_positive(Ball::exact(6.0)))
3961            .add(third_slope.mul(d4).div_positive(Ball::exact(24.0)))
3962            .interval()
3963            .add(ClosedInterval::new(-value_remainder, value_remainder));
3964        let derivative = certificate
3965            .derivative
3966            .add(certificate.curvature.mul(d))
3967            .add(certificate.third.mul(d2).scale(0.5))
3968            .add(third_slope.mul(d3).div_positive(Ball::exact(6.0)))
3969            .interval()
3970            .add(ClosedInterval::new(
3971                -derivative_remainder,
3972                derivative_remainder,
3973            ));
3974        let curvature = certificate
3975            .curvature
3976            .add(certificate.third.mul(d))
3977            .add(third_slope.mul(d2).scale(0.5))
3978            .interval()
3979            .add(ClosedInterval::new(
3980                -curvature_remainder,
3981                curvature_remainder,
3982            ));
3983        (value, derivative, curvature)
3984    };
3985
3986    let (left_value, left_derivative, left_curvature) = endpoint_enclosure(
3987        left_certificate,
3988        ClosedInterval::new(0.0, half_width.hi),
3989        value_remainder,
3990        derivative_remainder,
3991        curvature_remainder,
3992    );
3993    let (right_value, right_derivative, right_curvature) = endpoint_enclosure(
3994        right_certificate,
3995        ClosedInterval::new(-half_width.hi, 0.0),
3996        value_remainder,
3997        derivative_remainder,
3998        curvature_remainder,
3999    );
4000    let half_cell_score = ClosedInterval::new(
4001        left_value.lo.min(right_value.lo),
4002        left_value.hi.max(right_value.hi),
4003    );
4004    let full_value_remainder = fifth_abs_bound
4005        .mul(width5)
4006        .div_positive(Ball::exact(80.0))
4007        .hi;
4008    let full_derivative_remainder = fifth_abs_bound
4009        .mul(width4)
4010        .div_positive(Ball::exact(24.0))
4011        .hi;
4012    let full_curvature_remainder = fifth_abs_bound
4013        .mul(width3)
4014        .div_positive(Ball::exact(12.0))
4015        .hi;
4016    let (full_left_value, _, _) = endpoint_enclosure(
4017        left_certificate,
4018        ClosedInterval::new(0.0, width.hi),
4019        full_value_remainder,
4020        full_derivative_remainder,
4021        full_curvature_remainder,
4022    );
4023    let (full_right_value, _, _) = endpoint_enclosure(
4024        right_certificate,
4025        ClosedInterval::new(-width.hi, 0.0),
4026        full_value_remainder,
4027        full_derivative_remainder,
4028        full_curvature_remainder,
4029    );
4030    let score_value = ClosedInterval::new(
4031        half_cell_score
4032            .lo
4033            .max(full_left_value.lo)
4034            .max(full_right_value.lo),
4035        half_cell_score
4036            .hi
4037            .min(full_left_value.hi)
4038            .min(full_right_value.hi),
4039    );
4040    if !(score_value.lo <= score_value.hi) {
4041        return Err(SplineScoreProofError::InvalidArithmetic {
4042            context: "endpoint score-enclosure intersection",
4043        });
4044    }
4045    let endpoint_third_derivative = ClosedInterval::new(
4046        left_derivative.lo.min(right_derivative.lo),
4047        left_derivative.hi.max(right_derivative.hi),
4048    );
4049    let endpoint_third_curvature = ClosedInterval::new(
4050        left_curvature.lo.min(right_curvature.lo),
4051        left_curvature.hi.max(right_curvature.hi),
4052    );
4053    let derivative_secant = right_certificate
4054        .derivative
4055        .sub(left_certificate.derivative)
4056        .div_positive(width);
4057    let secant_radius = third_abs_bound.mul(width).hi;
4058    let secant_curvature = derivative_secant
4059        .interval()
4060        .add(ClosedInterval::new(-secant_radius, secant_radius));
4061    let curvature = ClosedInterval::new(
4062        endpoint_third_curvature.lo.max(secant_curvature.lo),
4063        endpoint_third_curvature.hi.min(secant_curvature.hi),
4064    );
4065    if !(curvature.lo <= curvature.hi) {
4066        return Err(SplineScoreProofError::InvalidArithmetic {
4067            context: "curvature secant intersection",
4068        });
4069    }
4070    let curvature_ball = Ball::certified(0.0, curvature);
4071    let derivative_from_left = left_certificate
4072        .derivative
4073        .add(curvature_ball.mul(Ball::certified(0.0, ClosedInterval::new(0.0, width.hi))))
4074        .interval();
4075    let derivative_from_right = right_certificate
4076        .derivative
4077        .add(curvature_ball.mul(Ball::certified(0.0, ClosedInterval::new(-width.hi, 0.0))))
4078        .interval();
4079    let derivative_from_curvature = ClosedInterval::new(
4080        derivative_from_left.lo.max(derivative_from_right.lo),
4081        derivative_from_left.hi.min(derivative_from_right.hi),
4082    );
4083    let derivative = ClosedInterval::new(
4084        endpoint_third_derivative
4085            .lo
4086            .max(derivative_from_curvature.lo),
4087        endpoint_third_derivative
4088            .hi
4089            .min(derivative_from_curvature.hi),
4090    );
4091    if !(derivative.lo <= derivative.hi) {
4092        return Err(SplineScoreProofError::InvalidArithmetic {
4093            context: "derivative curvature-integral intersection",
4094        });
4095    }
4096    let evaluation_error = left_certificate
4097        .value
4098        .forward_error()
4099        .max(right_certificate.value.forward_error());
4100    Ok(DerivativeEnclosure {
4101        score: ScoreValueEnclosure {
4102            value: score_value,
4103            evaluation_error,
4104        },
4105        derivative,
4106        curvature,
4107    })
4108}
4109
4110/// Exact diffuse smoother for the `order−1` partially-diffuse leading nodes
4111/// (#1044 — the multi-node generalization of the `m = 2` reverse-Markov
4112/// closure).
4113///
4114/// Ordinary RTS recovers every node `t ≥ order−1` (where the filtered
4115/// distribution is proper). The first `order−1` nodes are partially diffuse:
4116/// their filtered covariance still carries unresolved diffuse mass, so RTS —
4117/// which needs the predicted covariance `P_{t+1|t}` to be invertible — cannot
4118/// reach them. By the Markov property the leading block depends on all future
4119/// data ONLY through the first proper smoothed node `α_{order−1}`:
4120///
4121///   p(α_{0..order−2} | y) = ∫ p(α_{0..order−2} | α_{order−1}, y_{0..order−2})
4122///                             · p(α_{order−1} | y) dα_{order−1}.
4123///
4124/// The inner conditional is a proper Gaussian: it is the flat (improper)
4125/// leading prior tightened by the Markov increments `(α_{t+1} − Fα_t)ᵀ(qQ)⁻¹(·)`
4126/// and the leading observations `w_t (y_t − f_t)²`, with `α_{order−1}` entering
4127/// linearly through the last increment. Writing `u = (α_0, …, α_{order−2})`,
4128///
4129///   u | α_{order−1} ~ N(C·α_{order−1} + d,  Σ),   Σ = Λ⁻¹,
4130///   Λ  = increments(F'(qQ)⁻¹F …) + leading obs,
4131///   d  = Σ·b_const,   C = Σ·B   (B = the pinned-node coupling F'(qQ)⁻¹),
4132///
4133/// and pushing the smoothed `α_{order−1} ~ N(α̂_p, V_p)` through the affine map
4134/// gives the EXACT smoothed leading block, its covariances, and the lag-one
4135/// cross-covariances `Cov(α_j, α_{j+1} | y)` the bridge `predict` needs:
4136///
4137///   mean(u) = C·α̂_p + d,   Cov(u) = C V_p Cᵀ + Σ,   Cov(u, α_p) = C V_p.
4138///
4139/// This is exact Gaussian conditioning — no diffuse RTS recursion, no
4140/// sign-convention-laden `r/N` adjoint. At `order = 2` (one leading node) it is
4141/// algebraically the existing single-node closure.
4142fn leading_block_smooth(
4143    sm_state: &mut [Vec2],
4144    sm_cov: &mut [Mat2],
4145    gains: &mut [Mat2],
4146    nodes: &[PooledNode],
4147    q: f64,
4148    order: usize,
4149) -> Result<(), String> {
4150    let nb = order - 1; // leading nodes 0..nb-1 (the partially-diffuse ones)
4151    let pin = order - 1; // first proper smoothed node (conditioning anchor)
4152    let d = nb * order; // joint dimension of the leading block
4153    let mut lambda = vec![vec![0.0_f64; d]; d];
4154    let mut b_const = vec![0.0_f64; d];
4155    let mut bmat = vec![vec![0.0_f64; order]; d]; // coupling to the pinned node
4156
4157    // Markov increments t = 0..order-2, each connecting node t and node t+1.
4158    for t in 0..order - 1 {
4159        let delta = nodes[t + 1].x - nodes[t].x;
4160        let f = transition(delta, order);
4161        let qn = process_noise(delta, q, order);
4162        let a = mat_inv(&qn, order, "leading-block increment noise")?; // (qQ)⁻¹ (symmetric)
4163        let ft = mat_t(&f, order);
4164        let fta = mat_mul(&ft, &a, order); // F'A
4165        let ftaf = mat_mul(&fta, &f, order); // F'A F
4166        let af = mat_mul(&a, &f, order); // A F = (F'A)'
4167        // Node t diagonal block (node t is always in the block): += F'A F.
4168        for i in 0..order {
4169            for j in 0..order {
4170                lambda[t * order + i][t * order + j] += ftaf[i][j];
4171            }
4172        }
4173        if t + 1 <= nb - 1 {
4174            // Both nodes are in the block: fill node t+1's diagonal and the
4175            // symmetric cross blocks.
4176            for i in 0..order {
4177                for j in 0..order {
4178                    lambda[(t + 1) * order + i][(t + 1) * order + j] += a[i][j];
4179                    lambda[t * order + i][(t + 1) * order + j] -= fta[i][j];
4180                    lambda[(t + 1) * order + i][t * order + j] -= af[i][j];
4181                }
4182            }
4183        } else {
4184            // t+1 is the pinned node: it enters the conditional only linearly,
4185            // through B (its coupling into node t's score is F'A·α_pin).
4186            for i in 0..order {
4187                for j in 0..order {
4188                    bmat[t * order + i][j] += fta[i][j];
4189                }
4190            }
4191        }
4192    }
4193    // Leading observations: y_t informs the f-component (local index 0) of node t.
4194    for t in 0..nb {
4195        let w = nodes[t].w;
4196        lambda[t * order][t * order] += w;
4197        b_const[t * order] += w * nodes[t].y;
4198    }
4199
4200    // Conditional covariance Σ = Λ⁻¹, intercept d = Σ·b_const, coupling C = Σ·B.
4201    let sigma = dense_spd_inverse(&lambda, "leading-block precision")?;
4202    let dvec: Vec<f64> = (0..d)
4203        .map(|i| (0..d).map(|k| sigma[i][k] * b_const[k]).sum())
4204        .collect();
4205    let cmat: Vec<Vec<f64>> = (0..d)
4206        .map(|i| {
4207            (0..order)
4208                .map(|j| (0..d).map(|k| sigma[i][k] * bmat[k][j]).sum())
4209                .collect()
4210        })
4211        .collect();
4212
4213    // Pinned smoothed moments (from the ordinary RTS pass).
4214    let ahat_p = sm_state[pin];
4215    let vp = sm_cov[pin];
4216    // cvp = C·V_p  (= Cov(u, α_pin)), D×order.
4217    let cvp: Vec<Vec<f64>> = (0..d)
4218        .map(|i| {
4219            (0..order)
4220                .map(|j| (0..order).map(|k| cmat[i][k] * vp[k][j]).sum())
4221                .collect()
4222        })
4223        .collect();
4224    // mean(u) = C·α̂_p + d.
4225    let mean_u: Vec<f64> = (0..d)
4226        .map(|i| (0..order).map(|j| cmat[i][j] * ahat_p[j]).sum::<f64>() + dvec[i])
4227        .collect();
4228    // Cov(u) = cvp·Cᵀ + Σ.
4229    let cov_u: Vec<Vec<f64>> = (0..d)
4230        .map(|i| {
4231            (0..d)
4232                .map(|k| (0..order).map(|j| cvp[i][j] * cmat[k][j]).sum::<f64>() + sigma[i][k])
4233                .collect()
4234        })
4235        .collect();
4236
4237    // Scatter the smoothed leading states and covariances.
4238    for j in 0..nb {
4239        for i in 0..order {
4240            sm_state[j][i] = mean_u[j * order + i];
4241        }
4242        let mut cov = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4243        for i in 0..order {
4244            for k in 0..order {
4245                cov[i][k] = cov_u[j * order + i][j * order + k];
4246            }
4247        }
4248        symmetrize(&mut cov, order);
4249        sm_cov[j] = cov;
4250    }
4251    // Lag-one bridge gains for the leading intervals [j, j+1], j = 0..order-2.
4252    // gain_j = Cov(α_j, α_{j+1} | y) · Cov(α_{j+1} | y)⁻¹, so that the bridge's
4253    // `gain_j · P^s_{j+1}` reproduces the exact lag-one smoothed cross-cov.
4254    for j in 0..nb {
4255        let mut cross = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4256        if j + 1 <= nb - 1 {
4257            // Both in the block: read the (j, j+1) sub-block of Cov(u).
4258            for i in 0..order {
4259                for k in 0..order {
4260                    cross[i][k] = cov_u[j * order + i][(j + 1) * order + k];
4261                }
4262            }
4263        } else {
4264            // j+1 is the pinned node: read node j's rows of Cov(u, α_pin) = cvp.
4265            for i in 0..order {
4266                for k in 0..order {
4267                    cross[i][k] = cvp[j * order + i][k];
4268                }
4269            }
4270        }
4271        let denom_inv = mat_inv(&sm_cov[j + 1], order, "leading-block gain denominator")?;
4272        gains[j] = mat_mul(&cross, &denom_inv, order);
4273    }
4274    Ok(())
4275}
4276
4277/// Fit at a FIXED `log λ` and order `m ∈ {1, 2, 3}`, σ² either supplied or
4278/// profiled.
4279pub fn fit_spline_scan_at(
4280    x: &[f64],
4281    y: &[f64],
4282    w: &[f64],
4283    log_lambda: f64,
4284    sigma2: Option<f64>,
4285    order: usize,
4286) -> Result<SplineScanFit, String> {
4287    if order == 0 || order > MAX_ORDER {
4288        return Err(format!(
4289            "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
4290        ));
4291    }
4292    let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
4293    let q = gam_problem::checked_exp_log_strength(-log_lambda)
4294        .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
4295    let pass = run_filter::<true>(&nodes, q, order)?;
4296    let n = nodes.len();
4297    let dof = (n_obs - order) as f64;
4298    let sigma2 = match sigma2 {
4299        Some(s) => {
4300            if !(s.is_finite() && s > 0.0) {
4301                return Err(format!("spline scan: invalid sigma2 {s}"));
4302            }
4303            s
4304        }
4305        None => (pass.sum_v2_over_f + ssr_within) / dof,
4306    };
4307    // Full diffuse restricted log-likelihood at this (λ, σ²), up to λ- and
4308    // σ-free additive constants: −½[Σ log F̃ + dof·ln σ² + RSS/σ²]. At the
4309    // profiled σ̂² the quadratic term collapses to the λ-free constant `dof`,
4310    // matching `concentrated_criterion` up to that constant.
4311    let rss = pass.sum_v2_over_f + ssr_within;
4312    let restricted_loglik = -0.5 * (pass.sum_log_f + dof * sigma2.ln() + rss / sigma2);
4313
4314    // ── Smoother: ordinary RTS for the proper nodes (t ≥ order−1) plus an
4315    // exact diffuse conditioning of the `order−1` leading nodes. ──
4316    // The filtered distribution is fully proper from node order−1 onward (the
4317    // diffuse rank, = order, is consumed by node order−1), so ordinary RTS is
4318    // valid for t ≥ order−1. The first order−1 nodes are partially diffuse —
4319    // their filtered covariance still carries unresolved diffuse mass and the
4320    // RTS predicted-covariance inverse is singular there — and are recovered
4321    // exactly, jointly, by `leading_block_smooth` (conditioning the whole
4322    // leading block on the first proper smoothed node). For order = 1 there is
4323    // no leading node and RTS covers every node down to t = 0.
4324    let mut sm_state = vec![[0.0_f64; MAX_ORDER]; n];
4325    let mut sm_cov = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
4326    let mut gains = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
4327    sm_state[n - 1] = pass.steps[n - 1].a_filt;
4328    sm_cov[n - 1] = pass.steps[n - 1].p_filt;
4329    for t in (order - 1..n - 1).rev() {
4330        let p_next_pred = &pass.steps[t + 1].p_pred;
4331        let delta = nodes[t + 1].x - nodes[t].x;
4332        let f_t = transition(delta, order);
4333        let p_inv = mat_inv(p_next_pred, order, "RTS predicted covariance")?;
4334        let g = mat_mul(
4335            &mat_mul(&pass.steps[t].p_filt, &mat_t(&f_t, order), order),
4336            &p_inv,
4337            order,
4338        );
4339        let mut dm: Vec2 = [0.0; MAX_ORDER];
4340        for i in 0..order {
4341            dm[i] = sm_state[t + 1][i] - pass.steps[t + 1].a_pred[i];
4342        }
4343        let corr = mat_vec(&g, &dm, order);
4344        for i in 0..order {
4345            sm_state[t][i] = pass.steps[t].a_filt[i] + corr[i];
4346        }
4347        let dp = mat_sub(&sm_cov[t + 1], p_next_pred, order);
4348        let mut cov = mat_add(
4349            &pass.steps[t].p_filt,
4350            &mat_mul(&mat_mul(&g, &dp, order), &mat_t(&g, order), order),
4351            order,
4352        );
4353        symmetrize(&mut cov, order);
4354        sm_cov[t] = cov;
4355        gains[t] = g;
4356    }
4357    // The order−1 partially-diffuse leading nodes by exact joint conditioning
4358    // (the multi-node generalization of the m=2 reverse-Markov closure).
4359    if order >= 2 {
4360        leading_block_smooth(&mut sm_state, &mut sm_cov, &mut gains, &nodes, q, order)?;
4361    }
4362
4363    let knots: Vec<f64> = nodes.iter().map(|n| n.x).collect();
4364    let mean: Vec<f64> = sm_state.iter().map(|s| s[0]).collect();
4365    // f′ lives at state index 1 — present for order ≥ 2 only; the m = 1 latent
4366    // process (Brownian motion) has no derivative state to expose.
4367    let deriv: Option<Vec<f64>> = (order >= 2).then(|| sm_state.iter().map(|s| s[1]).collect());
4368    let var: Vec<f64> = sm_cov.iter().map(|p| p[0][0] * sigma2).collect();
4369    // Weighted DATA residual sum of squares at the smoothed mean. Tied rows
4370    // pool exactly: Σᵢ wᵢ(yᵢ − f̂ₖ)² = Σᵢ wᵢ(yᵢ − ȳₖ)² + Σₖ Wₖ(ȳₖ − f̂ₖ)²
4371    // (within-tie scatter plus pooled-node misfit), so the raw rows the scan
4372    // does not retain are not needed.
4373    let data_sse = ssr_within
4374        + nodes
4375            .iter()
4376            .zip(mean.iter())
4377            .map(|(node, &fhat)| {
4378                let r = node.y - fhat;
4379                node.w * r * r
4380            })
4381            .sum::<f64>();
4382    Ok(SplineScanFit {
4383        order,
4384        knots,
4385        mean,
4386        deriv,
4387        var,
4388        log_lambda,
4389        sigma2,
4390        restricted_loglik,
4391        training_sample_size: std::num::NonZeroUsize::new(n_obs)
4392            .expect("pool_nodes requires at least one training row"),
4393        data_sse,
4394        smoothed_state: sm_state,
4395        smoothed_cov: sm_cov,
4396        rts_gain: gains,
4397        q,
4398        node_weight: nodes.iter().map(|n| n.w).collect(),
4399    })
4400}
4401
4402#[derive(Clone, Copy, Debug, PartialEq)]
4403enum SplineKktKind {
4404    LowerBoundary,
4405    UpperBoundary,
4406    Stationary { curvature: ClosedInterval },
4407}
4408
4409#[derive(Clone, Copy, Debug, PartialEq)]
4410enum SplineOptimumProof {
4411    Kkt {
4412        bracket: ClosedInterval,
4413        kind: SplineKktKind,
4414    },
4415    /// The producer proved every exact score in this region indistinguishable
4416    /// at the point evaluator's certified comparison resolution. This is a
4417    /// successful typed optimum, not a failed stationary-point certificate.
4418    ResolutionFlat {
4419        bracket: ClosedInterval,
4420        max_score_gap: f64,
4421        score_resolution: f64,
4422    },
4423}
4424
4425/// Preserve the certified optimizer's proof category at the spline consumer
4426/// seam.
4427///
4428/// Boundary and stationary selections require their exact-real KKT proof
4429/// below. A [`ScoreOptimumLocation::ResolutionFlat`] selection instead carries
4430/// the producer's successful value-resolution theorem. Requiring a stationary
4431/// KKT certificate from that category contradicts its contract: its whole
4432/// purpose is that unresolved stationary structure is immaterial because the
4433/// cell's exact score diameter does not exceed comparison resolution.
4434fn spline_optimum_proof(
4435    search: &ScoreSearchResult,
4436) -> Result<SplineOptimumProof, SplineScoreProofError> {
4437    match search.location {
4438        ScoreOptimumLocation::LowerBoundary => Ok(SplineOptimumProof::Kkt {
4439            bracket: ClosedInterval::point(search.lower_boundary.x),
4440            kind: SplineKktKind::LowerBoundary,
4441        }),
4442        ScoreOptimumLocation::UpperBoundary => Ok(SplineOptimumProof::Kkt {
4443            bracket: ClosedInterval::point(search.upper_boundary.x),
4444            kind: SplineKktKind::UpperBoundary,
4445        }),
4446        ScoreOptimumLocation::Stationary(index) => {
4447            let stationary = search.stationary_points.get(index).ok_or_else(|| {
4448                SplineScoreProofError::Search(
4449                    "optimizer returned an invalid stationary-point index".to_string(),
4450                )
4451            })?;
4452            Ok(SplineOptimumProof::Kkt {
4453                bracket: stationary.bracket,
4454                kind: SplineKktKind::Stationary {
4455                    curvature: stationary.curvature,
4456                },
4457            })
4458        }
4459        ScoreOptimumLocation::ResolutionFlat(index) => {
4460            let flat = search.resolution_flat_regions.get(index).ok_or_else(|| {
4461                SplineScoreProofError::Search(
4462                    "optimizer returned an invalid resolution-flat index".to_string(),
4463                )
4464            })?;
4465            if !(flat.max_score_gap.is_finite()
4466                && flat.max_score_gap >= 0.0
4467                && flat.score_resolution.is_finite()
4468                && flat.score_resolution >= 0.0
4469                && flat.max_score_gap <= flat.score_resolution
4470                && flat.bracket.contains(search.optimum.x)
4471                && flat.sample.x.to_bits() == search.optimum.x.to_bits())
4472            {
4473                return Err(SplineScoreProofError::Search(format!(
4474                    "optimizer returned an invalid resolution-flat certificate: selected {}, \
4475                     representative {}, bracket {:?}, maximum score gap {}, score resolution {}",
4476                    search.optimum.x,
4477                    flat.sample.x,
4478                    flat.bracket,
4479                    flat.max_score_gap,
4480                    flat.score_resolution
4481                )));
4482            }
4483            Ok(SplineOptimumProof::ResolutionFlat {
4484                bracket: flat.bracket,
4485                max_score_gap: flat.max_score_gap,
4486                score_resolution: flat.score_resolution,
4487            })
4488        }
4489    }
4490}
4491
4492fn spline_kkt_holds(
4493    kind: SplineKktKind,
4494    final_enclosure: DerivativeEnclosure,
4495) -> (bool, ClosedInterval) {
4496    match kind {
4497        SplineKktKind::LowerBoundary => (
4498            final_enclosure.derivative.hi <= 0.0,
4499            final_enclosure.curvature,
4500        ),
4501        SplineKktKind::UpperBoundary => (
4502            final_enclosure.derivative.lo >= 0.0,
4503            final_enclosure.curvature,
4504        ),
4505        SplineKktKind::Stationary { curvature } => (
4506            // Recompute the final bracket's derivative containment, which
4507            // depends on its endpoint certificates. Preserve the producer's
4508            // strict curvature enclosure: it proved this root unique on a
4509            // parent cell and therefore remains valid on every contracted
4510            // subset, even if a fresh tiny-cell formula loses the sign to
4511            // cancellation.
4512            final_enclosure.derivative.contains_zero() && curvature.hi < 0.0,
4513            curvature,
4514        ),
4515    }
4516}
4517
4518/// Fit with `log λ` selected by the concentrated diffuse REML criterion.
4519/// Every stationary interval in the bounded, scale-equivariant log-λ domain
4520/// is isolated using analytic derivatives and rigorous interval bounds; the
4521/// two boundary/null-recovery candidates are evaluated exactly.
4522pub fn fit_spline_scan(
4523    x: &[f64],
4524    y: &[f64],
4525    w: &[f64],
4526    order: usize,
4527) -> Result<SplineScanFit, SplineScoreProofError> {
4528    if order == 0 || order > MAX_ORDER {
4529        return Err(SplineScoreProofError::InvalidInput(format!(
4530            "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
4531        )));
4532    }
4533    let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
4534    // Covariate-rescaling equivariance (#1214). The order-`m` IWP process noise
4535    // is `Q(δ) ∝ q · δ^{2m−1}`, so under an affine covariate rescale `x → a·x`
4536    // (all abscissa gaps `δ → a·δ`) the posterior `f(x)` is *exactly* invariant
4537    // iff the smoothing parameter co-transforms as `q → q / a^{2m−1}`, i.e.
4538    // `log λ → log λ + (2m−1)·log a` (λ = 1/q). The whole smoother — criterion,
4539    // fit, and the Gaussian-bridge `predict` — runs self-consistently in the raw
4540    // covariate units, so the *only* place covariate scale leaks in is this
4541    // outer `log λ` search: a fixed absolute bracket `[LOG_LAMBDA_LO,
4542    // LOG_LAMBDA_HI]` does not track the data span, so at small/large covariate
4543    // scale the equivariant optimum rails out of the bracket and the fit drifts.
4544    // Anchor the bracket to the data's own length scale: search `log λ` around
4545    // `(2m−1)·log L` where `L` is the abscissa span (which scales linearly with
4546    // the covariate), so the search is performed in scale-free units and the
4547    // selected `q · L^{2m−1}` — hence the posterior `f(x)` — is invariant.
4548    let first_x = nodes
4549        .first()
4550        .ok_or_else(|| {
4551            SplineScoreProofError::InvalidInput(
4552                "spline scan: pooled data unexpectedly contain no nodes".to_string(),
4553            )
4554        })?
4555        .x;
4556    let last_x = nodes
4557        .last()
4558        .ok_or_else(|| {
4559            SplineScoreProofError::InvalidInput(
4560                "spline scan: pooled data unexpectedly contain no nodes".to_string(),
4561            )
4562        })?
4563        .x;
4564    let span = last_x - first_x;
4565    if !(span.is_finite() && span > 0.0) {
4566        return Err(SplineScoreProofError::InvalidInput(format!(
4567            "spline scan: pooled covariate span must be finite and positive, got {span}"
4568        )));
4569    }
4570    let log_span = gam_math::score_opt::certified_ln_positive(span).ok_or(
4571        SplineScoreProofError::InvalidArithmetic {
4572            context: "covariate-span logarithm",
4573        },
4574    )?;
4575    let log_span_representative = log_span.lo + 0.5 * (log_span.hi - log_span.lo);
4576    let scale_shift = (2 * order - 1) as f64 * log_span_representative;
4577    let lo_anchor = LOG_LAMBDA_LO + scale_shift;
4578    let hi_anchor = LOG_LAMBDA_HI + scale_shift;
4579    let n_nodes = nodes.len();
4580    let endpoint_certificates = RefCell::new(HashMap::<u64, CertifiedCriterionJet>::new());
4581    let search = maximize_score_1d(
4582        lo_anchor,
4583        hi_anchor,
4584        f64::EPSILON.sqrt(),
4585        |ll| {
4586            let certificate =
4587                certified_concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order)?;
4588            endpoint_certificates
4589                .borrow_mut()
4590                .insert(ll.to_bits(), certificate);
4591            Ok(certificate.jet)
4592        },
4593        |left, right| {
4594            let certificates = endpoint_certificates.borrow();
4595            let left_certificate = certificates
4596                .get(&left.x.to_bits())
4597                .copied()
4598                .ok_or(SplineScoreProofError::MissingEndpointCertificate { log_lambda: left.x })?;
4599            let right_certificate = certificates.get(&right.x.to_bits()).copied().ok_or(
4600                SplineScoreProofError::MissingEndpointCertificate {
4601                    log_lambda: right.x,
4602                },
4603            )?;
4604            concentrated_criterion_enclosure(
4605                n_nodes,
4606                n_obs,
4607                left,
4608                right,
4609                left_certificate,
4610                right_certificate,
4611                order,
4612            )
4613        },
4614    )
4615    .map_err(|error| match error {
4616        gam_math::score_opt::ScoreSearchError::PointEvaluation { source, .. }
4617        | gam_math::score_opt::ScoreSearchError::EnclosureEvaluation { source, .. } => source,
4618        other => SplineScoreProofError::Search(other.to_string()),
4619    })?;
4620    if search.value_certificate.maximum_excess > search.value_certificate.comparison_resolution {
4621        return Err(SplineScoreProofError::GlobalValueOrderingUnresolved {
4622            maximum_excess: search.value_certificate.maximum_excess,
4623            comparison_resolution: search.value_certificate.comparison_resolution,
4624        });
4625    }
4626    match spline_optimum_proof(&search)? {
4627        SplineOptimumProof::Kkt {
4628            bracket: kkt_bracket,
4629            kind: kkt_kind,
4630        } => {
4631            let kkt_enclosure = {
4632                let certificates = endpoint_certificates.borrow();
4633                let left_certificate = certificates.get(&kkt_bracket.lo.to_bits()).copied().ok_or(
4634                    SplineScoreProofError::MissingEndpointCertificate {
4635                        log_lambda: kkt_bracket.lo,
4636                    },
4637                )?;
4638                let right_certificate = certificates
4639                    .get(&kkt_bracket.hi.to_bits())
4640                    .copied()
4641                    .ok_or(SplineScoreProofError::MissingEndpointCertificate {
4642                        log_lambda: kkt_bracket.hi,
4643                    })?;
4644                let sample = |log_lambda: f64, certificate: CertifiedCriterionJet| ScoreSample {
4645                    x: log_lambda,
4646                    value: certificate.jet.value,
4647                    derivative: certificate.jet.derivative,
4648                    curvature: certificate.jet.curvature,
4649                    third: certificate.jet.third,
4650                };
4651                concentrated_criterion_enclosure(
4652                    n_nodes,
4653                    n_obs,
4654                    sample(kkt_bracket.lo, left_certificate),
4655                    sample(kkt_bracket.hi, right_certificate),
4656                    left_certificate,
4657                    right_certificate,
4658                    order,
4659                )?
4660            };
4661            let (kkt_holds, kkt_curvature) = spline_kkt_holds(kkt_kind, kkt_enclosure);
4662            if !kkt_holds {
4663                return Err(SplineScoreProofError::OptimumKktUncertified {
4664                    location: search.location,
4665                    bracket: kkt_bracket,
4666                    derivative: kkt_enclosure.derivative,
4667                    curvature: kkt_curvature,
4668                });
4669            }
4670        }
4671        SplineOptimumProof::ResolutionFlat {
4672            bracket,
4673            max_score_gap,
4674            score_resolution,
4675        } => {
4676            log::debug!(
4677                "spline scan: accepting certified resolution-flat REML optimum on \
4678                 {bracket:?}; maximum score gap {max_score_gap:e} <= comparison \
4679                 resolution {score_resolution:e}"
4680            );
4681        }
4682    }
4683    // The fixed-λ fitter below consumes the historical scalar recurrence.
4684    // Before crossing that seam, independently re-evaluate the selected point
4685    // and require every scalar component to lie in the directed ball that won
4686    // the search. This is one O(n) pass per completed fit, not per search cell.
4687    let selected_certificate = endpoint_certificates
4688        .borrow()
4689        .get(&search.optimum.x.to_bits())
4690        .copied()
4691        .ok_or_else(|| {
4692            SplineScoreProofError::Search(format!(
4693                "spline scan: selected log lambda {} has no cached score certificate",
4694                search.optimum.x
4695            ))
4696        })?;
4697    let independent =
4698        concentrated_criterion_jet(&nodes, ssr_within, n_obs, search.optimum.x, order)
4699            .map_err(SplineScoreProofError::Computation)?;
4700    for (name, ball, scalar) in [
4701        ("value", selected_certificate.value, independent.0),
4702        ("derivative", selected_certificate.derivative, independent.1),
4703        ("curvature", selected_certificate.curvature, independent.2),
4704        ("third", selected_certificate.third, independent.3),
4705    ] {
4706        if !ball.interval().contains(scalar) {
4707            return Err(SplineScoreProofError::Computation(format!(
4708                "spline scan: selected {name} scalar {scalar} escapes its directed score ball {:?}",
4709                ball.interval()
4710            )));
4711        }
4712    }
4713    fit_spline_scan_at(x, y, w, search.optimum.x, None, order)
4714        .map_err(SplineScoreProofError::Computation)
4715}
4716
4717/// Lossless serializable snapshot of a [`SplineScanFit`] (#1034).
4718///
4719/// Carries exactly the smoother state the Gaussian-bridge `predict` replays:
4720/// pooled knots, smoothed `(f, f′, …, f^{(m−1)})` states (`m` per knot),
4721/// smoothed state covariances (unit-σ² scale, symmetric — stored as the
4722/// upper triangle row-major, `m(m+1)/2` per knot), RTS backward gains (full
4723/// `m×m` row-major — gains are NOT symmetric), pooled node weights, and the
4724/// three fit scalars. `q = e^{−log λ}` and the public `mean`/`deriv`/`var`
4725/// views are derived on restore rather than stored, so a snapshot cannot go
4726/// internally inconsistent. The layouts are order-derived; at the historical
4727/// cubic `m = 2` they are exactly the original `[f, f′]` / `[c00, c01, c11]` /
4728/// `[g00, g01, g10, g11]` triples, so pre-order-generality snapshots restore
4729/// unchanged.
4730#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
4731pub struct SplineScanState {
4732    /// Smoothing-spline order `m ∈ {1, 2, 3}` (`#[serde(default)]` → reads as
4733    /// the historical cubic `m = 2` for snapshots written before order
4734    /// generality).
4735    #[serde(default = "default_spline_scan_order")]
4736    pub order: usize,
4737    pub knots: Vec<f64>,
4738    /// Smoothed `(f, f′, …, f^{(m−1)})` per knot, row-major (`m` per knot).
4739    pub state: Vec<f64>,
4740    /// Smoothed covariance per knot at unit-σ² scale, upper triangle row-major
4741    /// (`m(m+1)/2` per knot): `[c00, c01, …, c0,m−1, c11, …, c_{m−1,m−1}]`.
4742    pub cov: Vec<f64>,
4743    /// RTS backward gain per knot, full `m×m` row-major (`m²` per knot); the
4744    /// last knot's gain is structurally unused and stored as written.
4745    pub gain: Vec<f64>,
4746    /// Pooled (tied-abscissa summed) observation weight per knot.
4747    pub node_weight: Vec<f64>,
4748    pub log_lambda: f64,
4749    pub sigma2: f64,
4750    pub restricted_loglik: f64,
4751    /// Original training row count. Required on the wire.
4752    pub training_sample_size: std::num::NonZeroU64,
4753    /// Weighted data residual sum of squares `Σ wᵢ (yᵢ − f̂(xᵢ))²` at the
4754    /// smoothed mean — the Gaussian deviance. Stored because it cannot be
4755    /// recovered from the profiled σ² (whose quadratic also carries
4756    /// process/roughness energy) and the raw rows are not retained.
4757    pub data_sse: f64,
4758}
4759
4760/// Serde default for [`SplineScanState::order`]: historical snapshots predate
4761/// order generality and are cubic (`m = 2`).
4762fn default_spline_scan_order() -> usize {
4763    2
4764}
4765
4766impl SplineScanFit {
4767    /// Snapshot the full smoother state for persistence (#1034).
4768    pub fn to_state(&self) -> SplineScanState {
4769        let order = self.order;
4770        let tri = order * (order + 1) / 2;
4771        let nk = self.knots.len();
4772        let mut state = Vec::with_capacity(order * nk);
4773        for s in &self.smoothed_state {
4774            state.extend_from_slice(&s[..order]);
4775        }
4776        let mut cov = Vec::with_capacity(tri * nk);
4777        for c in &self.smoothed_cov {
4778            for i in 0..order {
4779                for j in i..order {
4780                    cov.push(c[i][j]);
4781                }
4782            }
4783        }
4784        let mut gain = Vec::with_capacity(order * order * nk);
4785        for g in &self.rts_gain {
4786            for i in 0..order {
4787                for j in 0..order {
4788                    gain.push(g[i][j]);
4789                }
4790            }
4791        }
4792        SplineScanState {
4793            order: self.order,
4794            knots: self.knots.clone(),
4795            state,
4796            cov,
4797            gain,
4798            node_weight: self.node_weight.clone(),
4799            log_lambda: self.log_lambda,
4800            sigma2: self.sigma2,
4801            restricted_loglik: self.restricted_loglik,
4802            training_sample_size: std::num::NonZeroU64::new(
4803                u64::try_from(self.training_sample_size.get())
4804                    .expect("SplineScanFit row count exceeds the persistence format"),
4805            )
4806            .expect("SplineScanFit construction requires training rows"),
4807            data_sse: self.data_sse,
4808        }
4809    }
4810
4811    /// Rebuild the exact in-memory fit from a persisted snapshot (#1034).
4812    ///
4813    /// Validates shape, finiteness, strict knot ordering, positive weights and
4814    /// σ², so a corrupt payload fails loudly here instead of inside a later
4815    /// `predict`. The restored fit replays the Gaussian bridge bit-for-bit:
4816    /// every field `predict`/`edf`/`deriv_at_knot` reads is either stored
4817    /// verbatim or derived by the same expressions the fitter uses.
4818    pub fn from_state(state: &SplineScanState) -> Result<Self, String> {
4819        let order = state.order;
4820        if order == 0 || order > MAX_ORDER {
4821            return Err(format!(
4822                "spline scan state: order must be in 1..={MAX_ORDER}, got {order}"
4823            ));
4824        }
4825        let m = state.knots.len();
4826        if m < order + 1 {
4827            return Err(format!(
4828                "spline scan state: order {order} needs at least {} knots, got {m}",
4829                order + 1
4830            ));
4831        }
4832        let tri = order * (order + 1) / 2;
4833        if state.state.len() != order * m
4834            || state.cov.len() != tri * m
4835            || state.gain.len() != order * order * m
4836            || state.node_weight.len() != m
4837        {
4838            return Err(format!(
4839                "spline scan state: inconsistent lengths (order={order}, m={m}, state={}, cov={}, gain={}, weights={})",
4840                state.state.len(),
4841                state.cov.len(),
4842                state.gain.len(),
4843                state.node_weight.len()
4844            ));
4845        }
4846        let all = state
4847            .state
4848            .iter()
4849            .chain(&state.cov)
4850            .chain(&state.gain)
4851            .chain(&state.knots)
4852            .chain(&state.node_weight);
4853        for (i, v) in all.enumerate() {
4854            if !v.is_finite() {
4855                return Err(format!("spline scan state: non-finite entry at {i}"));
4856            }
4857        }
4858        gam_problem::validate_log_strength(state.log_lambda)
4859            .map_err(|error| format!("spline scan state: {error}"))?;
4860        if !(state.restricted_loglik.is_finite() && state.sigma2.is_finite() && state.sigma2 > 0.0)
4861        {
4862            return Err(format!(
4863                "spline scan state: invalid scalars (log_lambda={}, sigma2={}, restricted_loglik={})",
4864                state.log_lambda, state.sigma2, state.restricted_loglik
4865            ));
4866        }
4867        if !(state.data_sse.is_finite() && state.data_sse >= 0.0) {
4868            return Err(format!(
4869                "spline scan state: invalid data_sse {}",
4870                state.data_sse
4871            ));
4872        }
4873        if state.knots.windows(2).any(|kk| !(kk[0] < kk[1])) {
4874            return Err("spline scan state: knots must be strictly increasing".to_string());
4875        }
4876        if state.node_weight.iter().any(|&w| w <= 0.0) {
4877            return Err("spline scan state: node weights must be positive".to_string());
4878        }
4879        let smoothed_state: Vec<Vec2> = state
4880            .state
4881            .chunks_exact(order)
4882            .map(|s| {
4883                let mut v = [0.0_f64; MAX_ORDER];
4884                v[..order].copy_from_slice(s);
4885                v
4886            })
4887            .collect();
4888        let smoothed_cov: Vec<Mat2> = state
4889            .cov
4890            .chunks_exact(tri)
4891            .map(|c| {
4892                let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4893                let mut idx = 0;
4894                for i in 0..order {
4895                    for j in i..order {
4896                        mm[i][j] = c[idx];
4897                        mm[j][i] = c[idx];
4898                        idx += 1;
4899                    }
4900                }
4901                mm
4902            })
4903            .collect();
4904        let rts_gain: Vec<Mat2> = state
4905            .gain
4906            .chunks_exact(order * order)
4907            .map(|g| {
4908                let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4909                for i in 0..order {
4910                    for j in 0..order {
4911                        mm[i][j] = g[i * order + j];
4912                    }
4913                }
4914                mm
4915            })
4916            .collect();
4917        let sigma2 = state.sigma2;
4918        let training_sample_size =
4919            usize::try_from(state.training_sample_size.get()).map_err(|_| {
4920                format!(
4921                    "spline scan state: training_sample_size {} exceeds this platform's usize",
4922                    state.training_sample_size
4923                )
4924            })?;
4925        Ok(Self {
4926            order,
4927            knots: state.knots.clone(),
4928            mean: smoothed_state.iter().map(|s| s[0]).collect(),
4929            deriv: (order >= 2).then(|| smoothed_state.iter().map(|s| s[1]).collect()),
4930            var: smoothed_cov.iter().map(|c| c[0][0] * sigma2).collect(),
4931            log_lambda: state.log_lambda,
4932            sigma2,
4933            restricted_loglik: state.restricted_loglik,
4934            training_sample_size: std::num::NonZeroUsize::new(training_sample_size)
4935                .expect("nonzero wire count remains nonzero after conversion"),
4936            data_sse: state.data_sse,
4937            smoothed_state,
4938            smoothed_cov,
4939            rts_gain,
4940            q: gam_problem::checked_exp_log_strength(-state.log_lambda)
4941                .map_err(|error| format!("spline scan inverse log strength: {error}"))?,
4942            node_weight: state.node_weight.clone(),
4943        })
4944    }
4945
4946    /// Exact posterior `(mean, variance)` of `f` at an arbitrary abscissa.
4947    ///
4948    /// Interior points use the Gaussian bridge conditional on the two flanking
4949    /// smoothed states with the exact lag-one smoothed cross-covariance
4950    /// `Cov(α_t, α_{t+1} | y) = G_t · P^s_{t+1}`; exterior points extrapolate
4951    /// from the boundary state (linear mean, cubically growing variance).
4952    pub fn predict(&self, x_new: f64) -> Result<(f64, f64), String> {
4953        if !x_new.is_finite() {
4954            return Err("spline scan: non-finite prediction abscissa".to_string());
4955        }
4956        let n = self.knots.len();
4957        let order = self.order;
4958        let first = self.knots[0];
4959        let last = self.knots[n - 1];
4960        if x_new <= first {
4961            let delta = first - x_new;
4962            // Backward extrapolation through the reverse map α(x) = F⁻¹(α₁ − η).
4963            let f_t = transition(delta, order);
4964            let f_inv = mat_inv(&f_t, order, "backward extrapolation transition")?;
4965            let mean_s = mat_vec(&f_inv, &self.smoothed_state[0], order);
4966            let qm = process_noise(delta, self.q, order);
4967            let cov = mat_add(
4968                &mat_mul(
4969                    &mat_mul(&f_inv, &self.smoothed_cov[0], order),
4970                    &mat_t(&f_inv, order),
4971                    order,
4972                ),
4973                &mat_mul(&mat_mul(&f_inv, &qm, order), &mat_t(&f_inv, order), order),
4974                order,
4975            );
4976            return Ok((mean_s[0], cov[0][0] * self.sigma2));
4977        }
4978        if x_new >= last {
4979            let delta = x_new - last;
4980            let f_t = transition(delta, order);
4981            let mean_s = mat_vec(&f_t, &self.smoothed_state[n - 1], order);
4982            let cov = mat_add(
4983                &mat_mul(
4984                    &mat_mul(&f_t, &self.smoothed_cov[n - 1], order),
4985                    &mat_t(&f_t, order),
4986                    order,
4987                ),
4988                &process_noise(delta, self.q, order),
4989                order,
4990            );
4991            return Ok((mean_s[0], cov[0][0] * self.sigma2));
4992        }
4993        // Flanking knot interval via binary search.
4994        let t = match self.knots.binary_search_by(|k| k.total_cmp(&x_new)) {
4995            Ok(idx) => return Ok((self.mean[idx], self.var[idx])),
4996            Err(idx) => idx - 1,
4997        };
4998        let (xa, xb) = (self.knots[t], self.knots[t + 1]);
4999        let (d1, d2) = (x_new - xa, xb - x_new);
5000        let (f1m, f2m) = (transition(d1, order), transition(d2, order));
5001        let (q1, q2) = (
5002            process_noise(d1, self.q, order),
5003            process_noise(d2, self.q, order),
5004        );
5005        let q1_inv = mat_inv(&q1, order, "bridge left noise")?;
5006        let q2_inv = mat_inv(&q2, order, "bridge right noise")?;
5007        // p(α* | α_t, α_{t+1}) ∝ N(α*; F₁α_t, Q₁)·N(α_{t+1}; F₂α*, Q₂):
5008        //   Λ = Q₁⁻¹ + F₂ᵀQ₂⁻¹F₂,  mean = Λ⁻¹(Q₁⁻¹F₁ α_t + F₂ᵀQ₂⁻¹ α_{t+1}).
5009        let lambda = mat_add(
5010            &q1_inv,
5011            &mat_mul(&mat_mul(&mat_t(&f2m, order), &q2_inv, order), &f2m, order),
5012            order,
5013        );
5014        let lam_inv = mat_inv(&lambda, order, "bridge precision")?;
5015        let ca = mat_mul(&lam_inv, &mat_mul(&q1_inv, &f1m, order), order);
5016        let cb = mat_mul(
5017            &lam_inv,
5018            &mat_mul(&mat_t(&f2m, order), &q2_inv, order),
5019            order,
5020        );
5021        let ma = mat_vec(&ca, &self.smoothed_state[t], order);
5022        let mb = mat_vec(&cb, &self.smoothed_state[t + 1], order);
5023        let mut mean_s = [0.0_f64; MAX_ORDER];
5024        for i in 0..order {
5025            mean_s[i] = ma[i] + mb[i];
5026        }
5027        // Push the joint smoothed covariance of (α_t, α_{t+1}) through the
5028        // affine map: cross term uses Cov(α_t, α_{t+1}|y) = G_t · P^s_{t+1}.
5029        let cross = mat_mul(&self.rts_gain[t], &self.smoothed_cov[t + 1], order);
5030        let mut cov = mat_add(
5031            &mat_add(
5032                &mat_mul(
5033                    &mat_mul(&ca, &self.smoothed_cov[t], order),
5034                    &mat_t(&ca, order),
5035                    order,
5036                ),
5037                &mat_mul(
5038                    &mat_mul(&cb, &self.smoothed_cov[t + 1], order),
5039                    &mat_t(&cb, order),
5040                    order,
5041                ),
5042                order,
5043            ),
5044            &lam_inv,
5045            order,
5046        );
5047        let cab = mat_mul(&mat_mul(&ca, &cross, order), &mat_t(&cb, order), order);
5048        cov = mat_add(&cov, &mat_add(&cab, &mat_t(&cab, order), order), order);
5049        symmetrize(&mut cov, order);
5050        Ok((mean_s[0], cov[0][0] * self.sigma2))
5051    }
5052
5053    /// Exact effective degrees of freedom of the fitted smoother.
5054    ///
5055    /// For a Gaussian smoother the influence (hat) matrix is
5056    /// `S = Cov_post · W / σ²` (posterior mean is linear in `y` with that
5057    /// exact coefficient matrix), so
5058    /// `EDF = tr(S) = tr(W · Cov_post) / σ² = Σ_t w_t · Var_smoothed(f_t) / σ²`.
5059    /// This is the standard Gaussian-process identity — no second smoother
5060    /// pass and no approximation. Tied abscissae pool exactly: each raw row
5061    /// `i` in tie-group `k` contributes `∂f̂(x_k)/∂y_i = C̃_kk · w_i` (the
5062    /// pooled mean `ȳ_k` is precision-weighted), so the raw-row trace
5063    /// `Σ_i w_i · C̃_{k(i),k(i)}` collapses to `Σ_k W_k · C̃_kk` with the
5064    /// pooled weights `W_k`. `smoothed_cov` is stored at unit-σ² scale
5065    /// (`C̃ = Cov_post / σ²`), so the σ² factors cancel exactly.
5066    pub fn edf(&self) -> f64 {
5067        self.node_weight
5068            .iter()
5069            .zip(self.smoothed_cov.iter())
5070            .map(|(w, c)| w * c[0][0])
5071            .sum()
5072    }
5073
5074    /// Posterior `(mean, variance)` of the derivative `f′` at a knot index.
5075    ///
5076    /// `None` at order `m = 1`: the latent process is Brownian motion, which
5077    /// is almost surely nondifferentiable — there is no derivative state, and
5078    /// fabricating a "known zero" `(0, 0)` would assert certainty about a
5079    /// quantity that does not exist.
5080    pub fn deriv_at_knot(&self, t: usize) -> Option<(f64, f64)> {
5081        (self.order >= 2).then(|| {
5082            (
5083                self.smoothed_state[t][1],
5084                self.smoothed_cov[t][1][1] * self.sigma2,
5085            )
5086        })
5087    }
5088
5089    /// Selected smoothing parameter `λ = e^{log λ}` (#1046).
5090    pub fn lambda(&self) -> f64 {
5091        gam_problem::checked_exp_log_strength(self.log_lambda)
5092            .expect("SplineScanFit construction validates its private log strength")
5093    }
5094
5095    pub fn log_lambda(&self) -> f64 {
5096        self.log_lambda
5097    }
5098
5099    /// Number of original training rows / experimental units.
5100    pub fn training_sample_size(&self) -> usize {
5101        self.training_sample_size.get()
5102    }
5103
5104    /// Gaussian deviance — the weighted DATA residual sum of squares
5105    /// `Σ wᵢ(yᵢ − f̂ᵢ)²` at the smoothed mean (#1046). This is the stored
5106    /// `data_sse`, computed against the fitted values at fit time. It is NOT
5107    /// `σ̂²·(n − order)`: the profiled σ² divides the REML innovations
5108    /// quadratic, which is data residual energy PLUS process/roughness energy
5109    /// at the posterior mode (for order 1 on `x = (0,1)`, `y = (0,1)`, unit
5110    /// weights and λ = 1 the posterior mean is `(1/3, 2/3)`; the data SSE is
5111    /// 2/9 while `σ̂²·(n − order) = 1/3`, the extra 1/9 being penalty energy).
5112    pub fn deviance(&self) -> f64 {
5113        self.data_sse
5114    }
5115}
5116
5117#[cfg(test)]
5118mod tests {
5119    /// Seed a covariance zonotope without throwing away exact symmetry.
5120    ///
5121    /// The two off-diagonal storage locations denote one real covariance entry.
5122    /// A shared generator therefore encloses their common error while preserving
5123    /// that identity; two independent axis generators would immediately forget it
5124    /// and recreate the componentwise wrapping effect on the first congruence.
5125    fn covariance_zonotope_from_symmetric_matrix(
5126        matrix: &BallMat,
5127        order: usize,
5128    ) -> Zonotope<COVARIANCE_D1_DIM> {
5129        let mut state = Zonotope::<COVARIANCE_D1_DIM>::zeroed(order * order);
5130        for i in 0..order {
5131            for j in i..order {
5132                let value = matrix[i][j].value;
5133                state.center[i * order + j] = value;
5134                state.center[j * order + i] = value;
5135                let radius = [
5136                    (value - matrix[i][j].lo).abs(),
5137                    (matrix[i][j].hi - value).abs(),
5138                    (value - matrix[j][i].lo).abs(),
5139                    (matrix[j][i].hi - value).abs(),
5140                ]
5141                .into_iter()
5142                .fold(0.0_f64, f64::max);
5143                if radius > 0.0 {
5144                    let mut generator = [0.0_f64; COVARIANCE_D1_DIM];
5145                    let radius = next_up_ball(radius);
5146                    generator[i * order + j] = radius;
5147                    generator[j * order + i] = radius;
5148                    state.generators.push(generator);
5149                }
5150            }
5151        }
5152        state
5153    }
5154
5155
5156
5157    /// Compaction must preserve the signed directions that make a contracting
5158    /// recursion contract.  Fresh roundoff enters as axis generators, so age
5159    /// based reduction used to fold this old `[1, -1]` direction first and
5160    /// replace it by the expanding box `[±1] × [±1]`.
5161    #[test]
5162    fn zonotope_compaction_retains_correlation_before_axis_roundoff() {
5163        let mut state = Zonotope::<2>::zeroed(2);
5164        state.generators.push([1.0, -1.0]);
5165        for i in 0..ZONOTOPE_GENERATOR_CAP {
5166            state
5167                .generators
5168                .push(if i % 2 == 0 { [0.25, 0.0] } else { [0.0, 0.25] });
5169        }
5170
5171        state.compact();
5172
5173        assert!(state.generators.len() <= ZONOTOPE_GENERATOR_CAP);
5174        assert!(
5175            state
5176                .generators
5177                .iter()
5178                .any(|generator| *generator == [1.0, -1.0]),
5179            "compaction discarded the only signed correlation direction"
5180        );
5181    }
5182
5183    /// Two occurrences of `qQ` contain ONE uncertain `q`, not two independent
5184    /// interval choices. The distinguished coefficient must therefore add
5185    /// under the identity and cancel under an opposing signed map. Turning
5186    /// each occurrence into a fresh axis generator leaves radius `2|g|` in the
5187    /// cancellation arm and cannot prove the exact identity.
5188    #[test]
5189    fn shared_q_process_noise_injections_accumulate_and_cancel_as_one_generator() {
5190        let q = Ball {
5191            value: 10.0,
5192            lo: 9.0,
5193            hi: 11.0,
5194        };
5195        let noise = ball_process_noise_taylor(Ball::exact(2.0), q, 1);
5196        let g = noise.shared_q[0];
5197        assert!(g > 0.0);
5198
5199        let identity = zonotope_identity_map::<COVARIANCE_D1_DIM>(1);
5200        let mut accumulated = Zonotope::<COVARIANCE_D1_DIM>::zeroed(1);
5201        assert!(accumulated.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5202        assert!(accumulated.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5203        assert_eq!(accumulated.shared_q[0], 2.0 * g);
5204
5205        let mut negative_identity = [[Ball::ZERO; COVARIANCE_D1_DIM]; COVARIANCE_D1_DIM];
5206        negative_identity[0][0] = Ball::exact(-1.0);
5207        let mut cancelled = Zonotope::<COVARIANCE_D1_DIM>::zeroed(1);
5208        assert!(cancelled.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5209        assert!(cancelled.apply_with_shared_q(
5210            &negative_identity,
5211            &noise.constant,
5212            &noise.shared_q,
5213        ));
5214        assert_eq!(cancelled.shared_q[0], 0.0);
5215
5216        let old_independent_radius = 2.0 * g.abs();
5217        assert!(
5218            ball_radius_about_value(cancelled.coordinate(0)) < old_independent_radius * 1.0e-10,
5219            "independent qQ axes would retain radius {old_independent_radius:e}, \
5220             but the shared-q cancellation left {:?}",
5221            cancelled.coordinate(0),
5222        );
5223    }
5224
5225    #[test]
5226    fn centred_riccati_zonotope_contains_an_off_centre_covariance_and_noise() {
5227        let centres = [[4.0, 1.0, 0.3], [1.0, 3.0, 0.2], [0.3, 0.2, 2.0]];
5228        let radii = [[0.2, 0.1, 0.08], [0.1, 0.2, 0.07], [0.08, 0.07, 0.2]];
5229        let mut enclosure = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
5230        for i in 0..MAX_ORDER {
5231            for j in 0..MAX_ORDER {
5232                enclosure[i][j] = Ball {
5233                    value: centres[i][j],
5234                    lo: centres[i][j] - radii[i][j],
5235                    hi: centres[i][j] + radii[i][j],
5236                };
5237            }
5238        }
5239        let mut state = covariance_zonotope_from_symmetric_matrix(&enclosure, MAX_ORDER);
5240        let observation_variance = Ball {
5241            value: 1.2,
5242            lo: 1.1,
5243            hi: 1.3,
5244        };
5245        assert!(covariance_zonotope_measurement_update(
5246            &mut state,
5247            observation_variance,
5248            MAX_ORDER,
5249        ));
5250
5251        let actual = [[4.1, 0.95, 0.35], [0.95, 3.1, 0.15], [0.35, 0.15, 1.9]];
5252        let actual_r = 1.25;
5253        let innovation = actual[0][0] + actual_r;
5254        for i in 0..MAX_ORDER {
5255            for j in 0..MAX_ORDER {
5256                let updated = actual[i][j] - actual[i][0] * actual[0][j] / innovation;
5257                assert!(
5258                    state
5259                        .coordinate(i * MAX_ORDER + j)
5260                        .interval()
5261                        .contains(updated),
5262                    "updated covariance ({i},{j})={updated} escaped {:?}",
5263                    state.coordinate(i * MAX_ORDER + j).interval()
5264                );
5265            }
5266        }
5267    }
5268
5269    /// Diagnostic reproduction of the #2300 weighted-scan non-termination:
5270    /// the exact acceptance DGP (n=180, step weights 1/9), with the SAME
5271    /// certified search `fit_spline_scan` runs — but through a counting
5272    /// wrapper that bails out with the evaluation count and the stuck
5273    /// abscissa once the search exceeds a budget no terminating search on a
5274    /// 36-wide bracket can legitimately need. A pass proves termination in
5275    /// bounded work; the panic message is the diagnosis.
5276    #[test]
5277    fn weighted_scan_dgp_2300_search_terminates_in_bounded_evaluations() {
5278        // Deterministic stand-in for the acceptance DGP (xorshift Box-Muller;
5279        // the hang class is structural, not noise-realization-specific). Shared
5280        // with the `d3` enclosure diagnostic so the two cannot drift apart.
5281        let (x, y, w) = dgp_2300();
5282        // Every smoothing order, not just the cubic: the order-3 (quintic)
5283        // search has a deeper λ→∞ tail walk (scale shift (2m−1)·log L) and a
5284        // larger residual-d.f. Lipschitz constant, and was the remaining
5285        // effective hang after the order-2 fix (#2300 — the degree-5
5286        // observation-interval node timed out at 1500s). Endpoint-pair V‴
5287        // interpolation certifies its tail at fourth-order rate, so a uniform
5288        // budget far below the pre-fix eval counts must hold at all orders.
5289        //
5290        // The three orders are mathematically independent. Run them as three
5291        // scoped, single-core lanes so this regression's wall time is the
5292        // maximum order cost instead of their sum; three workers are negligible
5293        // on the remote validation nodes and avoid turning a performance test
5294        // into its own serial bottleneck.
5295        std::thread::scope(|scope| {
5296            for order in 1..=MAX_ORDER {
5297                let (x, y, w) = (&x, &y, &w);
5298                scope.spawn(move || {
5299                    let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order).expect("pool");
5300                    let span = nodes.last().unwrap().x - nodes.first().unwrap().x;
5301                    let scale_shift = (2 * order - 1) as f64 * span.ln();
5302                    let lo = LOG_LAMBDA_LO + scale_shift;
5303                    let hi = LOG_LAMBDA_HI + scale_shift;
5304
5305                    let n_nodes = nodes.len();
5306                    let evals = std::cell::Cell::new(0u64);
5307                    let last_x = std::cell::Cell::new(f64::NAN);
5308                    let endpoint_certificates =
5309                        RefCell::new(HashMap::<u64, CertifiedCriterionJet>::new());
5310                    let budget = 4_096u64;
5311                    let result = gam_math::score_opt::maximize_score_1d(
5312                        lo,
5313                        hi,
5314                        f64::EPSILON.sqrt(),
5315                        |ll| {
5316                            let count = evals.get() + 1;
5317                            evals.set(count);
5318                            last_x.set(ll);
5319                            assert!(
5320                                count <= budget,
5321                                "order-{order} certified scan search exceeded {budget} criterion \
5322                                 evaluations (last log-lambda sample {ll:.9}; bracket \
5323                                 [{lo:.3}, {hi:.3}]) — non-terminating subdivision reproduced"
5324                            );
5325                            let certificate = certified_concentrated_criterion_jet(
5326                                &nodes, ssr_within, n_obs, ll, order,
5327                            )?;
5328                            endpoint_certificates
5329                                .borrow_mut()
5330                                .insert(ll.to_bits(), certificate);
5331                            Ok(certificate.jet)
5332                        },
5333                        |a, b| {
5334                            let certificates = endpoint_certificates.borrow();
5335                            let left = certificates.get(&a.x.to_bits()).copied().ok_or(
5336                                SplineScoreProofError::MissingEndpointCertificate {
5337                                    log_lambda: a.x,
5338                                },
5339                            )?;
5340                            let right = certificates.get(&b.x.to_bits()).copied().ok_or(
5341                                SplineScoreProofError::MissingEndpointCertificate {
5342                                    log_lambda: b.x,
5343                                },
5344                            )?;
5345                            concentrated_criterion_enclosure(
5346                                n_nodes, n_obs, a, b, left, right, order,
5347                            )
5348                        },
5349                    );
5350                    match result {
5351                        Ok(search) => assert!(
5352                            search.optimum.x.is_finite(),
5353                            "order-{order} search must return a finite optimum"
5354                        ),
5355                        Err(error) => panic!(
5356                            "order-{order} weighted scan search failed after {} evaluations \
5357                             (last x {:.9}): {error:?}",
5358                            evals.get(),
5359                            last_x.get()
5360                        ),
5361                    }
5362                });
5363            }
5364        });
5365    }
5366
5367    /// The #2300 weighted-scan DGP, as its own function so the certified-search
5368    /// test and the `d3` enclosure diagnostics below read the SAME data rather
5369    /// than two copies that can drift apart.
5370    fn dgp_2300() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
5371        let n = 180usize;
5372        let mut state: u64 = 0x2300_2300_2300_2300;
5373        let mut next_unit = move || {
5374            state ^= state << 13;
5375            state ^= state >> 7;
5376            state ^= state << 17;
5377            (state >> 11) as f64 / (1u64 << 53) as f64
5378        };
5379        let mut x = Vec::with_capacity(n);
5380        let mut y = Vec::with_capacity(n);
5381        let mut w = Vec::with_capacity(n);
5382        for i in 0..n {
5383            let xi = -2.0 + 4.0 * (i as f64) / ((n - 1) as f64);
5384            let wi: f64 = if xi < 0.0 { 1.0 } else { 9.0 };
5385            let u1 = next_unit().max(1e-12);
5386            let u2 = next_unit();
5387            let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
5388            x.push(xi);
5389            w.push(wi);
5390            y.push(0.4 + (1.3 * xi).sin() + (0.45 / wi.sqrt()) * z);
5391        }
5392        (x, y, w)
5393    }
5394
5395    /// The certified derivative ladder reaches exact endpoint jets throughout
5396    /// the search domain that exposed #2614.
5397    ///
5398    /// This fixture used to refuse at smoothing order 3 throughout
5399    /// `-20 <= rho <= -10` when its covariance-derivative zonotope overflowed.
5400    /// Merely accepting a global analytic fallback there would be sound but
5401    /// would reintroduce the loose cells that exhausted the subdivision budget.
5402    /// The repaired centred Riccati/shared-`q` representation must instead
5403    /// preserve enough dependence for BOTH curvature and third derivative to
5404    /// come from their endpoint jets at every measured point.
5405    #[test]
5406    fn certified_ladder_reaches_endpoint_jets_across_the_search_domain() {
5407        let (x, y, w) = dgp_2300();
5408        let visited = [
5409            -24.0_f64,
5410            -20.0,
5411            -18.0,
5412            -16.6135,
5413            // The log-lambda the #2300 certified search refuses at, order 2.
5414            -13.841116916640328,
5415            -10.0,
5416            -6.0,
5417            0.0,
5418            6.0,
5419        ];
5420        for order in 1..=MAX_ORDER {
5421            let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5422            for &log_lambda in &visited {
5423                let certificate = certified_concentrated_criterion_jet(
5424                    &nodes, within, n_obs, log_lambda, order,
5425                )
5426                .unwrap_or_else(|error| {
5427                    panic!(
5428                        "order {order}, rho {log_lambda}: repaired certified ladder refused: \
5429                         {error:?}"
5430                    )
5431                });
5432                assert_eq!(
5433                    certificate.curvature_source,
5434                    BoundSource::EndpointJet,
5435                    "order {order}, rho {log_lambda}: curvature lost its exact endpoint anchor"
5436                );
5437                assert_eq!(
5438                    certificate.third_source,
5439                    BoundSource::EndpointJet,
5440                    "order {order}, rho {log_lambda}: third derivative lost its exact endpoint anchor"
5441                );
5442            }
5443        }
5444    }
5445
5446    /// The certified criterion jet stays inside the range the Gaussian model
5447    /// gives it, on the fixture where it did not (#2614).
5448    ///
5449    /// `V′ = −½(Σ log F̃)′ − ½·ν·(Σ v²/F̃)′/rss`, and the exact accumulator ranges
5450    /// (see [`intersect_first_order_accumulator_exact_ranges`]) are
5451    /// `−r ≤ (Σ log F̃)′ ≤ 0` and `0 ≤ (Σ v²/F̃)′ ≤ Σ v²/F̃ ≤ rss`, so
5452    /// `−ν/2 ≤ V′ ≤ r/2` — a width of at most `(r + ν)/2`. Measured before those
5453    /// ranges were applied: `±1.95e91` at order 2, `ρ = −18`, i.e. 89 orders of
5454    /// magnitude outside a range the model fixes at `178`. That is what made the
5455    /// search report `Unresolved` rather than bracket a stationary point.
5456    ///
5457    /// Containment is asserted FIRST and against an independent recurrence: the
5458    /// ball jet must enclose the scalar `f64` jet, which shares no arithmetic
5459    /// with it. A narrower enclosure that stops containing the value it encloses
5460    /// is a worse defect than the width this test exists to bound.
5461    #[test]
5462    fn the_certified_jet_contains_the_scalar_jet_and_stays_in_its_closed_form_range() {
5463        let (x, y, w) = dgp_2300();
5464        for order in 1..=MAX_ORDER {
5465            let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5466            let proper_modes = (nodes.len() - order) as f64;
5467            let residual_dof = (n_obs - order) as f64;
5468            for &rho in &[
5469                -18.0_f64,
5470                -16.6135,
5471                -13.841116916640328,
5472                -10.0,
5473                -6.0,
5474                0.0,
5475                6.0,
5476            ] {
5477                let Ok(certificate) =
5478                    certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5479                else {
5480                    continue;
5481                };
5482                let scalar = concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5483                    .expect("independent scalar recurrence");
5484                assert!(
5485                    certificate.value.interval().contains(scalar.0),
5486                    "order={order} rho={rho}: scalar value {} escaped {:?}",
5487                    scalar.0,
5488                    certificate.value
5489                );
5490                assert!(
5491                    certificate.derivative.interval().contains(scalar.1),
5492                    "order={order} rho={rho}: scalar derivative {} escaped {:?}",
5493                    scalar.1,
5494                    certificate.derivative
5495                );
5496                let width = certificate.derivative.hi - certificate.derivative.lo;
5497                assert!(
5498                    width < proper_modes + residual_dof,
5499                    "order={order} rho={rho}: the certified derivative ball is {width:e} \
5500                     wide, outside the closed-form range the accumulators are bounded to \
5501                     ({:e}); the search cannot sign an interval that wide",
5502                    0.5 * (proper_modes + residual_dof)
5503                );
5504            }
5505        }
5506    }
5507
5508    /// The amplifier behind every width in this file, measured on the map
5509    /// itself rather than inferred from what it produces — and the one bound on
5510    /// it that needs no interval product.
5511    ///
5512    /// The filtered covariance sits at its Riccati fixed point on this fixture
5513    /// (`P₁₁ = 1225.652` and `P₂₂ = 950572.8` at nodes 40, 60 and 80 alike), so
5514    /// the recursion's true width map is the closed-loop congruence
5515    /// `Ψ = F A`, `A = I − K e₀ᵀ`, and it CONTRACTS. The componentwise interval
5516    /// evaluation of that same recursion propagates widths through `|Ψ|`
5517    /// instead, and that EXPLODES. Both products are formed here, over the whole
5518    /// proper range, from the traced per-node gains.
5519    ///
5520    /// This is not dependency loss that a corner or exact-range evaluation can
5521    /// reach. `Ψ` has `−K_i` below the diagonal of its first column and `+δ`
5522    /// above it, so the sign of the `1↔2` cycle is NEGATIVE — and a cycle's sign
5523    /// is invariant under diagonal similarity, so no rescaling of the state
5524    /// makes `|Ψ| = Ψ`. The cancellation is between coordinates of one step, and
5525    /// no componentwise interval arithmetic in any diagonal basis can see it.
5526    /// Every enclosure this file builds by recursion over nodes — the
5527    /// covariance, its jets, the mean, and equally the BACKWARD smoother
5528    /// recursions a closed-form `V′` would need, which propagate through `Ψᵀ`
5529    /// and inherit the same factor — is bounded below by it.
5530    ///
5531    /// THE THIRD COLUMN is the reason this test is worth its cost. The Riccati
5532    /// recursion is `P⁻_{t+1} = Ψ_t P⁻_t Ψ_tᵀ + G_t` with
5533    /// `G_t = F R K Kᵀ Fᵀ + Q ⪰ 0`, which is an IDENTITY at every node and not
5534    /// only at a fixed point. So `Ψ_t P⁻_t Ψ_tᵀ ⪯ P⁻_{t+1}`, and with
5535    /// `S_t = (P⁻_{t+1})^{-1/2} Ψ_t (P⁻_t)^{1/2}` that says `‖S_t‖₂ ≤ 1` —
5536    /// the closed loop is a contraction in the metric its own covariance
5537    /// defines. The product telescopes,
5538    /// `Π Ψ = (P⁻_b)^{1/2}(S_b ⋯ S_a)(P⁻_a)^{-1/2}`, so `Π‖S_t‖₂` bounds the
5539    /// signed product with NO interval product formed anywhere: a per-node
5540    /// scalar, each one certifiable on its own. That is what a windowed repair
5541    /// would need in place of the exploding column, and this test measures
5542    /// whether the sub-multiplicative bound is strong enough to be that
5543    /// replacement — `Π‖S_t‖` against the `‖Π Ψ‖` it must stand in for.
5544    ///
5545    /// As measured (order 3, ρ = −16.6135, 175 closed-loop steps):
5546    ///
5547    /// ```text
5548    ///   steps    ‖Π Ψ‖        ‖Π |Ψ|‖       Π‖S_t‖
5549    ///      20    1.73e−1      9.01e+6       8.08e−1
5550    ///      40    1.13e−3      2.81e+11      6.48e−1
5551    ///      80    1.02e−9      2.74e+20      4.17e−1
5552    ///     120    1.83e−17     1.78e+33      1.52e−1
5553    ///     175    9.54e−30     4.40e+51      3.19e−2
5554    ///   per step 0.6826       1.9729        0.98050   (worst step 0.993226)
5555    /// ```
5556    ///
5557    /// Read all three. The filter contracts by 30 orders of magnitude over its
5558    /// own data while the componentwise interval evaluation of the same
5559    /// recursion inflates by 51 — 81 orders between what the filter does and
5560    /// what that arithmetic can prove about it, and `4.4e51 × 2.2e−16` is why
5561    /// quantities whose values are bit-stable carry enclosures of no
5562    /// information at all.
5563    ///
5564    /// And the Lyapunov column HOLDS but does not RESCUE. Every `‖S_t‖₂` is at
5565    /// most one exactly as the Riccati identity says (largest 0.993226), so the
5566    /// bound is real and needs no interval product — but `Π‖S_t‖` decays at
5567    /// 0.98050 per step against the true 0.6826, so over the same 175 steps it
5568    /// certifies `3.19e−2` where the truth is `9.54e−30`. Twenty-seven orders
5569    /// too weak, because `‖S_t‖₂` is the WORST direction — the barely-observed
5570    /// curvature coordinate, contracting at 0.9932 — while the product contracts
5571    /// fast only because its dominant directions ROTATE, which submultiplicativity
5572    /// cannot see.
5573    ///
5574    /// What that leaves is not "the Lyapunov structure is useless" but a
5575    /// sharper statement of the repair: the `S_t` are contractions in the metric
5576    /// the covariance defines, so an interval product OF THE `S_t` grows its
5577    /// widths additively rather than geometrically. Carrying the enclosure in
5578    /// `P^{1/2}` coordinates — not bounding the product by a product of bounds —
5579    /// is the move, and this test certifies per node the one property that makes
5580    /// that preconditioner the right one.
5581    #[test]
5582    fn the_closed_loop_map_contracts_while_its_absolute_value_explodes() {
5583        let (x, y, w) = dgp_2300();
5584        let order = 3;
5585        // This is the middle of the formerly refusing order-3 tail.
5586        let log_lambda = -16.6135_f64;
5587        let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5588        let q_value =
5589            gam_problem::checked_exp_log_strength(-log_lambda).expect("inverse log strength");
5590        let q = Ball::certified(
5591            q_value,
5592            gam_math::score_opt::certified_exp(-log_lambda).expect("certified exponential"),
5593        );
5594        let mut trace: Vec<BallTraceRecord> = Vec::new();
5595        certified_concentrated_criterion_jet(&nodes, within, n_obs, log_lambda, order)
5596            .expect("the certified jet must exist at the rho this map is measured at");
5597        run_filter_ball_traced(&nodes, q, order, Some(&mut trace)).expect("traced pass");
5598        let mut gains: HashMap<usize, [f64; MAX_ORDER]> = HashMap::new();
5599        let mut predicted: HashMap<usize, Mat2> = HashMap::new();
5600        for (node, name, ball) in &trace {
5601            if let Some(coordinate) = GAIN_NAMES.iter().position(|candidate| candidate == name) {
5602                gains.entry(*node).or_insert([0.0; MAX_ORDER])[coordinate] = ball.value;
5603            }
5604            for (i, row) in P_NEXT_ENTRY_NAMES.iter().enumerate().take(order) {
5605                for (j, entry) in row.iter().enumerate().take(order) {
5606                    if entry == name {
5607                        predicted
5608                            .entry(*node)
5609                            .or_insert([[0.0; MAX_ORDER]; MAX_ORDER])[i][j] = ball.value;
5610                    }
5611                }
5612            }
5613        }
5614        let max_norm = |matrix: &Mat2| -> f64 {
5615            let mut norm = 0.0_f64;
5616            for row in matrix.iter().take(order) {
5617                for entry in row.iter().take(order) {
5618                    norm = norm.max(entry.abs());
5619                }
5620            }
5621            norm
5622        };
5623        // Largest eigenvalue of a matrix similar to a symmetric PSD one, by
5624        // power iteration. `None` when the iterate collapses, which is a
5625        // statement about this fixture and not about the matrix.
5626        let spectral_radius = |matrix: &Mat2| -> Option<f64> {
5627            let mut vector = [1.0_f64; MAX_ORDER];
5628            let mut radius = 0.0_f64;
5629            let mut iterations = 0usize;
5630            while iterations < 500 {
5631                let mut next = [0.0_f64; MAX_ORDER];
5632                for i in 0..order {
5633                    for k in 0..order {
5634                        next[i] += matrix[i][k] * vector[k];
5635                    }
5636                }
5637                let scale = next
5638                    .iter()
5639                    .take(order)
5640                    .fold(0.0_f64, |widest, entry| widest.max(entry.abs()));
5641                if !(scale > 0.0 && scale.is_finite()) {
5642                    return None;
5643                }
5644                for i in 0..order {
5645                    vector[i] = next[i] / scale;
5646                }
5647                radius = scale;
5648                iterations += 1;
5649            }
5650            Some(radius)
5651        };
5652        let mut signed: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5653        let mut absolute: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5654        for i in 0..order {
5655            signed[i][i] = 1.0;
5656            absolute[i][i] = 1.0;
5657        }
5658        let mut log_lyapunov = 0.0_f64;
5659        let mut worst_step = 0.0_f64;
5660        let mut steps = 0usize;
5661        for t in (order + 1)..(nodes.len() - 1) {
5662            let (Some(gain), Some(before), Some(after)) =
5663                (gains.get(&t), predicted.get(&(t - 1)), predicted.get(&t))
5664            else {
5665                continue;
5666            };
5667            let delta = nodes[t + 1].x - nodes[t].x;
5668            let ball_f = ball_transition(Ball::exact(delta), order);
5669            let mut transition: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5670            let mut update: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5671            for i in 0..order {
5672                update[i][i] = 1.0;
5673                for j in 0..order {
5674                    transition[i][j] = ball_f[i][j].value;
5675                }
5676            }
5677            for i in 0..order {
5678                update[i][0] -= gain[i];
5679            }
5680            // Update THEN predict, which is the order the filter runs in.
5681            let closed = mat_mul(&transition, &update, order);
5682            let mut next_signed: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5683            let mut next_absolute: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5684            for i in 0..order {
5685                for j in 0..order {
5686                    for k in 0..order {
5687                        next_signed[i][j] += closed[i][k] * signed[k][j];
5688                        next_absolute[i][j] += closed[i][k].abs() * absolute[k][j];
5689                    }
5690                }
5691            }
5692            signed = next_signed;
5693            absolute = next_absolute;
5694            // `‖S_t‖₂² = λ_max((P⁻_{t+1})⁻¹ Ψ P⁻_t Ψᵀ)`.
5695            let Ok(inverse_after) = mat_inv(after, order, "lyapunov weight") else {
5696                continue;
5697            };
5698            let congruence = mat_mul(
5699                &mat_mul(&closed, before, order),
5700                &mat_t(&closed, order),
5701                order,
5702            );
5703            let Some(squared) = spectral_radius(&mat_mul(&inverse_after, &congruence, order))
5704            else {
5705                continue;
5706            };
5707            let factor = squared.max(0.0).sqrt();
5708            worst_step = worst_step.max(factor);
5709            log_lyapunov += factor.ln();
5710            steps += 1;
5711            if steps % 20 == 0 {
5712                eprintln!(
5713                    "after {steps} steps: ||prod Psi|| = {:.6e}, ||prod |Psi||| = {:.6e}, \
5714                     prod ||S_t|| = {:.6e}",
5715                    max_norm(&signed),
5716                    max_norm(&absolute),
5717                    log_lyapunov.exp()
5718                );
5719            }
5720        }
5721        let contracted = max_norm(&signed);
5722        let inflated = max_norm(&absolute);
5723        let lyapunov = log_lyapunov.exp();
5724        eprintln!(
5725            "closed loop over {steps} steps: signed {contracted:.6e}, absolute {inflated:.6e}, \
5726             lyapunov {lyapunov:.6e}; per step signed {:.4}, absolute {:.4}, lyapunov {:.6}, \
5727             worst single step {worst_step:.6}",
5728            contracted.powf(1.0 / steps as f64),
5729            inflated.powf(1.0 / steps as f64),
5730            lyapunov.powf(1.0 / steps as f64)
5731        );
5732        assert!(
5733            contracted < 1.0,
5734            "the closed-loop product does not contract ({contracted:e} over {steps} steps); \
5735             the filter's own stability is the premise of every width argument here"
5736        );
5737        assert!(
5738            inflated > 1.0e10,
5739            "the absolute closed-loop product no longer explodes ({inflated:e} over {steps} \
5740             steps). If that is a repair, the recursion-level enclosures can be tightened \
5741             directly and this test is where the new factor is recorded"
5742        );
5743        assert!(
5744            worst_step <= 1.0 + 1.0e-9,
5745            "the Riccati identity `Psi P Psi^T + G = P_next` with `G >= 0` makes every \
5746             `||S_t||_2` at most one; the largest measured is {worst_step}, so either the \
5747             traced covariance is not the one the recursion produced or the identity is \
5748             being read wrong"
5749        );
5750        assert!(
5751            lyapunov >= contracted,
5752            "the Lyapunov product {lyapunov:e} must bound the signed product {contracted:e} \
5753             it stands in for"
5754        );
5755    }
5756
5757    /// The centred Riccati representation keeps the filtered-mean enclosure
5758    /// below the search resolution throughout the former order-3 failure band.
5759    ///
5760    /// Before #2614, `mean_a0` stayed O(1) while its enclosure width grew from
5761    /// `3.6e-7` at node 8 to `2.3e254` at node 120. That was pure dependency
5762    /// loss: the scalar filter remained stable. The repaired path must preserve
5763    /// both facts directly — bounded values and a finite enclosure narrower
5764    /// than the resolution the certified search asks it to support — and the
5765    /// criterion consuming that pass must certify rather than refuse.
5766    #[test]
5767    fn centred_riccati_mean_enclosure_stays_below_search_resolution() {
5768        let (x, y, w) = dgp_2300();
5769        let order = 3;
5770        let log_lambda = -16.6135_f64;
5771        let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5772        let q_value =
5773            gam_problem::checked_exp_log_strength(-log_lambda).expect("inverse log strength");
5774        let q = Ball::certified(
5775            q_value,
5776            gam_math::score_opt::certified_exp(-log_lambda).expect("certified exponential"),
5777        );
5778        let mut trace: Vec<BallTraceRecord> = Vec::new();
5779        run_filter_ball_traced(&nodes, q, order, Some(&mut trace))
5780            .expect("the repaired filter must certify the former failure point");
5781        let mean: Vec<(usize, Ball)> = trace
5782            .iter()
5783            .filter(|(_, name, _)| *name == "mean_a0")
5784            .map(|(node, _, ball)| (*node, *ball))
5785            .collect();
5786        assert_eq!(
5787            mean.len(),
5788            nodes.len() - order,
5789            "every proper filter node must expose a mean certificate"
5790        );
5791        let resolution = f64::EPSILON.sqrt();
5792        let widest_value = mean
5793            .iter()
5794            .fold(0.0_f64, |widest, (_, ball)| widest.max(ball.value.abs()));
5795        assert!(
5796            widest_value < 1.0e2,
5797            "the filtered mean's VALUE left O(1) at order {order}, rho {log_lambda}: \
5798             {widest_value:e}"
5799        );
5800        for (node, ball) in mean {
5801            assert!(
5802                ball.is_finite(),
5803                "mean enclosure is non-finite at node {node}"
5804            );
5805            let width = ball.hi - ball.lo;
5806            let scaled_resolution = resolution * (1.0 + ball.value.abs());
5807            assert!(
5808                width <= scaled_resolution,
5809                "mean enclosure at node {node} is {width:e} wide, exceeding the \
5810                 scale-aware search resolution {scaled_resolution:e}"
5811            );
5812        }
5813        certified_concentrated_criterion_jet(&nodes, within, n_obs, log_lambda, order)
5814            .expect("the criterion consuming the repaired pass must certify");
5815    }
5816
5817    /// Value-only diagnostic surface retained for the derivative oracle tests.
5818    fn concentrated_criterion(
5819        nodes: &[PooledNode],
5820        ssr_within: f64,
5821        n_obs: usize,
5822        log_lambda: f64,
5823        order: usize,
5824    ) -> Result<f64, String> {
5825        Ok(concentrated_criterion_jet(nodes, ssr_within, n_obs, log_lambda, order)?.0)
5826    }
5827    use super::*;
5828
5829    #[test]
5830    fn concentrated_score_jet_matches_test_only_differences() {
5831        let x = [0.0, 0.07, 0.19, 0.41, 0.41, 0.68, 1.0, 1.37];
5832        let y = [0.2, -0.4, 0.8, 0.1, 0.35, -0.2, 0.7, 0.15];
5833        let w = [1.0, 2.0, 0.7, 1.4, 0.9, 3.0, 1.2, 0.8];
5834        for order in 1..=MAX_ORDER {
5835            let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pooled data");
5836            for &rho in &[-4.0, -0.3, 2.5] {
5837                let (value, d1, d2, d3) =
5838                    concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5839                        .expect("analytic score jet");
5840                // Finite differences are deliberately confined to this oracle
5841                // test; production selection uses the analytic sensitivities.
5842                let h = 2.0e-4;
5843                let fm = concentrated_criterion(&nodes, within, n_obs, rho - h, order)
5844                    .expect("left score");
5845                let fp = concentrated_criterion(&nodes, within, n_obs, rho + h, order)
5846                    .expect("right score");
5847                let fm2 = concentrated_criterion(&nodes, within, n_obs, rho - 2.0 * h, order)
5848                    .expect("far left score");
5849                let fp2 = concentrated_criterion(&nodes, within, n_obs, rho + 2.0 * h, order)
5850                    .expect("far right score");
5851                let d1_fd = (fp - fm) / (2.0 * h);
5852                let d2_fd = (fp - 2.0 * value + fm) / (h * h);
5853                let d3_fd = (fp2 - 2.0 * fp + 2.0 * fm - fm2) / (2.0 * h * h * h);
5854                // Independent finite-difference certificate: endpoint VALUE
5855                // balls enclose the central quotient, and the global third-
5856                // derivative theorem bounds its O(h²) truncation remainder.
5857                let left_ball =
5858                    certified_concentrated_criterion_jet(&nodes, within, n_obs, rho - h, order)
5859                        .expect("left value ball");
5860                let right_ball =
5861                    certified_concentrated_criterion_jet(&nodes, within, n_obs, rho + h, order)
5862                        .expect("right value ball");
5863                let finite_difference = right_ball
5864                    .value
5865                    .sub(left_ball.value)
5866                    .div_positive(Ball::exact(2.0 * h));
5867                let proper_modes = (nodes.len() - order) as f64;
5868                let residual_dof = (n_obs - order) as f64;
5869                let third_bound = 0.5 * (0.25 * proper_modes + 6.0 * residual_dof);
5870                let truncation = third_bound * h * h / 6.0;
5871                let certified_center =
5872                    certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5873                        .expect("center derivative ball");
5874                assert!(
5875                    certified_center.derivative.hi >= finite_difference.lo - truncation
5876                        && certified_center.derivative.lo <= finite_difference.hi + truncation,
5877                    "order={order} rho={rho}: analytic derivative ball {:?} is disjoint \
5878                     from independently value-differenced {:?} ± {truncation:e}",
5879                    certified_center.derivative,
5880                    finite_difference
5881                );
5882                let d1_scale = 1.0 + d1.abs().max(d1_fd.abs());
5883                let d2_scale = 1.0 + d2.abs().max(d2_fd.abs());
5884                let d3_scale = 1.0 + d3.abs().max(d3_fd.abs());
5885                assert!(
5886                    (d1 - d1_fd).abs() <= 2.0e-6 * d1_scale,
5887                    "order={order} rho={rho}: analytic d1={d1}, FD={d1_fd}"
5888                );
5889                assert!(
5890                    (d2 - d2_fd).abs() <= 2.0e-4 * d2_scale,
5891                    "order={order} rho={rho}: analytic d2={d2}, FD={d2_fd}"
5892                );
5893                assert!(
5894                    (d3 - d3_fd).abs() <= 5.0e-3 * d3_scale,
5895                    "order={order} rho={rho}: analytic d3={d3}, FD={d3_fd}"
5896                );
5897            }
5898        }
5899    }
5900
5901    #[test]
5902    fn directed_score_balls_contain_independent_scalar_jets_across_scales() {
5903        let base_x = [0.0, 0.03, 0.11, 0.27, 0.52, 0.81, 1.17, 1.6];
5904        let y = [2.0e3, -4.0e2, 8.0e2, 1.0e2, 3.5e2, -2.0e2, 7.0e2, 1.5e2];
5905        let w = [1.0e-4, 2.0e4, 0.7, 1.4e3, 9.0e-3, 3.0e2, 1.2, 8.0e-2];
5906        for order in 1..=MAX_ORDER {
5907            for scale in [1.0e-1_f64, 1.0, 1.0e2] {
5908                let x: Vec<f64> = base_x.iter().map(|value| scale * value).collect();
5909                let (nodes, within, n_obs) =
5910                    pool_nodes(&x, &y, &w, order).expect("adversarial pooled data");
5911                let rho = (2 * order - 1) as f64 * scale.ln() + 0.35;
5912                let certified =
5913                    certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5914                        .expect("directed score recurrence");
5915                let scalar = concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5916                    .expect("independent scalar recurrence");
5917                for (name, ball, reference) in [
5918                    ("value", certified.value, scalar.0),
5919                    ("derivative", certified.derivative, scalar.1),
5920                    ("curvature", certified.curvature, scalar.2),
5921                    ("third", certified.third, scalar.3),
5922                ] {
5923                    assert!(
5924                        ball.interval().contains(reference),
5925                        "order={order} scale={scale:e}: scalar {name} {reference} escaped {ball:?}"
5926                    );
5927                }
5928
5929                let point_sample = ScoreSample {
5930                    x: rho,
5931                    value: certified.jet.value,
5932                    derivative: certified.jet.derivative,
5933                    curvature: certified.jet.curvature,
5934                    third: certified.jet.third,
5935                };
5936                let point_enclosure = concentrated_criterion_enclosure(
5937                    nodes.len(),
5938                    n_obs,
5939                    point_sample,
5940                    point_sample,
5941                    certified,
5942                    certified,
5943                    order,
5944                )
5945                .expect("degenerate point enclosure");
5946                assert_eq!(
5947                    point_enclosure.derivative,
5948                    certified.derivative.interval(),
5949                    "a zero-width cell must preserve the certified point derivative exactly"
5950                );
5951                assert_eq!(
5952                    point_enclosure.curvature,
5953                    certified.curvature.interval(),
5954                    "a zero-width cell must preserve the certified point curvature exactly"
5955                );
5956                assert_eq!(
5957                    point_enclosure.score.value,
5958                    certified.value.interval(),
5959                    "a zero-width cell must preserve the certified point score exactly"
5960                );
5961
5962                let rho_right = rho + 0.125;
5963                let right =
5964                    certified_concentrated_criterion_jet(&nodes, within, n_obs, rho_right, order)
5965                        .expect("right endpoint ball");
5966                let enclosure = concentrated_criterion_enclosure(
5967                    nodes.len(),
5968                    n_obs,
5969                    ScoreSample {
5970                        x: rho,
5971                        value: certified.jet.value,
5972                        derivative: certified.jet.derivative,
5973                        curvature: certified.jet.curvature,
5974                        third: certified.jet.third,
5975                    },
5976                    ScoreSample {
5977                        x: rho_right,
5978                        value: right.jet.value,
5979                        derivative: right.jet.derivative,
5980                        curvature: right.jet.curvature,
5981                        third: right.jet.third,
5982                    },
5983                    certified,
5984                    right,
5985                    order,
5986                )
5987                .expect("endpoint-anchored enclosure");
5988                for certificate in [certified, right] {
5989                    assert!(
5990                        enclosure.derivative.lo <= certificate.derivative.lo
5991                            && enclosure.derivative.hi >= certificate.derivative.hi,
5992                        "exact endpoint derivative escaped the cell enclosure"
5993                    );
5994                    assert!(
5995                        enclosure.curvature.lo <= certificate.curvature.lo
5996                            && enclosure.curvature.hi >= certificate.curvature.hi,
5997                        "exact endpoint curvature escaped the cell enclosure"
5998                    );
5999                    assert!(
6000                        enclosure.score.value.lo <= certificate.value.lo
6001                            && enclosure.score.value.hi >= certificate.value.hi,
6002                        "exact endpoint score escaped the cell enclosure"
6003                    );
6004                }
6005            }
6006        }
6007    }
6008
6009    /// Regression oracle for both #2614 saturated order-3 tail cells. The
6010    /// production enclosure is a theorem, not a sampling scheme; these dense
6011    /// scalar evaluations independently guard its implementation, while the
6012    /// comparison with the old full-width L4 theorem proves that
6013    /// nearest-endpoint endpoint-third interpolation actually removes (rather
6014    /// than merely moves) the false Taylor uncertainty.
6015    #[test]
6016    fn nearest_endpoint_taylor_hull_contains_dense_cell_and_tightens_every_channel() {
6017        let n = 60usize;
6018        let mut x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
6019        x[7] = x[6];
6020        let y: Vec<f64> = x
6021            .iter()
6022            .enumerate()
6023            .map(|(i, &xi)| {
6024                (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
6025            })
6026            .collect();
6027        let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * (i % 3) as f64).collect();
6028        let order = 3usize;
6029        let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pooled data");
6030        let lo = 13.759_277_343_75;
6031        let hi = 13.760_375_976_562_5;
6032        let left = certified_concentrated_criterion_jet(&nodes, within, n_obs, lo, order)
6033            .expect("left endpoint certificate");
6034        let right = certified_concentrated_criterion_jet(&nodes, within, n_obs, hi, order)
6035            .expect("right endpoint certificate");
6036        let sample = |rho: f64, certificate: CertifiedCriterionJet| ScoreSample {
6037            x: rho,
6038            value: certificate.jet.value,
6039            derivative: certificate.jet.derivative,
6040            curvature: certificate.jet.curvature,
6041            third: certificate.jet.third,
6042        };
6043        let nearest = concentrated_criterion_enclosure(
6044            nodes.len(),
6045            n_obs,
6046            sample(lo, left),
6047            sample(hi, right),
6048            left,
6049            right,
6050            order,
6051        )
6052        .expect("nearest-endpoint enclosure");
6053
6054        for step in 0..=256 {
6055            let rho = lo + (hi - lo) * step as f64 / 256.0;
6056            let (value, derivative, curvature, _) =
6057                concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6058                    .expect("independent scalar jet");
6059            assert!(
6060                nearest.score.value.contains(value),
6061                "dense score sample at rho={rho:.17} escaped {:?}",
6062                nearest.score.value
6063            );
6064            assert!(
6065                nearest.derivative.contains(derivative),
6066                "dense derivative sample at rho={rho:.17} escaped {:?}",
6067                nearest.derivative
6068            );
6069            assert!(
6070                nearest.curvature.contains(curvature),
6071                "dense curvature sample at rho={rho:.17} escaped {:?}",
6072                nearest.curvature
6073            );
6074        }
6075
6076        // Re-evaluate the identical certified Taylor theorem with each endpoint
6077        // spanning the FULL cell. This is the pre-fix geometry, expressed
6078        // directionally rather than weakened further into absolute-value
6079        // radii, so beating it is the stronger comparison.
6080        let width = Ball::exact(hi).sub(Ball::exact(lo));
6081        let width2 = width.square();
6082        let width3 = width2.mul(width);
6083        let width4 = width2.square();
6084        let fourth_abs_bound = Ball::exact((nodes.len() - order) as f64)
6085            .scale(0.25)
6086            .add(Ball::exact((n_obs - order) as f64).scale(26.0))
6087            .scale(0.5);
6088        let value_remainder = fourth_abs_bound
6089            .mul(width4)
6090            .div_positive(Ball::exact(24.0))
6091            .hi;
6092        let derivative_remainder = fourth_abs_bound
6093            .mul(width3)
6094            .div_positive(Ball::exact(6.0))
6095            .hi;
6096        let curvature_remainder = fourth_abs_bound.mul(width2).scale(0.5).hi;
6097        let full_cell_from_endpoint =
6098            |certificate: CertifiedCriterionJet, displacement: ClosedInterval| {
6099                let d = Ball::certified(0.0, displacement);
6100                let d2 = d.square();
6101                let d3 = d2.mul(d);
6102                let value = certificate
6103                    .value
6104                    .add(certificate.derivative.mul(d))
6105                    .add(certificate.curvature.mul(d2).scale(0.5))
6106                    .add(certificate.third.mul(d3).div_positive(Ball::exact(6.0)))
6107                    .interval()
6108                    .add(ClosedInterval::new(-value_remainder, value_remainder));
6109                let derivative = certificate
6110                    .derivative
6111                    .add(certificate.curvature.mul(d))
6112                    .add(certificate.third.mul(d2).scale(0.5))
6113                    .interval()
6114                    .add(ClosedInterval::new(
6115                        -derivative_remainder,
6116                        derivative_remainder,
6117                    ));
6118                let curvature = certificate
6119                    .curvature
6120                    .add(certificate.third.mul(d))
6121                    .interval()
6122                    .add(ClosedInterval::new(
6123                        -curvature_remainder,
6124                        curvature_remainder,
6125                    ));
6126                (value, derivative, curvature)
6127            };
6128        let old_left = full_cell_from_endpoint(left, ClosedInterval::new(0.0, width.hi));
6129        let old_right = full_cell_from_endpoint(right, ClosedInterval::new(-width.hi, 0.0));
6130        let old_value = ClosedInterval::new(
6131            old_left.0.lo.min(old_right.0.lo),
6132            old_left.0.hi.max(old_right.0.hi),
6133        );
6134        let old_derivative = ClosedInterval::new(
6135            old_left.1.lo.min(old_right.1.lo),
6136            old_left.1.hi.max(old_right.1.hi),
6137        );
6138        let old_curvature = ClosedInterval::new(
6139            old_left.2.lo.min(old_right.2.lo),
6140            old_left.2.hi.max(old_right.2.hi),
6141        );
6142        for (name, tightened, full_width) in [
6143            ("score", nearest.score.value, old_value),
6144            ("derivative", nearest.derivative, old_derivative),
6145            ("curvature", nearest.curvature, old_curvature),
6146        ] {
6147            assert!(
6148                tightened.hi - tightened.lo < full_width.hi - full_width.lo,
6149                "nearest-endpoint {name} enclosure {tightened:?} was not strictly \
6150                 narrower than full-width theorem {full_width:?}"
6151            );
6152        }
6153        assert!(
6154            nearest.derivative.hi < 0.0,
6155            "the corrected theorem must certify the live #2614 cell's negative slope: {:?}",
6156            nearest.derivative
6157        );
6158
6159        // The half-cell L4 theorem above exposed the next saturated cell at
6160        // rho≈16.127. It has the same dyadic width, but its endpoint slope is
6161        // only 2.76e-9, so the old global L4 remainder is eight times larger
6162        // than the signal even with correct nearest-endpoint geometry. The
6163        // endpoint-third/L5 theorem must contain the whole cell AND recover its
6164        // sign; otherwise it merely moves the same budget exhaustion again.
6165        let shifted_lo = 16.126_831_054_687_5;
6166        let shifted_hi = 16.127_929_687_5;
6167        assert_eq!(
6168            shifted_hi - shifted_lo,
6169            hi - lo,
6170            "the old-theorem comparison below shares the measured dyadic width"
6171        );
6172        let shifted_left =
6173            certified_concentrated_criterion_jet(&nodes, within, n_obs, shifted_lo, order)
6174                .expect("shifted left endpoint certificate");
6175        let shifted_right =
6176            certified_concentrated_criterion_jet(&nodes, within, n_obs, shifted_hi, order)
6177                .expect("shifted right endpoint certificate");
6178        let shifted = concentrated_criterion_enclosure(
6179            nodes.len(),
6180            n_obs,
6181            sample(shifted_lo, shifted_left),
6182            sample(shifted_hi, shifted_right),
6183            shifted_left,
6184            shifted_right,
6185            order,
6186        )
6187        .expect("shifted endpoint-third enclosure");
6188        for step in 0..=256 {
6189            let rho = shifted_lo + (shifted_hi - shifted_lo) * step as f64 / 256.0;
6190            let (value, derivative, curvature, _) =
6191                concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6192                    .expect("shifted independent scalar jet");
6193            assert!(
6194                shifted.score.value.contains(value),
6195                "shifted dense score at rho={rho:.17} escaped {:?}",
6196                shifted.score.value
6197            );
6198            assert!(
6199                shifted.derivative.contains(derivative),
6200                "shifted dense derivative at rho={rho:.17} escaped {:?}",
6201                shifted.derivative
6202            );
6203            assert!(
6204                shifted.curvature.contains(curvature),
6205                "shifted dense curvature at rho={rho:.17} escaped {:?}",
6206                shifted.curvature
6207            );
6208        }
6209        let shifted_old_left =
6210            full_cell_from_endpoint(shifted_left, ClosedInterval::new(0.0, width.hi));
6211        let shifted_old_right =
6212            full_cell_from_endpoint(shifted_right, ClosedInterval::new(-width.hi, 0.0));
6213        for (name, tightened, old_left, old_right) in [
6214            (
6215                "score",
6216                shifted.score.value,
6217                shifted_old_left.0,
6218                shifted_old_right.0,
6219            ),
6220            (
6221                "derivative",
6222                shifted.derivative,
6223                shifted_old_left.1,
6224                shifted_old_right.1,
6225            ),
6226            (
6227                "curvature",
6228                shifted.curvature,
6229                shifted_old_left.2,
6230                shifted_old_right.2,
6231            ),
6232        ] {
6233            let full_width =
6234                ClosedInterval::new(old_left.lo.min(old_right.lo), old_left.hi.max(old_right.hi));
6235            assert!(
6236                tightened.hi - tightened.lo < full_width.hi - full_width.lo,
6237                "endpoint-third {name} enclosure {tightened:?} was not strictly \
6238                 narrower than the full-width L4 theorem {full_width:?}"
6239            );
6240        }
6241        assert!(
6242            shifted.derivative.hi < 0.0,
6243            "the endpoint-third theorem must certify the shifted #2614 cell's \
6244             negative slope: {:?}",
6245            shifted.derivative
6246        );
6247    }
6248
6249    #[test]
6250    fn spline_consumer_preserves_a_valid_resolution_flat_optimum_category() {
6251        let optimum = ScoreSample {
6252            x: -0.25,
6253            value: 3.0,
6254            derivative: 0.0,
6255            curvature: 0.0,
6256            third: 0.0,
6257        };
6258        let bracket = ClosedInterval::new(-0.5, 0.0);
6259        let max_score_gap = 0.125;
6260        let score_resolution = 0.25;
6261        let search = ScoreSearchResult {
6262            optimum,
6263            location: ScoreOptimumLocation::ResolutionFlat(0),
6264            lower_boundary: ScoreSample { x: -1.0, ..optimum },
6265            upper_boundary: ScoreSample { x: 1.0, ..optimum },
6266            stationary_points: Vec::new(),
6267            resolution_flat_regions: vec![gam_math::score_opt::ResolutionFlatRegion {
6268                sample: optimum,
6269                bracket,
6270                score: ClosedInterval::new(2.875, 3.0),
6271                max_score_gap,
6272                score_resolution,
6273            }],
6274            dominated_regions: Vec::new(),
6275            value_certificate: gam_math::score_opt::GlobalScoreCertificate {
6276                selected: ClosedInterval::point(3.0),
6277                maximum: ClosedInterval::new(3.0, 3.125),
6278                maximum_excess: max_score_gap,
6279                comparison_resolution: score_resolution,
6280            },
6281        };
6282        assert_eq!(
6283            spline_optimum_proof(&search).expect("valid resolution-flat proof"),
6284            SplineOptimumProof::ResolutionFlat {
6285                bracket,
6286                max_score_gap,
6287                score_resolution,
6288            },
6289            "the spline consumer must preserve the producer's successful typed category"
6290        );
6291
6292        let mut invalid = search;
6293        invalid.resolution_flat_regions[0].max_score_gap =
6294            invalid.resolution_flat_regions[0].score_resolution + f64::EPSILON;
6295        assert!(
6296            matches!(
6297                spline_optimum_proof(&invalid),
6298                Err(SplineScoreProofError::Search(_))
6299            ),
6300            "a malformed producer certificate must still fail instead of being accepted"
6301        );
6302    }
6303
6304    #[test]
6305    fn spline_consumer_retains_the_producers_stationary_curvature_proof() {
6306        let optimum = ScoreSample {
6307            x: -9.084_292_923_99,
6308            value: 3.0,
6309            derivative: 0.0,
6310            curvature: -1.0,
6311            third: 0.0,
6312        };
6313        let bracket = ClosedInterval::new(-9.084_292_924_175_005, -9.084_292_923_812_374);
6314        let producer_curvature = ClosedInterval::new(-6.4, -0.2);
6315        let point_score = ScoreValueEnclosure {
6316            value: ClosedInterval::new(2.999, 3.001),
6317            evaluation_error: 0.001,
6318        };
6319        let search = ScoreSearchResult {
6320            optimum,
6321            location: ScoreOptimumLocation::Stationary(0),
6322            lower_boundary: ScoreSample {
6323                x: -10.0,
6324                ..optimum
6325            },
6326            upper_boundary: ScoreSample { x: -8.0, ..optimum },
6327            stationary_points: vec![gam_math::score_opt::StationaryPoint {
6328                sample: optimum,
6329                bracket,
6330                score: point_score,
6331                curvature: producer_curvature,
6332            }],
6333            resolution_flat_regions: Vec::new(),
6334            dominated_regions: Vec::new(),
6335            value_certificate: gam_math::score_opt::GlobalScoreCertificate {
6336                selected: point_score.value,
6337                maximum: point_score.value,
6338                maximum_excess: 0.0,
6339                comparison_resolution: 0.002,
6340            },
6341        };
6342        let SplineOptimumProof::Kkt { bracket: got, kind } =
6343            spline_optimum_proof(&search).expect("valid stationary proof")
6344        else {
6345            panic!("stationary producer category was not preserved");
6346        };
6347        assert_eq!(got, bracket);
6348        assert_eq!(
6349            kind,
6350            SplineKktKind::Stationary {
6351                curvature: producer_curvature,
6352            }
6353        );
6354
6355        let local_enclosure = DerivativeEnclosure {
6356            score: point_score,
6357            derivative: ClosedInterval::new(-1.2e-9, 1.2e-9),
6358            // Mirrors the persistence failure: a fresh tiny-cell secant loses
6359            // curvature sign even though the parent proof remains strict.
6360            curvature: ClosedInterval::new(-6.39, 0.0064),
6361        };
6362        let (holds, consumed_curvature) = spline_kkt_holds(kind, local_enclosure);
6363        assert!(holds, "the final derivative still contains the unique root");
6364        assert_eq!(consumed_curvature, producer_curvature);
6365    }
6366
6367    #[test]
6368    fn derivative_secant_recovers_weighted_order3_root_curvature_sign() {
6369        let (x, y, w) = dgp_2300();
6370        let order = 3usize;
6371        let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("weighted pool");
6372        // Live cell at evaluation 1024 of the pre-secant #2300 traversal.
6373        let lo = -2.337_075_252_506_015;
6374        let hi = -2.337_040_920_230_624;
6375        let left = certified_concentrated_criterion_jet(&nodes, within, n_obs, lo, order)
6376            .expect("weighted left endpoint");
6377        let right = certified_concentrated_criterion_jet(&nodes, within, n_obs, hi, order)
6378            .expect("weighted right endpoint");
6379        assert!(
6380            left.curvature.interval().contains_zero() && right.curvature.interval().contains_zero(),
6381            "the oracle must exercise the loose direct covariance-d2 path"
6382        );
6383        let sample = |rho: f64, certificate: CertifiedCriterionJet| ScoreSample {
6384            x: rho,
6385            value: certificate.jet.value,
6386            derivative: certificate.jet.derivative,
6387            curvature: certificate.jet.curvature,
6388            third: certificate.jet.third,
6389        };
6390        let enclosure = concentrated_criterion_enclosure(
6391            nodes.len(),
6392            n_obs,
6393            sample(lo, left),
6394            sample(hi, right),
6395            left,
6396            right,
6397            order,
6398        )
6399        .expect("secant curvature enclosure");
6400        assert!(
6401            enclosure.curvature.hi < 0.0,
6402            "the derivative secant must recover strict concavity: {:?}",
6403            enclosure.curvature
6404        );
6405        assert!(
6406            enclosure.derivative.lo > 0.0,
6407            "integrating the secant curvature from both endpoints must preserve \
6408             the live cell's positive slope: {:?}",
6409            enclosure.derivative
6410        );
6411        for step in 0..=256 {
6412            let rho = lo + (hi - lo) * step as f64 / 256.0;
6413            let (_, derivative, curvature, _) =
6414                concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6415                    .expect("independent weighted scalar jet");
6416            assert!(
6417                enclosure.derivative.contains(derivative),
6418                "weighted scalar derivative {derivative} at rho={rho:.17} escaped {:?}",
6419                enclosure.derivative
6420            );
6421            assert!(
6422                enclosure.curvature.contains(curvature),
6423                "weighted scalar curvature {curvature} at rho={rho:.17} escaped {:?}",
6424                enclosure.curvature
6425            );
6426        }
6427    }
6428
6429    #[test]
6430    fn score_proof_refuses_exactly_when_diffuse_innovation_ball_contains_zero() {
6431        assert_eq!(
6432            Ball::ZERO.square(),
6433            Ball::ZERO,
6434            "structural zero must survive squaring exactly"
6435        );
6436        assert_eq!(
6437            Ball::ONE.square(),
6438            Ball::ONE,
6439            "the exact unit covariance must not acquire artificial width"
6440        );
6441        let tiny = f64::from_bits(1);
6442        let nodes = [
6443            PooledNode {
6444                x: 0.0,
6445                y: 0.0,
6446                w: 1.0,
6447            },
6448            PooledNode {
6449                x: tiny,
6450                y: 1.0,
6451                w: 1.0,
6452            },
6453            PooledNode {
6454                x: 1.0,
6455                y: -1.0,
6456                w: 1.0,
6457            },
6458        ];
6459        let error = run_filter_ball(&nodes, Ball::ONE, 2)
6460            .expect_err("an underflow-wide diffuse innovation cannot be divided soundly");
6461        assert!(matches!(
6462            error,
6463            SplineScoreProofError::InnovationContainsZero {
6464                node: 1,
6465                kind: SplineInnovationKind::Diffuse,
6466                ..
6467            }
6468        ));
6469    }
6470
6471    /// #1034 persistence seam: snapshot → JSON → restore must replay the
6472    /// Gaussian bridge bit-for-bit — knot posteriors, off-knot bridge,
6473    /// boundary extrapolation, EDF, and derivative posteriors all compare
6474    /// with exact equality, because every replayed field is either stored
6475    /// verbatim or derived by the fitter's own expressions. Parameterized over
6476    /// the smoothing order so the order-derived state/cov/gain layouts
6477    /// (#1044: m=3 stores 3-wide state, 6-wide upper-tri cov, 9-wide gain) are
6478    /// each round-tripped.
6479    fn round_trip_predict_bit_for_bit(order: usize) {
6480        let n = 60usize;
6481        let x: Vec<f64> = (0..n).map(|i| (i as f64) / (n as f64 - 1.0)).collect();
6482        // Deterministic wiggly response with a tie pair to exercise pooling.
6483        let mut x = x;
6484        x[7] = x[6];
6485        let y: Vec<f64> = x
6486            .iter()
6487            .enumerate()
6488            .map(|(i, &xi)| {
6489                (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
6490            })
6491            .collect();
6492        let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * ((i % 3) as f64)).collect();
6493        let fit = fit_spline_scan(&x, &y, &w, order).expect("scan fit");
6494        assert_eq!(fit.order, order);
6495        // The original row count is retained verbatim: one tie pair collapses a
6496        // knot, but never changes the fit's sample-size authority.
6497        assert_eq!(fit.training_sample_size(), n);
6498
6499        let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
6500        let state: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
6501        let restored = SplineScanFit::from_state(&state).expect("restore fit");
6502
6503        assert_eq!(fit.training_sample_size(), restored.training_sample_size());
6504        if order == 2 {
6505            let mut pre_change = serde_json::to_value(fit.to_state()).expect("serialize state");
6506            pre_change
6507                .as_object_mut()
6508                .expect("spline state serializes as an object")
6509                .remove("training_sample_size");
6510            assert!(
6511                serde_json::from_value::<SplineScanState>(pre_change).is_err(),
6512                "pre-training-size spline state must not deserialize"
6513            );
6514            let mut zero = serde_json::to_value(fit.to_state()).expect("serialize state");
6515            zero.as_object_mut()
6516                .expect("spline state serializes as an object")
6517                .insert("training_sample_size".to_string(), serde_json::json!(0));
6518            assert!(
6519                serde_json::from_value::<SplineScanState>(zero).is_err(),
6520                "zero training rows must not deserialize"
6521            );
6522        }
6523        assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
6524        assert_eq!(fit.knots, restored.knots);
6525        assert_eq!(fit.mean, restored.mean);
6526        assert_eq!(fit.var, restored.var);
6527        assert_eq!(fit.deriv, restored.deriv);
6528        assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
6529        assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
6530        assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
6531        for t in 0..fit.knots.len() {
6532            match (fit.deriv_at_knot(t), restored.deriv_at_knot(t)) {
6533                (Some((d0, v0)), Some((d1, v1))) => {
6534                    assert!(order >= 2);
6535                    assert_eq!(d0.to_bits(), d1.to_bits());
6536                    assert_eq!(v0.to_bits(), v1.to_bits());
6537                }
6538                (None, None) => assert_eq!(order, 1),
6539                _ => panic!("derivative availability drifted across the persistence seam"),
6540            }
6541        }
6542        // Off-knot bridge, exact knot hit, and both extrapolation sides.
6543        for &xq in &[-0.2, 0.0, 0.013, 0.5, x[6], 0.987, 1.0, 1.3] {
6544            let (m0, v0) = fit.predict(xq).expect("predict original");
6545            let (m1, v1) = restored.predict(xq).expect("predict restored");
6546            assert_eq!(
6547                m0.to_bits(),
6548                m1.to_bits(),
6549                "mean drift at x={xq} (m={order})"
6550            );
6551            assert_eq!(
6552                v0.to_bits(),
6553                v1.to_bits(),
6554                "variance drift at x={xq} (m={order})"
6555            );
6556        }
6557
6558        // Corrupt payloads fail loudly, not inside a later predict.
6559        let mut bad = fit.to_state();
6560        bad.cov.truncate(bad.cov.len() - 1);
6561        SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
6562        let mut bad = fit.to_state();
6563        bad.sigma2 = -1.0;
6564        SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
6565        let mut bad = fit.to_state();
6566        bad.knots[2] = bad.knots[1];
6567        SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
6568    }
6569
6570    #[test]
6571    fn state_snapshot_round_trips_predict_and_training_sample_size_bit_for_bit() {
6572        round_trip_predict_bit_for_bit(2);
6573    }
6574
6575    /// #1044: the order-1 and order-3 layouts round-trip bit-for-bit too.
6576    #[test]
6577    fn state_snapshot_round_trips_predict_bit_for_bit_order1() {
6578        round_trip_predict_bit_for_bit(1);
6579    }
6580
6581    #[test]
6582    fn state_snapshot_round_trips_predict_bit_for_bit_order3() {
6583        round_trip_predict_bit_for_bit(3);
6584    }
6585
6586    /// A hand-built persisted state, valid by `from_state`'s own structural
6587    /// rules: `state` is `order` per knot, `cov` the `order(order+1)/2` upper
6588    /// triangle per knot, `gain` the full `order²` per knot, one weight per
6589    /// knot, strictly increasing knots, `sigma2 > 0`, and an in-range
6590    /// `log_lambda`. No fitting is involved.
6591    fn hand_built_state(order: usize) -> SplineScanState {
6592        let knots = vec![0.0, 0.25, 0.6, 1.0, 1.4];
6593        let knot_count = knots.len();
6594        let tri = order * (order + 1) / 2;
6595        SplineScanState {
6596            order,
6597            state: (0..order * knot_count)
6598                .map(|i| 0.1 + 0.07 * i as f64)
6599                .collect(),
6600            // Diagonal-leading per knot so restored variances stay positive.
6601            cov: (0..tri * knot_count)
6602                .map(|i| {
6603                    if i % tri == 0 {
6604                        0.5 + 0.01 * i as f64
6605                    } else {
6606                        0.02
6607                    }
6608                })
6609                .collect(),
6610            gain: (0..order * order * knot_count)
6611                .map(|i| 0.03 * ((i % 5) as f64))
6612                .collect(),
6613            node_weight: (0..knot_count).map(|i| 1.0 + 0.25 * i as f64).collect(),
6614            knots,
6615            log_lambda: 0.35,
6616            sigma2: 1.75,
6617            restricted_loglik: -12.5,
6618            training_sample_size: std::num::NonZeroU64::new(64).expect("64 is nonzero"),
6619            data_sse: 3.25,
6620        }
6621    }
6622
6623    /// #2614 decoupling: the #1034/#1044 persistence seam, verified WITHOUT the
6624    /// optimizer.
6625    ///
6626    /// `round_trip_predict_bit_for_bit` opens with
6627    /// `fit_spline_scan(...).expect("scan fit")`, so while the certified scan
6628    /// refuses (#2614, measured: two of those three tests die there) every
6629    /// assertion behind it is WITHDRAWN rather than failing — the bit-for-bit
6630    /// posteriors, the off-knot bridge, both extrapolation sides, and the three
6631    /// corrupt-payload rejections are all unprotected, and the red count reads
6632    /// "some tests fail" when the truth is "a guarantee is untested".
6633    ///
6634    /// A serialization guarantee must not depend on an optimizer guarantee.
6635    /// `SplineScanFit::from_state` reconstructs a fit from a plain
6636    /// `SplineScanState`, so the whole seam can be driven from a hand-built
6637    /// state and holds regardless of whether any fit converges. This does NOT
6638    /// replace the fitted round-trip, which additionally proves the fitter's own
6639    /// fields survive; it makes the seam itself independently covered.
6640    #[test]
6641    fn persistence_seam_round_trips_without_the_optimizer_2614() {
6642        for order in 1..=MAX_ORDER {
6643            let built = hand_built_state(order);
6644            let fit = SplineScanFit::from_state(&built).expect("hand-built state must restore");
6645            let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
6646            let parsed: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
6647            let restored = SplineScanFit::from_state(&parsed).expect("restore fit");
6648
6649            assert_eq!(fit.order, restored.order, "order drifted (m={order})");
6650            assert_eq!(fit.knots, restored.knots, "knots drifted (m={order})");
6651            assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
6652            assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
6653            assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
6654            assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
6655            assert_eq!(fit.training_sample_size(), restored.training_sample_size());
6656
6657            // Off-knot bridge, exact knot hits, and both extrapolation sides.
6658            for &xq in &[-0.3, 0.0, 0.13, 0.6, 1.0, 1.4, 1.9] {
6659                let (m0, v0) = fit.predict(xq).expect("predict original");
6660                let (m1, v1) = restored.predict(xq).expect("predict restored");
6661                assert_eq!(
6662                    m0.to_bits(),
6663                    m1.to_bits(),
6664                    "mean drift at x={xq} (m={order})"
6665                );
6666                assert_eq!(
6667                    v0.to_bits(),
6668                    v1.to_bits(),
6669                    "variance drift at x={xq} (m={order})"
6670                );
6671            }
6672
6673            // Corrupt payloads fail loudly, not inside a later predict.
6674            let mut bad = fit.to_state();
6675            bad.cov.truncate(bad.cov.len() - 1);
6676            SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
6677            let mut bad = fit.to_state();
6678            bad.sigma2 = -1.0;
6679            SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
6680            let mut bad = fit.to_state();
6681            bad.knots[2] = bad.knots[1];
6682            SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
6683        }
6684    }
6685
6686    /// Dense order-1 (random-walk / linear smoothing spline) posterior of the
6687    /// SAME intrinsic prior the order-1 scan integrates: improper level on
6688    /// `f_0`, increments `f_{t+1}−f_t ~ N(0, q·δ_t)`, observations `y_t` with
6689    /// precision `w_t` (unit σ²). Solve the tridiagonal precision densely and
6690    /// compare to the scan — the exact-equivalence gate for the new m=1 path.
6691    fn dense_rw_truth(x: &[f64], y: &[f64], w: &[f64], log_lambda: f64) -> (Vec<f64>, Vec<f64>) {
6692        let n = x.len();
6693        let q = (-log_lambda).exp();
6694        let mut prec = vec![vec![0.0_f64; n]; n];
6695        let mut rhs = vec![0.0_f64; n];
6696        for t in 0..n {
6697            prec[t][t] += w[t];
6698            rhs[t] += w[t] * y[t];
6699        }
6700        for t in 0..n - 1 {
6701            let p = 1.0 / (q * (x[t + 1] - x[t]));
6702            prec[t][t] += p;
6703            prec[t + 1][t + 1] += p;
6704            prec[t][t + 1] -= p;
6705            prec[t + 1][t] -= p;
6706        }
6707        // Dense inverse via Gauss-Jordan (small n in the test).
6708        let mut aug = prec.clone();
6709        let mut inv = vec![vec![0.0_f64; n]; n];
6710        for i in 0..n {
6711            inv[i][i] = 1.0;
6712        }
6713        for col in 0..n {
6714            let piv = (col..n)
6715                .max_by(|&a, &b| aug[a][col].abs().total_cmp(&aug[b][col].abs()))
6716                .unwrap();
6717            aug.swap(col, piv);
6718            inv.swap(col, piv);
6719            let d = aug[col][col];
6720            for k in 0..n {
6721                aug[col][k] /= d;
6722                inv[col][k] /= d;
6723            }
6724            for r in 0..n {
6725                if r == col {
6726                    continue;
6727                }
6728                let f = aug[r][col];
6729                if f == 0.0 {
6730                    continue;
6731                }
6732                for k in 0..n {
6733                    aug[r][k] -= f * aug[col][k];
6734                    inv[r][k] -= f * inv[col][k];
6735                }
6736            }
6737        }
6738        let mean: Vec<f64> = (0..n)
6739            .map(|i| (0..n).map(|j| inv[i][j] * rhs[j]).sum())
6740            .collect();
6741        let var: Vec<f64> = (0..n).map(|i| inv[i][i]).collect();
6742        (mean, var)
6743    }
6744
6745    /// The order-1 scan must reproduce the dense random-walk posterior exactly
6746    /// (mean, pointwise variance, and the EDF identity tr(S)=Σ w_t·Var_t/σ²) at
6747    /// the scan's own selected λ — the #1034-item-2 correctness gate.
6748    #[test]
6749    fn order_one_scan_matches_dense_random_walk_posterior() {
6750        let n = 30usize;
6751        let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
6752        let y: Vec<f64> = x
6753            .iter()
6754            .enumerate()
6755            .map(|(i, &xi)| 2.0 * xi + 0.4 * (5.0 * xi).sin() + 0.05 * ((i * 13 % 7) as f64 - 3.0))
6756            .collect();
6757        let w = vec![1.0_f64; n];
6758        let fit = fit_spline_scan(&x, &y, &w, 1).expect("order-1 scan fit");
6759        assert_eq!(fit.order, 1);
6760
6761        let (mean, var) = dense_rw_truth(&x, &y, &w, fit.log_lambda);
6762        for t in 0..n {
6763            assert!(
6764                (fit.mean[t] - mean[t]).abs() <= 1e-7 * mean[t].abs().max(1e-3),
6765                "order-1 mean mismatch at {t}: scan={} dense={}",
6766                fit.mean[t],
6767                mean[t]
6768            );
6769            let se_scan = fit.var[t].sqrt();
6770            let se_dense = (var[t] * fit.sigma2).sqrt();
6771            assert!(
6772                (se_scan - se_dense).abs() <= 1e-7 * se_dense.max(1e-12),
6773                "order-1 SE mismatch at {t}: scan={se_scan} dense={se_dense}"
6774            );
6775        }
6776        // EDF identity against the dense posterior variance diagonal.
6777        let dense_edf: f64 = w.iter().zip(var.iter()).map(|(wt, vt)| wt * vt).sum();
6778        assert!(
6779            (fit.edf() - dense_edf).abs() <= 1e-7 * dense_edf.max(1e-12),
6780            "order-1 EDF mismatch: scan={} dense={dense_edf}",
6781            fit.edf()
6782        );
6783        // Order-1 derivative state is structurally absent: Brownian motion has
6784        // no pointwise derivative, so the fit must say so rather than report a
6785        // fabricated known-zero.
6786        assert!(fit.deriv.is_none());
6787        assert!(fit.deriv_at_knot(0).is_none());
6788    }
6789
6790    /// `deviance()` must be the weighted DATA residual sum of squares at the
6791    /// fitted values, not the profiled REML quadratic. For order 1 on
6792    /// `x = (0, 1)`, `y = (0, 1)`, unit weights, λ = 1, the posterior mean is
6793    /// `(1/3, 2/3)`: the data SSE is `2·(1/3)² = 2/9`, while
6794    /// `σ̂²·(n − order) = 1/3` carries an extra `1/9` of process/roughness
6795    /// energy.
6796    #[test]
6797    fn deviance_is_data_sse_not_penalized_quadratic() {
6798        let x = [0.0, 1.0];
6799        let y = [0.0, 1.0];
6800        let w = [1.0, 1.0];
6801        let fit = fit_spline_scan_at(&x, &y, &w, 0.0, None, 1).expect("order-1 fit");
6802        // Self-consistency against a direct recomputation at the fitted values.
6803        let manual: f64 = x
6804            .iter()
6805            .zip(&y)
6806            .zip(&w)
6807            .map(|((&xi, &yi), &wi)| {
6808                let (m, _) = fit.predict(xi).expect("predict at knot");
6809                wi * (yi - m) * (yi - m)
6810            })
6811            .sum();
6812        assert!(
6813            (fit.deviance() - manual).abs() <= 1e-12 * manual.max(1e-300),
6814            "deviance {} != recomputed data SSE {manual}",
6815            fit.deviance()
6816        );
6817        assert!(
6818            (fit.deviance() - 2.0 / 9.0).abs() < 1e-10,
6819            "deviance {} != 2/9",
6820            fit.deviance()
6821        );
6822        // The old proxy is strictly larger: it includes penalty energy.
6823        let reml_quadratic = fit.sigma2 * (fit.training_sample_size() as f64 - fit.order as f64);
6824        assert!(fit.deviance() < reml_quadratic);
6825    }
6826}