1use 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#[derive(Clone, Copy, Debug)]
66struct PooledNode {
67 x: f64,
68 y: f64,
70 w: f64,
72}
73
74const LOG_LAMBDA_LO: f64 = -18.0;
76const LOG_LAMBDA_HI: f64 = 18.0;
77const MAX_ORDER: usize = 3;
85
86type Mat2 = [[f64; MAX_ORDER]; MAX_ORDER];
92type Vec2 = [f64; MAX_ORDER];
93
94#[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 #[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 #[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 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#[derive(Clone, Debug, PartialEq)]
289pub enum SplineScoreProofError {
290 InnovationContainsZero {
293 node: usize,
294 kind: SplineInnovationKind,
295 enclosure: ClosedInterval,
296 },
297 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 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 contribution_lo: f64,
334 contribution_hi: f64,
335 f_star_d3_lo: f64,
341 f_star_d3_hi: f64,
342 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
576fn 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 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 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
625fn dense_spd_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
641 let d = a.len();
642 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 let mut inv_s = gauss_jordan_inverse(&a_s, what)?;
658 let mut resid = vec![vec![0.0_f64; d]; d]; 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]; 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 Ok((0..d)
687 .map(|i| (0..d).map(|j| s[i] * inv_s[i][j] * s[j]).collect())
688 .collect())
689}
690
691fn 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#[inline]
738fn factorial(k: usize) -> f64 {
739 (1..=k).map(|v| v as f64).product::<f64>().max(1.0)
740}
741
742#[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#[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#[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
878struct ProcessNoiseTaylor {
897 enclosure: BallMat,
899 constant: [Ball; COVARIANCE_D1_DIM],
902 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 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#[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#[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#[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#[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#[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
1077fn 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
1106const GLOBALLY_BOUNDED_FROM: usize = 4;
1114
1115const PREARRAY_COLUMNS: usize = 2 * MAX_ORDER;
1117
1118fn 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
1154fn 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
1169fn 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#[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
1245fn 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#[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#[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#[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
1438fn 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 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 let product_nonnegative = row_nonnegative == column_nonnegative;
1488 let corner = |minimizing: bool| -> Option<Ball> {
1489 let a = Ball::exact(if minimizing { entry.lo } else { entry.hi });
1491 let b = Ball::exact(if minimizing == column_nonnegative {
1493 row.hi
1494 } else {
1495 row.lo
1496 });
1497 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
1527fn 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#[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
1613struct FilterStep {
1615 a_filt: Vec2,
1617 p_filt: Mat2,
1618 a_pred: Vec2,
1620 p_pred: Mat2,
1621}
1622
1623struct FilterPass {
1625 steps: Vec<FilterStep>,
1626 sum_log_f: f64,
1628 sum_log_f_d1: f64,
1633 sum_log_f_d2: f64,
1634 sum_log_f_d3: f64,
1635 sum_v2_over_f: f64,
1637 sum_v2_over_f_d1: f64,
1639 sum_v2_over_f_d2: f64,
1640 sum_v2_over_f_d3: f64,
1641 n_proper: usize,
1643}
1644
1645#[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
1659fn 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 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 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 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 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 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 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 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 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
1985fn 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
2001type BallTraceRecord = (usize, &'static str, Ball);
2009
2010const MEAN_BLOCKS: usize = 2;
2012const MEAN_DIM: usize = MEAN_BLOCKS * MAX_ORDER;
2014const COVARIANCE_D1_DIM: usize = MAX_ORDER * MAX_ORDER;
2016const ZONOTOPE_GENERATOR_CAP: usize = 240;
2026const ZONOTOPE_ROUNDOFF: f64 = 32.0 * f64::EPSILON;
2030
2031#[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#[derive(Clone, Debug)]
2099struct Zonotope<const N: usize> {
2100 center: [f64; N],
2101 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 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 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 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 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 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 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
2297fn 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
2306fn 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
2322fn 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
2346fn 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
2372fn 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
2383fn 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(¢re_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
2470const 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"];
2475const GAIN_NAMES: [&str; MAX_ORDER] = ["gain_0", "gain_1", "gain_2"];
2477const 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 let mut mean = Zonotope::<MEAN_DIM>::zeroed(MEAN_BLOCKS * order);
2496 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 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 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 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 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 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 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 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 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 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 for i in 0..order {
2774 for j in 0..order {
2775 if i == j {
2776 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 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 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 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 let d1_pred = p_star_d1;
2886 let d2_pred = p_star_d2;
2887 let d3_pred = p_star_d3;
2888 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 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 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 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 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 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 ("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 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 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 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 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 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#[derive(Clone, Debug)]
3381pub struct SplineScanFit {
3382 pub order: usize,
3386 pub knots: Vec<f64>,
3388 pub mean: Vec<f64>,
3390 pub deriv: Option<Vec<f64>>,
3395 pub var: Vec<f64>,
3397 log_lambda: f64,
3399 pub sigma2: f64,
3401 pub restricted_loglik: f64,
3405 pub log_likelihood: f64,
3409 training_sample_size: std::num::NonZeroUsize,
3412 pub data_sse: f64,
3418 smoothed_state: Vec<Vec2>,
3420 smoothed_cov: Vec<Mat2>,
3422 rts_gain: Vec<Mat2>,
3424 q: f64,
3426 node_weight: Vec<f64>,
3428}
3429
3430fn pool_nodes(
3440 x: &[f64],
3441 y: &[f64],
3442 w: &[f64],
3443 order: usize,
3444) -> Result<(Vec<PooledNode>, f64, usize, f64), String> {
3445 let n = x.len();
3446 if y.len() != n || w.len() != n {
3447 return Err(format!(
3448 "spline scan: length mismatch x={n}, y={}, w={}",
3449 y.len(),
3450 w.len()
3451 ));
3452 }
3453 for i in 0..n {
3454 if !(x[i].is_finite() && y[i].is_finite() && w[i].is_finite() && w[i] > 0.0) {
3455 return Err(format!(
3456 "spline scan: non-finite or non-positive input at row {i} (x={}, y={}, w={})",
3457 x[i], y[i], w[i]
3458 ));
3459 }
3460 }
3461 let mut perm: Vec<usize> = (0..n).collect();
3462 perm.sort_by(|&i, &j| x[i].total_cmp(&x[j]));
3463 let response_origin = perm
3464 .first()
3465 .map(|&index| y[index])
3466 .ok_or_else(|| "spline scan: cannot pool an empty response".to_string())?;
3467 let centered_y = y
3468 .iter()
3469 .enumerate()
3470 .map(|(index, &value)| {
3471 let centered = value - response_origin;
3472 centered.is_finite().then_some(centered).ok_or_else(|| {
3473 format!("spline scan: centered response is non-finite at row {index}")
3474 })
3475 })
3476 .collect::<Result<Vec<_>, _>>()?;
3477 let mut nodes: Vec<PooledNode> = Vec::new();
3478 for &i in &perm {
3479 match nodes.last_mut() {
3480 Some(last) if last.x == x[i] => {
3481 let w_new = last.w + w[i];
3482 last.y = (last.y * last.w + centered_y[i] * w[i]) / w_new;
3483 last.w = w_new;
3484 }
3485 _ => nodes.push(PooledNode {
3486 x: x[i],
3487 y: centered_y[i],
3488 w: w[i],
3489 }),
3490 }
3491 }
3492 if nodes.len() < order + 1 {
3494 return Err(format!(
3495 "spline scan: order {order} needs at least {} distinct abscissae, got {}",
3496 order + 1,
3497 nodes.len()
3498 ));
3499 }
3500 let mut ssr_within = 0.0;
3502 let mut k = 0usize;
3503 for &i in &perm {
3504 while nodes[k].x != x[i] {
3505 k += 1;
3506 }
3507 let d = centered_y[i] - nodes[k].y;
3508 ssr_within += w[i] * d * d;
3509 }
3510 Ok((nodes, ssr_within, n, response_origin))
3511}
3512
3513fn concentrated_criterion_jet(
3520 nodes: &[PooledNode],
3521 ssr_within: f64,
3522 n_obs: usize,
3523 log_lambda: f64,
3524 order: usize,
3525) -> Result<(f64, f64, f64, f64), String> {
3526 let q = gam_problem::checked_exp_log_strength(-log_lambda)
3527 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
3528 let pass = run_filter::<false>(nodes, q, order)?;
3529 let dof = (n_obs - order) as f64;
3532 let rss = pass.sum_v2_over_f + ssr_within;
3533 if rss <= 0.0 {
3534 return Err("spline scan: degenerate zero residual sum".to_string());
3535 }
3536 let sigma2 = rss / dof;
3537 if pass.n_proper != nodes.len() - order {
3538 return Err(format!(
3539 "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
3540 nodes.len() - order,
3541 pass.n_proper
3542 ));
3543 }
3544 let rss_d1 = pass.sum_v2_over_f_d1;
3545 let rss_d2 = pass.sum_v2_over_f_d2;
3546 let rss_d3 = pass.sum_v2_over_f_d3;
3547 let rss_log_d1 = rss_d1 / rss;
3548 let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
3549 let rss_log_d3 = rss_d3 / rss - 3.0 * (rss_d2 / rss) * rss_log_d1
3550 + 2.0 * rss_log_d1 * rss_log_d1 * rss_log_d1;
3551 Ok((
3552 -0.5 * (pass.sum_log_f + dof * sigma2.ln()),
3553 -0.5 * (pass.sum_log_f_d1 + dof * rss_log_d1),
3554 -0.5 * (pass.sum_log_f_d2 + dof * rss_log_d2),
3555 -0.5 * (pass.sum_log_f_d3 + dof * rss_log_d3),
3556 ))
3557}
3558
3559#[derive(Clone, Copy, Debug)]
3560struct CertifiedCriterionJet {
3561 jet: ScoreJet,
3562 value: Ball,
3563 derivative: Ball,
3564 curvature: Ball,
3565 third: Ball,
3566 curvature_source: BoundSource,
3570 third_source: BoundSource,
3571}
3572
3573impl CertifiedCriterionJet {
3574 fn weakened_anchor(self) -> Option<(BoundSource, BoundSource)> {
3587 if matches!(
3588 (self.curvature_source, self.third_source),
3589 (BoundSource::EndpointJet, BoundSource::EndpointJet)
3590 ) {
3591 None
3592 } else {
3593 Some((self.curvature_source, self.third_source))
3594 }
3595 }
3596}
3597
3598#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3600enum BoundSource {
3601 EndpointJet,
3603 AnalyticGlobalBound,
3617}
3618
3619fn curvature_global_bound(proper_modes: f64, residual_dof: f64) -> f64 {
3623 0.5 * (0.25 * proper_modes + 2.0 * residual_dof)
3624}
3625
3626fn third_derivative_global_bound(proper_modes: f64, residual_dof: f64) -> f64 {
3627 0.5 * (0.25 * proper_modes + 6.0 * residual_dof)
3628}
3629
3630fn fifth_derivative_global_bound(proper_modes: Ball, residual_dof: Ball) -> Ball {
3652 proper_modes
3653 .scale(18.75)
3654 .add(residual_dof.scale(690.0))
3655 .scale(0.5)
3656}
3657
3658fn intersect_with_global_bound(ball: Ball, bound: f64) -> (Ball, BoundSource) {
3672 if ball.is_finite() {
3673 let lo = ball.lo.max(-bound);
3674 let hi = ball.hi.min(bound);
3675 if lo <= hi {
3676 return (
3677 Ball {
3678 value: ball.value.clamp(lo, hi),
3679 lo,
3680 hi,
3681 },
3682 BoundSource::EndpointJet,
3683 );
3684 }
3685 }
3686 (
3687 Ball {
3688 value: ball.value.clamp(-bound, bound),
3689 lo: -bound,
3690 hi: bound,
3691 },
3692 BoundSource::AnalyticGlobalBound,
3693 )
3694}
3695
3696fn certified_concentrated_criterion_jet(
3699 nodes: &[PooledNode],
3700 ssr_within: f64,
3701 n_obs: usize,
3702 log_lambda: f64,
3703 order: usize,
3704) -> Result<CertifiedCriterionJet, SplineScoreProofError> {
3705 let q_value = gam_problem::checked_exp_log_strength(-log_lambda).map_err(|error| {
3706 SplineScoreProofError::InvalidInput(format!("spline scan inverse log strength: {error}"))
3707 })?;
3708 let q_enclosure = gam_math::score_opt::certified_exp(-log_lambda).ok_or(
3709 SplineScoreProofError::InvalidArithmetic {
3710 context: "inverse log-strength exponential",
3711 },
3712 )?;
3713 let q = Ball::certified(q_value, q_enclosure);
3714 let pass = run_filter_ball(nodes, q, order)?;
3715 if pass.n_proper != nodes.len() - order {
3716 return Err(SplineScoreProofError::InvalidInput(format!(
3717 "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
3718 nodes.len() - order,
3719 pass.n_proper
3720 )));
3721 }
3722
3723 let dof = Ball::exact((n_obs - order) as f64);
3724 let rss = pass.sum_v2_over_f.add(Ball::exact(ssr_within));
3725 if !(rss.lo > 0.0) {
3726 return Err(SplineScoreProofError::NonPositiveProfileResidual {
3727 enclosure: rss.interval(),
3728 });
3729 }
3730 let sigma2 = rss.div_positive(dof);
3731 let rss_d1 = pass.sum_v2_over_f_d1;
3732 let rss_d2 = pass.sum_v2_over_f_d2;
3733 let rss_d3 = pass.sum_v2_over_f_d3;
3734 let mut rss_log_d1 = rss_d1.div_positive(rss);
3735 intersect_with_exact_range(&mut rss_log_d1, 0.0, 1.0);
3744 let rss_log_d2 = rss_d2.div_positive(rss).sub(rss_log_d1.square());
3745 let rss_log_d3 = rss_d3
3746 .div_positive(rss)
3747 .sub(rss_d2.div_positive(rss).mul(rss_log_d1).scale(3.0))
3748 .add(rss_log_d1.square().mul(rss_log_d1).scale(2.0));
3749 let value = pass
3750 .sum_log_f
3751 .add(dof.mul(sigma2.ln_positive()))
3752 .scale(-0.5);
3753 let derivative = pass.sum_log_f_d1.add(dof.mul(rss_log_d1)).scale(-0.5);
3754 let curvature = pass.sum_log_f_d2.add(dof.mul(rss_log_d2)).scale(-0.5);
3755 let third = pass.sum_log_f_d3.add(dof.mul(rss_log_d3)).scale(-0.5);
3756 if [value, derivative]
3757 .into_iter()
3758 .any(|ball| !ball.is_finite())
3759 {
3760 return Err(SplineScoreProofError::InvalidArithmetic {
3761 context: "concentrated criterion",
3762 });
3763 }
3764 let proper_modes = (nodes.len() - order) as f64;
3772 let residual_dof = (n_obs - order) as f64;
3773 let (curvature, curvature_source) = intersect_with_global_bound(
3774 curvature,
3775 curvature_global_bound(proper_modes, residual_dof),
3776 );
3777 let (third, third_source) = intersect_with_global_bound(
3778 third,
3779 third_derivative_global_bound(proper_modes, residual_dof),
3780 );
3781 Ok(CertifiedCriterionJet {
3782 jet: ScoreJet {
3783 value: value.value,
3784 derivative: derivative.value,
3785 curvature: curvature.value,
3786 third: third.value,
3787 },
3788 value,
3789 derivative,
3790 curvature,
3791 third,
3792 curvature_source,
3793 third_source,
3794 })
3795}
3796
3797fn concentrated_criterion_enclosure(
3866 n_nodes: usize,
3867 n_obs: usize,
3868 left: ScoreSample,
3869 right: ScoreSample,
3870 left_certificate: CertifiedCriterionJet,
3871 right_certificate: CertifiedCriterionJet,
3872 order: usize,
3873) -> Result<DerivativeEnclosure, SplineScoreProofError> {
3874 let (lo, hi) = (left.x, right.x);
3875 if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
3876 return Err(SplineScoreProofError::InvalidInput(format!(
3877 "spline scan: invalid score-enclosure interval [{lo}, {hi}]"
3878 )));
3879 }
3880 if lo == hi {
3881 return Ok(DerivativeEnclosure {
3882 score: ScoreValueEnclosure {
3883 value: ClosedInterval::new(
3884 left_certificate.value.lo.min(right_certificate.value.lo),
3885 left_certificate.value.hi.max(right_certificate.value.hi),
3886 ),
3887 evaluation_error: left_certificate
3888 .value
3889 .forward_error()
3890 .max(right_certificate.value.forward_error()),
3891 },
3892 derivative: ClosedInterval::new(
3893 left_certificate
3894 .derivative
3895 .lo
3896 .min(right_certificate.derivative.lo),
3897 left_certificate
3898 .derivative
3899 .hi
3900 .max(right_certificate.derivative.hi),
3901 ),
3902 curvature: ClosedInterval::new(
3903 left_certificate
3904 .curvature
3905 .lo
3906 .min(right_certificate.curvature.lo),
3907 left_certificate
3908 .curvature
3909 .hi
3910 .max(right_certificate.curvature.hi),
3911 ),
3912 });
3913 }
3914 let width = Ball::exact(hi).sub(Ball::exact(lo));
3915 if !(width.lo > 0.0) {
3916 return Err(SplineScoreProofError::InvalidArithmetic {
3917 context: "positive score-enclosure width",
3918 });
3919 }
3920 let proper_modes = Ball::exact((n_nodes - order) as f64);
3921 let residual_dof = Ball::exact((n_obs - order) as f64);
3922 let fifth_abs_bound = fifth_derivative_global_bound(proper_modes, residual_dof);
3923 let third_abs_bound = proper_modes
3924 .scale(0.25)
3925 .add(residual_dof.scale(6.0))
3926 .scale(0.5);
3927 for (side, weakened) in [
3933 ("left", left_certificate.weakened_anchor()),
3934 ("right", right_certificate.weakened_anchor()),
3935 ] {
3936 if let Some((curvature_source, third_source)) = weakened {
3937 log::debug!(
3938 "spline scan enclosure: {side} endpoint curvature anchored by \
3939 {curvature_source:?}, third order by {third_source:?}. A global-bound \
3940 anchor keeps the search CERTIFIED and widens its tail cells -- half rate \
3941 in place of fourth-order rate -- so it costs cells, never soundness."
3942 );
3943 }
3944 }
3945 let half_width = width.scale(0.5);
3946 let width2 = width.square();
3947 let width3 = width2.mul(width);
3948 let width4 = width2.square();
3949 let width5 = width4.mul(width);
3950 let value_remainder = fifth_abs_bound
3951 .mul(width5)
3952 .div_positive(Ball::exact(960.0))
3953 .hi;
3954 let derivative_remainder = fifth_abs_bound
3955 .mul(width4)
3956 .div_positive(Ball::exact(128.0))
3957 .hi;
3958 let curvature_remainder = fifth_abs_bound
3959 .mul(width3)
3960 .div_positive(Ball::exact(24.0))
3961 .hi;
3962 let third_slope = right_certificate
3963 .third
3964 .sub(left_certificate.third)
3965 .div_positive(width);
3966
3967 let endpoint_enclosure = |certificate: CertifiedCriterionJet,
3973 displacement: ClosedInterval,
3974 value_remainder: f64,
3975 derivative_remainder: f64,
3976 curvature_remainder: f64| {
3977 let d = Ball::certified(0.0, displacement);
3978 let d2 = d.square();
3979 let d3 = d2.mul(d);
3980 let d4 = d2.square();
3981 let value = certificate
3982 .value
3983 .add(certificate.derivative.mul(d))
3984 .add(certificate.curvature.mul(d2).scale(0.5))
3985 .add(certificate.third.mul(d3).div_positive(Ball::exact(6.0)))
3986 .add(third_slope.mul(d4).div_positive(Ball::exact(24.0)))
3987 .interval()
3988 .add(ClosedInterval::new(-value_remainder, value_remainder));
3989 let derivative = certificate
3990 .derivative
3991 .add(certificate.curvature.mul(d))
3992 .add(certificate.third.mul(d2).scale(0.5))
3993 .add(third_slope.mul(d3).div_positive(Ball::exact(6.0)))
3994 .interval()
3995 .add(ClosedInterval::new(
3996 -derivative_remainder,
3997 derivative_remainder,
3998 ));
3999 let curvature = certificate
4000 .curvature
4001 .add(certificate.third.mul(d))
4002 .add(third_slope.mul(d2).scale(0.5))
4003 .interval()
4004 .add(ClosedInterval::new(
4005 -curvature_remainder,
4006 curvature_remainder,
4007 ));
4008 (value, derivative, curvature)
4009 };
4010
4011 let (left_value, left_derivative, left_curvature) = endpoint_enclosure(
4012 left_certificate,
4013 ClosedInterval::new(0.0, half_width.hi),
4014 value_remainder,
4015 derivative_remainder,
4016 curvature_remainder,
4017 );
4018 let (right_value, right_derivative, right_curvature) = endpoint_enclosure(
4019 right_certificate,
4020 ClosedInterval::new(-half_width.hi, 0.0),
4021 value_remainder,
4022 derivative_remainder,
4023 curvature_remainder,
4024 );
4025 let half_cell_score = ClosedInterval::new(
4026 left_value.lo.min(right_value.lo),
4027 left_value.hi.max(right_value.hi),
4028 );
4029 let full_value_remainder = fifth_abs_bound
4030 .mul(width5)
4031 .div_positive(Ball::exact(80.0))
4032 .hi;
4033 let full_derivative_remainder = fifth_abs_bound
4034 .mul(width4)
4035 .div_positive(Ball::exact(24.0))
4036 .hi;
4037 let full_curvature_remainder = fifth_abs_bound
4038 .mul(width3)
4039 .div_positive(Ball::exact(12.0))
4040 .hi;
4041 let (full_left_value, _, _) = endpoint_enclosure(
4042 left_certificate,
4043 ClosedInterval::new(0.0, width.hi),
4044 full_value_remainder,
4045 full_derivative_remainder,
4046 full_curvature_remainder,
4047 );
4048 let (full_right_value, _, _) = endpoint_enclosure(
4049 right_certificate,
4050 ClosedInterval::new(-width.hi, 0.0),
4051 full_value_remainder,
4052 full_derivative_remainder,
4053 full_curvature_remainder,
4054 );
4055 let score_value = ClosedInterval::new(
4056 half_cell_score
4057 .lo
4058 .max(full_left_value.lo)
4059 .max(full_right_value.lo),
4060 half_cell_score
4061 .hi
4062 .min(full_left_value.hi)
4063 .min(full_right_value.hi),
4064 );
4065 if !(score_value.lo <= score_value.hi) {
4066 return Err(SplineScoreProofError::InvalidArithmetic {
4067 context: "endpoint score-enclosure intersection",
4068 });
4069 }
4070 let endpoint_third_derivative = ClosedInterval::new(
4071 left_derivative.lo.min(right_derivative.lo),
4072 left_derivative.hi.max(right_derivative.hi),
4073 );
4074 let endpoint_third_curvature = ClosedInterval::new(
4075 left_curvature.lo.min(right_curvature.lo),
4076 left_curvature.hi.max(right_curvature.hi),
4077 );
4078 let derivative_secant = right_certificate
4079 .derivative
4080 .sub(left_certificate.derivative)
4081 .div_positive(width);
4082 let secant_radius = third_abs_bound.mul(width).hi;
4083 let secant_curvature = derivative_secant
4084 .interval()
4085 .add(ClosedInterval::new(-secant_radius, secant_radius));
4086 let curvature = ClosedInterval::new(
4087 endpoint_third_curvature.lo.max(secant_curvature.lo),
4088 endpoint_third_curvature.hi.min(secant_curvature.hi),
4089 );
4090 if !(curvature.lo <= curvature.hi) {
4091 return Err(SplineScoreProofError::InvalidArithmetic {
4092 context: "curvature secant intersection",
4093 });
4094 }
4095 let curvature_ball = Ball::certified(0.0, curvature);
4096 let derivative_from_left = left_certificate
4097 .derivative
4098 .add(curvature_ball.mul(Ball::certified(0.0, ClosedInterval::new(0.0, width.hi))))
4099 .interval();
4100 let derivative_from_right = right_certificate
4101 .derivative
4102 .add(curvature_ball.mul(Ball::certified(0.0, ClosedInterval::new(-width.hi, 0.0))))
4103 .interval();
4104 let derivative_from_curvature = ClosedInterval::new(
4105 derivative_from_left.lo.max(derivative_from_right.lo),
4106 derivative_from_left.hi.min(derivative_from_right.hi),
4107 );
4108 let derivative = ClosedInterval::new(
4109 endpoint_third_derivative
4110 .lo
4111 .max(derivative_from_curvature.lo),
4112 endpoint_third_derivative
4113 .hi
4114 .min(derivative_from_curvature.hi),
4115 );
4116 if !(derivative.lo <= derivative.hi) {
4117 return Err(SplineScoreProofError::InvalidArithmetic {
4118 context: "derivative curvature-integral intersection",
4119 });
4120 }
4121 let evaluation_error = left_certificate
4122 .value
4123 .forward_error()
4124 .max(right_certificate.value.forward_error());
4125 Ok(DerivativeEnclosure {
4126 score: ScoreValueEnclosure {
4127 value: score_value,
4128 evaluation_error,
4129 },
4130 derivative,
4131 curvature,
4132 })
4133}
4134
4135fn leading_block_smooth(
4168 sm_state: &mut [Vec2],
4169 sm_cov: &mut [Mat2],
4170 gains: &mut [Mat2],
4171 nodes: &[PooledNode],
4172 q: f64,
4173 order: usize,
4174) -> Result<(), String> {
4175 let nb = order - 1; let pin = order - 1; let d = nb * order; let mut lambda = vec![vec![0.0_f64; d]; d];
4179 let mut b_const = vec![0.0_f64; d];
4180 let mut bmat = vec![vec![0.0_f64; order]; d]; for t in 0..order - 1 {
4184 let delta = nodes[t + 1].x - nodes[t].x;
4185 let f = transition(delta, order);
4186 let qn = process_noise(delta, q, order);
4187 let a = mat_inv(&qn, order, "leading-block increment noise")?; let ft = mat_t(&f, order);
4189 let fta = mat_mul(&ft, &a, order); let ftaf = mat_mul(&fta, &f, order); let af = mat_mul(&a, &f, order); for i in 0..order {
4194 for j in 0..order {
4195 lambda[t * order + i][t * order + j] += ftaf[i][j];
4196 }
4197 }
4198 if t + 1 <= nb - 1 {
4199 for i in 0..order {
4202 for j in 0..order {
4203 lambda[(t + 1) * order + i][(t + 1) * order + j] += a[i][j];
4204 lambda[t * order + i][(t + 1) * order + j] -= fta[i][j];
4205 lambda[(t + 1) * order + i][t * order + j] -= af[i][j];
4206 }
4207 }
4208 } else {
4209 for i in 0..order {
4212 for j in 0..order {
4213 bmat[t * order + i][j] += fta[i][j];
4214 }
4215 }
4216 }
4217 }
4218 for t in 0..nb {
4220 let w = nodes[t].w;
4221 lambda[t * order][t * order] += w;
4222 b_const[t * order] += w * nodes[t].y;
4223 }
4224
4225 let sigma = dense_spd_inverse(&lambda, "leading-block precision")?;
4227 let dvec: Vec<f64> = (0..d)
4228 .map(|i| (0..d).map(|k| sigma[i][k] * b_const[k]).sum())
4229 .collect();
4230 let cmat: Vec<Vec<f64>> = (0..d)
4231 .map(|i| {
4232 (0..order)
4233 .map(|j| (0..d).map(|k| sigma[i][k] * bmat[k][j]).sum())
4234 .collect()
4235 })
4236 .collect();
4237
4238 let ahat_p = sm_state[pin];
4240 let vp = sm_cov[pin];
4241 let cvp: Vec<Vec<f64>> = (0..d)
4243 .map(|i| {
4244 (0..order)
4245 .map(|j| (0..order).map(|k| cmat[i][k] * vp[k][j]).sum())
4246 .collect()
4247 })
4248 .collect();
4249 let mean_u: Vec<f64> = (0..d)
4251 .map(|i| (0..order).map(|j| cmat[i][j] * ahat_p[j]).sum::<f64>() + dvec[i])
4252 .collect();
4253 let cov_u: Vec<Vec<f64>> = (0..d)
4255 .map(|i| {
4256 (0..d)
4257 .map(|k| (0..order).map(|j| cvp[i][j] * cmat[k][j]).sum::<f64>() + sigma[i][k])
4258 .collect()
4259 })
4260 .collect();
4261
4262 for j in 0..nb {
4264 for i in 0..order {
4265 sm_state[j][i] = mean_u[j * order + i];
4266 }
4267 let mut cov = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4268 for i in 0..order {
4269 for k in 0..order {
4270 cov[i][k] = cov_u[j * order + i][j * order + k];
4271 }
4272 }
4273 symmetrize(&mut cov, order);
4274 sm_cov[j] = cov;
4275 }
4276 for j in 0..nb {
4280 let mut cross = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4281 if j + 1 <= nb - 1 {
4282 for i in 0..order {
4284 for k in 0..order {
4285 cross[i][k] = cov_u[j * order + i][(j + 1) * order + k];
4286 }
4287 }
4288 } else {
4289 for i in 0..order {
4291 for k in 0..order {
4292 cross[i][k] = cvp[j * order + i][k];
4293 }
4294 }
4295 }
4296 let denom_inv = mat_inv(&sm_cov[j + 1], order, "leading-block gain denominator")?;
4297 gains[j] = mat_mul(&cross, &denom_inv, order);
4298 }
4299 Ok(())
4300}
4301
4302pub fn fit_spline_scan_at(
4305 x: &[f64],
4306 y: &[f64],
4307 w: &[f64],
4308 log_lambda: f64,
4309 sigma2: Option<f64>,
4310 order: usize,
4311) -> Result<SplineScanFit, String> {
4312 if order == 0 || order > MAX_ORDER {
4313 return Err(format!(
4314 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
4315 ));
4316 }
4317 let (nodes, ssr_within, n_obs, response_origin) = pool_nodes(x, y, w, order)?;
4318 let q = gam_problem::checked_exp_log_strength(-log_lambda)
4319 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
4320 let pass = run_filter::<true>(&nodes, q, order)?;
4321 let n = nodes.len();
4322 let dof = (n_obs - order) as f64;
4323 let sigma2 = match sigma2 {
4324 Some(s) => {
4325 if !(s.is_finite() && s > 0.0) {
4326 return Err(format!("spline scan: invalid sigma2 {s}"));
4327 }
4328 s
4329 }
4330 None => (pass.sum_v2_over_f + ssr_within) / dof,
4331 };
4332 let rss = pass.sum_v2_over_f + ssr_within;
4337 let restricted_loglik = -0.5 * (pass.sum_log_f + dof * sigma2.ln() + rss / sigma2);
4338
4339 let mut sm_state = vec![[0.0_f64; MAX_ORDER]; n];
4350 let mut sm_cov = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
4351 let mut gains = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
4352 sm_state[n - 1] = pass.steps[n - 1].a_filt;
4353 sm_cov[n - 1] = pass.steps[n - 1].p_filt;
4354 for t in (order - 1..n - 1).rev() {
4355 let p_next_pred = &pass.steps[t + 1].p_pred;
4356 let delta = nodes[t + 1].x - nodes[t].x;
4357 let f_t = transition(delta, order);
4358 let p_inv = mat_inv(p_next_pred, order, "RTS predicted covariance")?;
4359 let g = mat_mul(
4360 &mat_mul(&pass.steps[t].p_filt, &mat_t(&f_t, order), order),
4361 &p_inv,
4362 order,
4363 );
4364 let mut dm: Vec2 = [0.0; MAX_ORDER];
4365 for i in 0..order {
4366 dm[i] = sm_state[t + 1][i] - pass.steps[t + 1].a_pred[i];
4367 }
4368 let corr = mat_vec(&g, &dm, order);
4369 for i in 0..order {
4370 sm_state[t][i] = pass.steps[t].a_filt[i] + corr[i];
4371 }
4372 let dp = mat_sub(&sm_cov[t + 1], p_next_pred, order);
4373 let mut cov = mat_add(
4374 &pass.steps[t].p_filt,
4375 &mat_mul(&mat_mul(&g, &dp, order), &mat_t(&g, order), order),
4376 order,
4377 );
4378 symmetrize(&mut cov, order);
4379 sm_cov[t] = cov;
4380 gains[t] = g;
4381 }
4382 if order >= 2 {
4385 leading_block_smooth(&mut sm_state, &mut sm_cov, &mut gains, &nodes, q, order)?;
4386 }
4387
4388 let knots: Vec<f64> = nodes.iter().map(|n| n.x).collect();
4389 let centered_mean: Vec<f64> = sm_state.iter().map(|s| s[0]).collect();
4390 let data_sse = ssr_within
4395 + nodes
4396 .iter()
4397 .zip(centered_mean.iter())
4398 .map(|(node, &fhat)| {
4399 let r = node.y - fhat;
4400 node.w * r * r
4401 })
4402 .sum::<f64>();
4403 let sum_log_weights = w.iter().map(|weight| weight.ln()).sum::<f64>();
4408 let log_likelihood = -0.5
4409 * (data_sse / sigma2
4410 + n_obs as f64 * (std::f64::consts::TAU.ln() + sigma2.ln())
4411 - sum_log_weights);
4412 if !log_likelihood.is_finite() {
4413 return Err(format!(
4414 "spline scan: weighted Gaussian log-likelihood is non-finite \
4415 (data_sse={data_sse}, sigma2={sigma2}, n={n_obs}, \
4416 sum_log_weights={sum_log_weights})"
4417 ));
4418 }
4419 for (index, state) in sm_state.iter_mut().enumerate() {
4423 state[0] += response_origin;
4424 if !state[0].is_finite() {
4425 return Err(format!(
4426 "spline scan: restored fitted response is non-finite at node {index}"
4427 ));
4428 }
4429 }
4430 let mean: Vec<f64> = sm_state.iter().map(|state| state[0]).collect();
4431 let deriv: Option<Vec<f64>> =
4434 (order >= 2).then(|| sm_state.iter().map(|state| state[1]).collect());
4435 let var: Vec<f64> = sm_cov.iter().map(|p| p[0][0] * sigma2).collect();
4436 Ok(SplineScanFit {
4437 order,
4438 knots,
4439 mean,
4440 deriv,
4441 var,
4442 log_lambda,
4443 sigma2,
4444 restricted_loglik,
4445 log_likelihood,
4446 training_sample_size: std::num::NonZeroUsize::new(n_obs)
4447 .expect("pool_nodes requires at least one training row"),
4448 data_sse,
4449 smoothed_state: sm_state,
4450 smoothed_cov: sm_cov,
4451 rts_gain: gains,
4452 q,
4453 node_weight: nodes.iter().map(|n| n.w).collect(),
4454 })
4455}
4456
4457#[derive(Clone, Copy, Debug, PartialEq)]
4458enum SplineKktKind {
4459 LowerBoundary,
4460 UpperBoundary,
4461 Stationary { curvature: ClosedInterval },
4462}
4463
4464#[derive(Clone, Copy, Debug, PartialEq)]
4465enum SplineOptimumProof {
4466 Kkt {
4467 bracket: ClosedInterval,
4468 kind: SplineKktKind,
4469 },
4470 ResolutionFlat {
4475 bracket: ClosedInterval,
4476 max_score_gap: f64,
4477 score_resolution: f64,
4478 },
4479}
4480
4481fn spline_optimum_proof(
4492 search: &ScoreSearchResult,
4493) -> Result<SplineOptimumProof, SplineScoreProofError> {
4494 match search.location {
4495 ScoreOptimumLocation::LowerBoundary => Ok(SplineOptimumProof::Kkt {
4496 bracket: ClosedInterval::point(search.lower_boundary.x),
4497 kind: SplineKktKind::LowerBoundary,
4498 }),
4499 ScoreOptimumLocation::UpperBoundary => Ok(SplineOptimumProof::Kkt {
4500 bracket: ClosedInterval::point(search.upper_boundary.x),
4501 kind: SplineKktKind::UpperBoundary,
4502 }),
4503 ScoreOptimumLocation::Stationary(index) => {
4504 let stationary = search.stationary_points.get(index).ok_or_else(|| {
4505 SplineScoreProofError::Search(
4506 "optimizer returned an invalid stationary-point index".to_string(),
4507 )
4508 })?;
4509 Ok(SplineOptimumProof::Kkt {
4510 bracket: stationary.bracket,
4511 kind: SplineKktKind::Stationary {
4512 curvature: stationary.curvature,
4513 },
4514 })
4515 }
4516 ScoreOptimumLocation::ResolutionFlat(index) => {
4517 let flat = search.resolution_flat_regions.get(index).ok_or_else(|| {
4518 SplineScoreProofError::Search(
4519 "optimizer returned an invalid resolution-flat index".to_string(),
4520 )
4521 })?;
4522 if !(flat.max_score_gap.is_finite()
4523 && flat.max_score_gap >= 0.0
4524 && flat.score_resolution.is_finite()
4525 && flat.score_resolution >= 0.0
4526 && flat.max_score_gap <= flat.score_resolution
4527 && flat.bracket.contains(search.optimum.x)
4528 && flat.sample.x.to_bits() == search.optimum.x.to_bits())
4529 {
4530 return Err(SplineScoreProofError::Search(format!(
4531 "optimizer returned an invalid resolution-flat certificate: selected {}, \
4532 representative {}, bracket {:?}, maximum score gap {}, score resolution {}",
4533 search.optimum.x,
4534 flat.sample.x,
4535 flat.bracket,
4536 flat.max_score_gap,
4537 flat.score_resolution
4538 )));
4539 }
4540 Ok(SplineOptimumProof::ResolutionFlat {
4541 bracket: flat.bracket,
4542 max_score_gap: flat.max_score_gap,
4543 score_resolution: flat.score_resolution,
4544 })
4545 }
4546 }
4547}
4548
4549fn spline_kkt_holds(
4550 kind: SplineKktKind,
4551 final_enclosure: DerivativeEnclosure,
4552) -> (bool, ClosedInterval) {
4553 match kind {
4554 SplineKktKind::LowerBoundary => (
4555 final_enclosure.derivative.hi <= 0.0,
4556 final_enclosure.curvature,
4557 ),
4558 SplineKktKind::UpperBoundary => (
4559 final_enclosure.derivative.lo >= 0.0,
4560 final_enclosure.curvature,
4561 ),
4562 SplineKktKind::Stationary { curvature } => (
4563 final_enclosure.derivative.contains_zero() && curvature.hi < 0.0,
4570 curvature,
4571 ),
4572 }
4573}
4574
4575pub fn fit_spline_scan(
4580 x: &[f64],
4581 y: &[f64],
4582 w: &[f64],
4583 order: usize,
4584) -> Result<SplineScanFit, SplineScoreProofError> {
4585 if order == 0 || order > MAX_ORDER {
4586 return Err(SplineScoreProofError::InvalidInput(format!(
4587 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
4588 )));
4589 }
4590 let (nodes, ssr_within, n_obs, _response_origin) = pool_nodes(x, y, w, order)?;
4591 let first_x = nodes
4606 .first()
4607 .ok_or_else(|| {
4608 SplineScoreProofError::InvalidInput(
4609 "spline scan: pooled data unexpectedly contain no nodes".to_string(),
4610 )
4611 })?
4612 .x;
4613 let last_x = nodes
4614 .last()
4615 .ok_or_else(|| {
4616 SplineScoreProofError::InvalidInput(
4617 "spline scan: pooled data unexpectedly contain no nodes".to_string(),
4618 )
4619 })?
4620 .x;
4621 let span = last_x - first_x;
4622 if !(span.is_finite() && span > 0.0) {
4623 return Err(SplineScoreProofError::InvalidInput(format!(
4624 "spline scan: pooled covariate span must be finite and positive, got {span}"
4625 )));
4626 }
4627 let log_span = gam_math::score_opt::certified_ln_positive(span).ok_or(
4628 SplineScoreProofError::InvalidArithmetic {
4629 context: "covariate-span logarithm",
4630 },
4631 )?;
4632 let log_span_representative = log_span.lo + 0.5 * (log_span.hi - log_span.lo);
4633 let scale_shift = (2 * order - 1) as f64 * log_span_representative;
4634 let lo_anchor = LOG_LAMBDA_LO + scale_shift;
4635 let hi_anchor = LOG_LAMBDA_HI + scale_shift;
4636 let n_nodes = nodes.len();
4637 let endpoint_certificates = RefCell::new(HashMap::<u64, CertifiedCriterionJet>::new());
4638 let search = maximize_score_1d(
4639 lo_anchor,
4640 hi_anchor,
4641 f64::EPSILON.sqrt(),
4642 |ll| {
4643 let certificate =
4644 certified_concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order)?;
4645 endpoint_certificates
4646 .borrow_mut()
4647 .insert(ll.to_bits(), certificate);
4648 Ok(certificate.jet)
4649 },
4650 |left, right| {
4651 let certificates = endpoint_certificates.borrow();
4652 let left_certificate = certificates
4653 .get(&left.x.to_bits())
4654 .copied()
4655 .ok_or(SplineScoreProofError::MissingEndpointCertificate { log_lambda: left.x })?;
4656 let right_certificate = certificates.get(&right.x.to_bits()).copied().ok_or(
4657 SplineScoreProofError::MissingEndpointCertificate {
4658 log_lambda: right.x,
4659 },
4660 )?;
4661 concentrated_criterion_enclosure(
4662 n_nodes,
4663 n_obs,
4664 left,
4665 right,
4666 left_certificate,
4667 right_certificate,
4668 order,
4669 )
4670 },
4671 )
4672 .map_err(|error| match error {
4673 gam_math::score_opt::ScoreSearchError::PointEvaluation { source, .. }
4674 | gam_math::score_opt::ScoreSearchError::EnclosureEvaluation { source, .. } => source,
4675 other => SplineScoreProofError::Search(other.to_string()),
4676 })?;
4677 if search.value_certificate.maximum_excess > search.value_certificate.comparison_resolution {
4678 return Err(SplineScoreProofError::GlobalValueOrderingUnresolved {
4679 maximum_excess: search.value_certificate.maximum_excess,
4680 comparison_resolution: search.value_certificate.comparison_resolution,
4681 });
4682 }
4683 match spline_optimum_proof(&search)? {
4684 SplineOptimumProof::Kkt {
4685 bracket: kkt_bracket,
4686 kind: kkt_kind,
4687 } => {
4688 let kkt_enclosure = {
4689 let certificates = endpoint_certificates.borrow();
4690 let left_certificate = certificates.get(&kkt_bracket.lo.to_bits()).copied().ok_or(
4691 SplineScoreProofError::MissingEndpointCertificate {
4692 log_lambda: kkt_bracket.lo,
4693 },
4694 )?;
4695 let right_certificate = certificates
4696 .get(&kkt_bracket.hi.to_bits())
4697 .copied()
4698 .ok_or(SplineScoreProofError::MissingEndpointCertificate {
4699 log_lambda: kkt_bracket.hi,
4700 })?;
4701 let sample = |log_lambda: f64, certificate: CertifiedCriterionJet| ScoreSample {
4702 x: log_lambda,
4703 value: certificate.jet.value,
4704 derivative: certificate.jet.derivative,
4705 curvature: certificate.jet.curvature,
4706 third: certificate.jet.third,
4707 };
4708 concentrated_criterion_enclosure(
4709 n_nodes,
4710 n_obs,
4711 sample(kkt_bracket.lo, left_certificate),
4712 sample(kkt_bracket.hi, right_certificate),
4713 left_certificate,
4714 right_certificate,
4715 order,
4716 )?
4717 };
4718 let (kkt_holds, kkt_curvature) = spline_kkt_holds(kkt_kind, kkt_enclosure);
4719 if !kkt_holds {
4720 return Err(SplineScoreProofError::OptimumKktUncertified {
4721 location: search.location,
4722 bracket: kkt_bracket,
4723 derivative: kkt_enclosure.derivative,
4724 curvature: kkt_curvature,
4725 });
4726 }
4727 }
4728 SplineOptimumProof::ResolutionFlat {
4729 bracket,
4730 max_score_gap,
4731 score_resolution,
4732 } => {
4733 log::debug!(
4734 "spline scan: accepting certified resolution-flat REML optimum on \
4735 {bracket:?}; maximum score excess {max_score_gap:e} <= comparison \
4736 resolution {score_resolution:e}"
4737 );
4738 }
4739 }
4740 let selected_certificate = endpoint_certificates
4745 .borrow()
4746 .get(&search.optimum.x.to_bits())
4747 .copied()
4748 .ok_or_else(|| {
4749 SplineScoreProofError::Search(format!(
4750 "spline scan: selected log lambda {} has no cached score certificate",
4751 search.optimum.x
4752 ))
4753 })?;
4754 let independent =
4755 concentrated_criterion_jet(&nodes, ssr_within, n_obs, search.optimum.x, order)
4756 .map_err(SplineScoreProofError::Computation)?;
4757 for (name, ball, scalar) in [
4758 ("value", selected_certificate.value, independent.0),
4759 ("derivative", selected_certificate.derivative, independent.1),
4760 ("curvature", selected_certificate.curvature, independent.2),
4761 ("third", selected_certificate.third, independent.3),
4762 ] {
4763 if !ball.interval().contains(scalar) {
4764 return Err(SplineScoreProofError::Computation(format!(
4765 "spline scan: selected {name} scalar {scalar} escapes its directed score ball {:?}",
4766 ball.interval()
4767 )));
4768 }
4769 }
4770 fit_spline_scan_at(x, y, w, search.optimum.x, None, order)
4771 .map_err(SplineScoreProofError::Computation)
4772}
4773
4774#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
4786pub struct SplineScanState {
4787 pub order: usize,
4789 pub knots: Vec<f64>,
4790 pub state: Vec<f64>,
4792 pub cov: Vec<f64>,
4795 pub gain: Vec<f64>,
4798 pub node_weight: Vec<f64>,
4800 pub log_lambda: f64,
4801 pub sigma2: f64,
4802 pub restricted_loglik: f64,
4803 pub log_likelihood: f64,
4807 pub training_sample_size: std::num::NonZeroU64,
4809 pub data_sse: f64,
4814}
4815
4816impl SplineScanFit {
4817 pub fn to_state(&self) -> SplineScanState {
4819 let order = self.order;
4820 let tri = order * (order + 1) / 2;
4821 let nk = self.knots.len();
4822 let mut state = Vec::with_capacity(order * nk);
4823 for s in &self.smoothed_state {
4824 state.extend_from_slice(&s[..order]);
4825 }
4826 let mut cov = Vec::with_capacity(tri * nk);
4827 for c in &self.smoothed_cov {
4828 for i in 0..order {
4829 for j in i..order {
4830 cov.push(c[i][j]);
4831 }
4832 }
4833 }
4834 let mut gain = Vec::with_capacity(order * order * nk);
4835 for g in &self.rts_gain {
4836 for i in 0..order {
4837 for j in 0..order {
4838 gain.push(g[i][j]);
4839 }
4840 }
4841 }
4842 SplineScanState {
4843 order: self.order,
4844 knots: self.knots.clone(),
4845 state,
4846 cov,
4847 gain,
4848 node_weight: self.node_weight.clone(),
4849 log_lambda: self.log_lambda,
4850 sigma2: self.sigma2,
4851 restricted_loglik: self.restricted_loglik,
4852 log_likelihood: self.log_likelihood,
4853 training_sample_size: std::num::NonZeroU64::new(
4854 u64::try_from(self.training_sample_size.get())
4855 .expect("SplineScanFit row count exceeds the persistence format"),
4856 )
4857 .expect("SplineScanFit construction requires training rows"),
4858 data_sse: self.data_sse,
4859 }
4860 }
4861
4862 pub fn from_state(state: &SplineScanState) -> Result<Self, String> {
4870 let order = state.order;
4871 if order == 0 || order > MAX_ORDER {
4872 return Err(format!(
4873 "spline scan state: order must be in 1..={MAX_ORDER}, got {order}"
4874 ));
4875 }
4876 let m = state.knots.len();
4877 if m < order + 1 {
4878 return Err(format!(
4879 "spline scan state: order {order} needs at least {} knots, got {m}",
4880 order + 1
4881 ));
4882 }
4883 let tri = order * (order + 1) / 2;
4884 if state.state.len() != order * m
4885 || state.cov.len() != tri * m
4886 || state.gain.len() != order * order * m
4887 || state.node_weight.len() != m
4888 {
4889 return Err(format!(
4890 "spline scan state: inconsistent lengths (order={order}, m={m}, state={}, cov={}, gain={}, weights={})",
4891 state.state.len(),
4892 state.cov.len(),
4893 state.gain.len(),
4894 state.node_weight.len()
4895 ));
4896 }
4897 let all = state
4898 .state
4899 .iter()
4900 .chain(&state.cov)
4901 .chain(&state.gain)
4902 .chain(&state.knots)
4903 .chain(&state.node_weight);
4904 for (i, v) in all.enumerate() {
4905 if !v.is_finite() {
4906 return Err(format!("spline scan state: non-finite entry at {i}"));
4907 }
4908 }
4909 gam_problem::validate_log_strength(state.log_lambda)
4910 .map_err(|error| format!("spline scan state: {error}"))?;
4911 if !(state.restricted_loglik.is_finite()
4912 && state.log_likelihood.is_finite()
4913 && state.sigma2.is_finite()
4914 && state.sigma2 > 0.0)
4915 {
4916 return Err(format!(
4917 "spline scan state: invalid scalars (log_lambda={}, sigma2={}, \
4918 restricted_loglik={}, log_likelihood={})",
4919 state.log_lambda, state.sigma2, state.restricted_loglik, state.log_likelihood
4920 ));
4921 }
4922 if !(state.data_sse.is_finite() && state.data_sse >= 0.0) {
4923 return Err(format!(
4924 "spline scan state: invalid data_sse {}",
4925 state.data_sse
4926 ));
4927 }
4928 if state.knots.windows(2).any(|kk| !(kk[0] < kk[1])) {
4929 return Err("spline scan state: knots must be strictly increasing".to_string());
4930 }
4931 if state.node_weight.iter().any(|&w| w <= 0.0) {
4932 return Err("spline scan state: node weights must be positive".to_string());
4933 }
4934 let smoothed_state: Vec<Vec2> = state
4935 .state
4936 .chunks_exact(order)
4937 .map(|s| {
4938 let mut v = [0.0_f64; MAX_ORDER];
4939 v[..order].copy_from_slice(s);
4940 v
4941 })
4942 .collect();
4943 let smoothed_cov: Vec<Mat2> = state
4944 .cov
4945 .chunks_exact(tri)
4946 .map(|c| {
4947 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4948 let mut idx = 0;
4949 for i in 0..order {
4950 for j in i..order {
4951 mm[i][j] = c[idx];
4952 mm[j][i] = c[idx];
4953 idx += 1;
4954 }
4955 }
4956 mm
4957 })
4958 .collect();
4959 let rts_gain: Vec<Mat2> = state
4960 .gain
4961 .chunks_exact(order * order)
4962 .map(|g| {
4963 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4964 for i in 0..order {
4965 for j in 0..order {
4966 mm[i][j] = g[i * order + j];
4967 }
4968 }
4969 mm
4970 })
4971 .collect();
4972 let sigma2 = state.sigma2;
4973 let training_sample_size =
4974 usize::try_from(state.training_sample_size.get()).map_err(|_| {
4975 format!(
4976 "spline scan state: training_sample_size {} exceeds this platform's usize",
4977 state.training_sample_size
4978 )
4979 })?;
4980 Ok(Self {
4981 order,
4982 knots: state.knots.clone(),
4983 mean: smoothed_state.iter().map(|s| s[0]).collect(),
4984 deriv: (order >= 2).then(|| smoothed_state.iter().map(|s| s[1]).collect()),
4985 var: smoothed_cov.iter().map(|c| c[0][0] * sigma2).collect(),
4986 log_lambda: state.log_lambda,
4987 sigma2,
4988 restricted_loglik: state.restricted_loglik,
4989 log_likelihood: state.log_likelihood,
4990 training_sample_size: std::num::NonZeroUsize::new(training_sample_size)
4991 .expect("nonzero wire count remains nonzero after conversion"),
4992 data_sse: state.data_sse,
4993 smoothed_state,
4994 smoothed_cov,
4995 rts_gain,
4996 q: gam_problem::checked_exp_log_strength(-state.log_lambda)
4997 .map_err(|error| format!("spline scan inverse log strength: {error}"))?,
4998 node_weight: state.node_weight.clone(),
4999 })
5000 }
5001
5002 pub fn predict(&self, x_new: f64) -> Result<(f64, f64), String> {
5009 if !x_new.is_finite() {
5010 return Err("spline scan: non-finite prediction abscissa".to_string());
5011 }
5012 let n = self.knots.len();
5013 let order = self.order;
5014 let first = self.knots[0];
5015 let last = self.knots[n - 1];
5016 if x_new <= first {
5017 let delta = first - x_new;
5018 let f_t = transition(delta, order);
5020 let f_inv = mat_inv(&f_t, order, "backward extrapolation transition")?;
5021 let mean_s = mat_vec(&f_inv, &self.smoothed_state[0], order);
5022 let qm = process_noise(delta, self.q, order);
5023 let cov = mat_add(
5024 &mat_mul(
5025 &mat_mul(&f_inv, &self.smoothed_cov[0], order),
5026 &mat_t(&f_inv, order),
5027 order,
5028 ),
5029 &mat_mul(&mat_mul(&f_inv, &qm, order), &mat_t(&f_inv, order), order),
5030 order,
5031 );
5032 return Ok((mean_s[0], cov[0][0] * self.sigma2));
5033 }
5034 if x_new >= last {
5035 let delta = x_new - last;
5036 let f_t = transition(delta, order);
5037 let mean_s = mat_vec(&f_t, &self.smoothed_state[n - 1], order);
5038 let cov = mat_add(
5039 &mat_mul(
5040 &mat_mul(&f_t, &self.smoothed_cov[n - 1], order),
5041 &mat_t(&f_t, order),
5042 order,
5043 ),
5044 &process_noise(delta, self.q, order),
5045 order,
5046 );
5047 return Ok((mean_s[0], cov[0][0] * self.sigma2));
5048 }
5049 let t = match self.knots.binary_search_by(|k| k.total_cmp(&x_new)) {
5051 Ok(idx) => return Ok((self.mean[idx], self.var[idx])),
5052 Err(idx) => idx - 1,
5053 };
5054 let (xa, xb) = (self.knots[t], self.knots[t + 1]);
5055 let (d1, d2) = (x_new - xa, xb - x_new);
5056 let (f1m, f2m) = (transition(d1, order), transition(d2, order));
5057 let (q1, q2) = (
5058 process_noise(d1, self.q, order),
5059 process_noise(d2, self.q, order),
5060 );
5061 let q1_inv = mat_inv(&q1, order, "bridge left noise")?;
5062 let q2_inv = mat_inv(&q2, order, "bridge right noise")?;
5063 let lambda = mat_add(
5066 &q1_inv,
5067 &mat_mul(&mat_mul(&mat_t(&f2m, order), &q2_inv, order), &f2m, order),
5068 order,
5069 );
5070 let lam_inv = mat_inv(&lambda, order, "bridge precision")?;
5071 let ca = mat_mul(&lam_inv, &mat_mul(&q1_inv, &f1m, order), order);
5072 let cb = mat_mul(
5073 &lam_inv,
5074 &mat_mul(&mat_t(&f2m, order), &q2_inv, order),
5075 order,
5076 );
5077 let ma = mat_vec(&ca, &self.smoothed_state[t], order);
5078 let mb = mat_vec(&cb, &self.smoothed_state[t + 1], order);
5079 let mut mean_s = [0.0_f64; MAX_ORDER];
5080 for i in 0..order {
5081 mean_s[i] = ma[i] + mb[i];
5082 }
5083 let cross = mat_mul(&self.rts_gain[t], &self.smoothed_cov[t + 1], order);
5086 let mut cov = mat_add(
5087 &mat_add(
5088 &mat_mul(
5089 &mat_mul(&ca, &self.smoothed_cov[t], order),
5090 &mat_t(&ca, order),
5091 order,
5092 ),
5093 &mat_mul(
5094 &mat_mul(&cb, &self.smoothed_cov[t + 1], order),
5095 &mat_t(&cb, order),
5096 order,
5097 ),
5098 order,
5099 ),
5100 &lam_inv,
5101 order,
5102 );
5103 let cab = mat_mul(&mat_mul(&ca, &cross, order), &mat_t(&cb, order), order);
5104 cov = mat_add(&cov, &mat_add(&cab, &mat_t(&cab, order), order), order);
5105 symmetrize(&mut cov, order);
5106 Ok((mean_s[0], cov[0][0] * self.sigma2))
5107 }
5108
5109 pub fn edf(&self) -> f64 {
5123 self.node_weight
5124 .iter()
5125 .zip(self.smoothed_cov.iter())
5126 .map(|(w, c)| w * c[0][0])
5127 .sum()
5128 }
5129
5130 pub fn lambda(&self) -> f64 {
5132 gam_problem::checked_exp_log_strength(self.log_lambda)
5133 .expect("SplineScanFit construction validates its private log strength")
5134 }
5135
5136 pub fn log_lambda(&self) -> f64 {
5137 self.log_lambda
5138 }
5139
5140 pub fn training_sample_size(&self) -> usize {
5142 self.training_sample_size.get()
5143 }
5144
5145 pub fn deviance(&self) -> f64 {
5154 self.data_sse
5155 }
5156}
5157
5158#[cfg(test)]
5159mod tests {
5160 fn covariance_zonotope_from_symmetric_matrix(
5167 matrix: &BallMat,
5168 order: usize,
5169 ) -> Zonotope<COVARIANCE_D1_DIM> {
5170 let mut state = Zonotope::<COVARIANCE_D1_DIM>::zeroed(order * order);
5171 for i in 0..order {
5172 for j in i..order {
5173 let value = matrix[i][j].value;
5174 state.center[i * order + j] = value;
5175 state.center[j * order + i] = value;
5176 let radius = [
5177 (value - matrix[i][j].lo).abs(),
5178 (matrix[i][j].hi - value).abs(),
5179 (value - matrix[j][i].lo).abs(),
5180 (matrix[j][i].hi - value).abs(),
5181 ]
5182 .into_iter()
5183 .fold(0.0_f64, f64::max);
5184 if radius > 0.0 {
5185 let mut generator = [0.0_f64; COVARIANCE_D1_DIM];
5186 let radius = next_up_ball(radius);
5187 generator[i * order + j] = radius;
5188 generator[j * order + i] = radius;
5189 state.generators.push(generator);
5190 }
5191 }
5192 }
5193 state
5194 }
5195
5196 #[test]
5201 fn zonotope_compaction_retains_correlation_before_axis_roundoff() {
5202 let mut state = Zonotope::<2>::zeroed(2);
5203 state.generators.push([1.0, -1.0]);
5204 for i in 0..ZONOTOPE_GENERATOR_CAP {
5205 state
5206 .generators
5207 .push(if i % 2 == 0 { [0.25, 0.0] } else { [0.0, 0.25] });
5208 }
5209
5210 state.compact();
5211
5212 assert!(state.generators.len() <= ZONOTOPE_GENERATOR_CAP);
5213 assert!(
5214 state
5215 .generators
5216 .iter()
5217 .any(|generator| *generator == [1.0, -1.0]),
5218 "compaction discarded the only signed correlation direction"
5219 );
5220 }
5221
5222 #[test]
5228 fn shared_q_process_noise_injections_accumulate_and_cancel_as_one_generator() {
5229 let q = Ball {
5230 value: 10.0,
5231 lo: 9.0,
5232 hi: 11.0,
5233 };
5234 let noise = ball_process_noise_taylor(Ball::exact(2.0), q, 1);
5235 let g = noise.shared_q[0];
5236 assert!(g > 0.0);
5237
5238 let identity = zonotope_identity_map::<COVARIANCE_D1_DIM>(1);
5239 let mut accumulated = Zonotope::<COVARIANCE_D1_DIM>::zeroed(1);
5240 assert!(accumulated.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5241 assert!(accumulated.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5242 assert_eq!(accumulated.shared_q[0], 2.0 * g);
5243
5244 let mut negative_identity = [[Ball::ZERO; COVARIANCE_D1_DIM]; COVARIANCE_D1_DIM];
5245 negative_identity[0][0] = Ball::exact(-1.0);
5246 let mut cancelled = Zonotope::<COVARIANCE_D1_DIM>::zeroed(1);
5247 assert!(cancelled.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5248 assert!(cancelled.apply_with_shared_q(
5249 &negative_identity,
5250 &noise.constant,
5251 &noise.shared_q,
5252 ));
5253 assert_eq!(cancelled.shared_q[0], 0.0);
5254
5255 let old_independent_radius = 2.0 * g.abs();
5256 assert!(
5257 ball_radius_about_value(cancelled.coordinate(0)) < old_independent_radius * 1.0e-10,
5258 "independent qQ axes would retain radius {old_independent_radius:e}, \
5259 but the shared-q cancellation left {:?}",
5260 cancelled.coordinate(0),
5261 );
5262 }
5263
5264 #[test]
5265 fn centred_riccati_zonotope_contains_an_off_centre_covariance_and_noise() {
5266 let centres = [[4.0, 1.0, 0.3], [1.0, 3.0, 0.2], [0.3, 0.2, 2.0]];
5267 let radii = [[0.2, 0.1, 0.08], [0.1, 0.2, 0.07], [0.08, 0.07, 0.2]];
5268 let mut enclosure = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
5269 for i in 0..MAX_ORDER {
5270 for j in 0..MAX_ORDER {
5271 enclosure[i][j] = Ball {
5272 value: centres[i][j],
5273 lo: centres[i][j] - radii[i][j],
5274 hi: centres[i][j] + radii[i][j],
5275 };
5276 }
5277 }
5278 let mut state = covariance_zonotope_from_symmetric_matrix(&enclosure, MAX_ORDER);
5279 let observation_variance = Ball {
5280 value: 1.2,
5281 lo: 1.1,
5282 hi: 1.3,
5283 };
5284 assert!(covariance_zonotope_measurement_update(
5285 &mut state,
5286 observation_variance,
5287 MAX_ORDER,
5288 ));
5289
5290 let actual = [[4.1, 0.95, 0.35], [0.95, 3.1, 0.15], [0.35, 0.15, 1.9]];
5291 let actual_r = 1.25;
5292 let innovation = actual[0][0] + actual_r;
5293 for i in 0..MAX_ORDER {
5294 for j in 0..MAX_ORDER {
5295 let updated = actual[i][j] - actual[i][0] * actual[0][j] / innovation;
5296 assert!(
5297 state
5298 .coordinate(i * MAX_ORDER + j)
5299 .interval()
5300 .contains(updated),
5301 "updated covariance ({i},{j})={updated} escaped {:?}",
5302 state.coordinate(i * MAX_ORDER + j).interval()
5303 );
5304 }
5305 }
5306 }
5307
5308 #[test]
5316 fn weighted_scan_dgp_2300_search_terminates_in_bounded_evaluations() {
5317 let (x, y, w) = dgp_2300();
5321 std::thread::scope(|scope| {
5335 for order in 1..=MAX_ORDER {
5336 let (x, y, w) = (&x, &y, &w);
5337 scope.spawn(move || {
5338 let (nodes, ssr_within, n_obs, _response_origin) =
5339 pool_nodes(x, y, w, order).expect("pool");
5340 let span = nodes.last().unwrap().x - nodes.first().unwrap().x;
5341 let scale_shift = (2 * order - 1) as f64 * span.ln();
5342 let lo = LOG_LAMBDA_LO + scale_shift;
5343 let hi = LOG_LAMBDA_HI + scale_shift;
5344
5345 let n_nodes = nodes.len();
5346 let evals = std::cell::Cell::new(0u64);
5347 let last_x = std::cell::Cell::new(f64::NAN);
5348 let endpoint_certificates =
5349 RefCell::new(HashMap::<u64, CertifiedCriterionJet>::new());
5350 let budget = 4_096u64;
5351 let result = gam_math::score_opt::maximize_score_1d(
5352 lo,
5353 hi,
5354 f64::EPSILON.sqrt(),
5355 |ll| {
5356 let count = evals.get() + 1;
5357 evals.set(count);
5358 last_x.set(ll);
5359 assert!(
5360 count <= budget,
5361 "order-{order} certified scan search exceeded {budget} criterion \
5362 evaluations (last log-lambda sample {ll:.9}; bracket \
5363 [{lo:.3}, {hi:.3}]) — non-terminating subdivision reproduced"
5364 );
5365 let certificate = certified_concentrated_criterion_jet(
5366 &nodes, ssr_within, n_obs, ll, order,
5367 )?;
5368 endpoint_certificates
5369 .borrow_mut()
5370 .insert(ll.to_bits(), certificate);
5371 Ok(certificate.jet)
5372 },
5373 |a, b| {
5374 let certificates = endpoint_certificates.borrow();
5375 let left = certificates.get(&a.x.to_bits()).copied().ok_or(
5376 SplineScoreProofError::MissingEndpointCertificate {
5377 log_lambda: a.x,
5378 },
5379 )?;
5380 let right = certificates.get(&b.x.to_bits()).copied().ok_or(
5381 SplineScoreProofError::MissingEndpointCertificate {
5382 log_lambda: b.x,
5383 },
5384 )?;
5385 concentrated_criterion_enclosure(
5386 n_nodes, n_obs, a, b, left, right, order,
5387 )
5388 },
5389 );
5390 match result {
5391 Ok(search) => assert!(
5392 search.optimum.x.is_finite(),
5393 "order-{order} search must return a finite optimum"
5394 ),
5395 Err(error) => panic!(
5396 "order-{order} weighted scan search failed after {} evaluations \
5397 (last x {:.9}): {error:?}",
5398 evals.get(),
5399 last_x.get()
5400 ),
5401 }
5402 });
5403 }
5404 });
5405 }
5406
5407 fn dgp_2300() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
5411 let n = 180usize;
5412 let mut state: u64 = 0x2300_2300_2300_2300;
5413 let mut next_unit = move || {
5414 state ^= state << 13;
5415 state ^= state >> 7;
5416 state ^= state << 17;
5417 (state >> 11) as f64 / (1u64 << 53) as f64
5418 };
5419 let mut x = Vec::with_capacity(n);
5420 let mut y = Vec::with_capacity(n);
5421 let mut w = Vec::with_capacity(n);
5422 for i in 0..n {
5423 let xi = -2.0 + 4.0 * (i as f64) / ((n - 1) as f64);
5424 let wi: f64 = if xi < 0.0 { 1.0 } else { 9.0 };
5425 let u1 = next_unit().max(1e-12);
5426 let u2 = next_unit();
5427 let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
5428 x.push(xi);
5429 w.push(wi);
5430 y.push(0.4 + (1.3 * xi).sin() + (0.45 / wi.sqrt()) * z);
5431 }
5432 (x, y, w)
5433 }
5434
5435 #[test]
5446 fn certified_ladder_reaches_endpoint_jets_across_the_search_domain() {
5447 let (x, y, w) = dgp_2300();
5448 let visited = [
5449 -24.0_f64,
5450 -20.0,
5451 -18.0,
5452 -16.6135,
5453 -13.841116916640328,
5455 -10.0,
5456 -6.0,
5457 0.0,
5458 6.0,
5459 ];
5460 for order in 1..=MAX_ORDER {
5461 let (nodes, within, n_obs, _response_origin) =
5462 pool_nodes(&x, &y, &w, order).expect("pool");
5463 for &log_lambda in &visited {
5464 let certificate = certified_concentrated_criterion_jet(
5465 &nodes, within, n_obs, log_lambda, order,
5466 )
5467 .unwrap_or_else(|error| {
5468 panic!(
5469 "order {order}, rho {log_lambda}: repaired certified ladder refused: \
5470 {error:?}"
5471 )
5472 });
5473 assert_eq!(
5474 certificate.curvature_source,
5475 BoundSource::EndpointJet,
5476 "order {order}, rho {log_lambda}: curvature lost its exact endpoint anchor"
5477 );
5478 assert_eq!(
5479 certificate.third_source,
5480 BoundSource::EndpointJet,
5481 "order {order}, rho {log_lambda}: third derivative lost its exact endpoint anchor"
5482 );
5483 }
5484 }
5485 }
5486
5487 #[test]
5503 fn the_certified_jet_contains_the_scalar_jet_and_stays_in_its_closed_form_range() {
5504 let (x, y, w) = dgp_2300();
5505 for order in 1..=MAX_ORDER {
5506 let (nodes, within, n_obs, _response_origin) =
5507 pool_nodes(&x, &y, &w, order).expect("pool");
5508 let proper_modes = (nodes.len() - order) as f64;
5509 let residual_dof = (n_obs - order) as f64;
5510 for &rho in &[
5511 -18.0_f64,
5512 -16.6135,
5513 -13.841116916640328,
5514 -10.0,
5515 -6.0,
5516 0.0,
5517 6.0,
5518 ] {
5519 let Ok(certificate) =
5520 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5521 else {
5522 continue;
5523 };
5524 let scalar = concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5525 .expect("independent scalar recurrence");
5526 assert!(
5527 certificate.value.interval().contains(scalar.0),
5528 "order={order} rho={rho}: scalar value {} escaped {:?}",
5529 scalar.0,
5530 certificate.value
5531 );
5532 assert!(
5533 certificate.derivative.interval().contains(scalar.1),
5534 "order={order} rho={rho}: scalar derivative {} escaped {:?}",
5535 scalar.1,
5536 certificate.derivative
5537 );
5538 let width = certificate.derivative.hi - certificate.derivative.lo;
5539 assert!(
5540 width < proper_modes + residual_dof,
5541 "order={order} rho={rho}: the certified derivative ball is {width:e} \
5542 wide, outside the closed-form range the accumulators are bounded to \
5543 ({:e}); the search cannot sign an interval that wide",
5544 0.5 * (proper_modes + residual_dof)
5545 );
5546 }
5547 }
5548 }
5549
5550 #[test]
5624 fn the_closed_loop_map_contracts_while_its_absolute_value_explodes() {
5625 let (x, y, w) = dgp_2300();
5626 let order = 3;
5627 let log_lambda = -16.6135_f64;
5629 let (nodes, within, n_obs, _response_origin) =
5630 pool_nodes(&x, &y, &w, order).expect("pool");
5631 let q_value =
5632 gam_problem::checked_exp_log_strength(-log_lambda).expect("inverse log strength");
5633 let q = Ball::certified(
5634 q_value,
5635 gam_math::score_opt::certified_exp(-log_lambda).expect("certified exponential"),
5636 );
5637 let mut trace: Vec<BallTraceRecord> = Vec::new();
5638 certified_concentrated_criterion_jet(&nodes, within, n_obs, log_lambda, order)
5639 .expect("the certified jet must exist at the rho this map is measured at");
5640 run_filter_ball_traced(&nodes, q, order, Some(&mut trace)).expect("traced pass");
5641 let mut gains: HashMap<usize, [f64; MAX_ORDER]> = HashMap::new();
5642 let mut predicted: HashMap<usize, Mat2> = HashMap::new();
5643 for (node, name, ball) in &trace {
5644 if let Some(coordinate) = GAIN_NAMES.iter().position(|candidate| candidate == name) {
5645 gains.entry(*node).or_insert([0.0; MAX_ORDER])[coordinate] = ball.value;
5646 }
5647 for (i, row) in P_NEXT_ENTRY_NAMES.iter().enumerate().take(order) {
5648 for (j, entry) in row.iter().enumerate().take(order) {
5649 if entry == name {
5650 predicted
5651 .entry(*node)
5652 .or_insert([[0.0; MAX_ORDER]; MAX_ORDER])[i][j] = ball.value;
5653 }
5654 }
5655 }
5656 }
5657 let max_norm = |matrix: &Mat2| -> f64 {
5658 let mut norm = 0.0_f64;
5659 for row in matrix.iter().take(order) {
5660 for entry in row.iter().take(order) {
5661 norm = norm.max(entry.abs());
5662 }
5663 }
5664 norm
5665 };
5666 let spectral_radius = |matrix: &Mat2| -> Option<f64> {
5670 let mut vector = [1.0_f64; MAX_ORDER];
5671 let mut radius = 0.0_f64;
5672 let mut iterations = 0usize;
5673 while iterations < 500 {
5674 let mut next = [0.0_f64; MAX_ORDER];
5675 for i in 0..order {
5676 for k in 0..order {
5677 next[i] += matrix[i][k] * vector[k];
5678 }
5679 }
5680 let scale = next
5681 .iter()
5682 .take(order)
5683 .fold(0.0_f64, |widest, entry| widest.max(entry.abs()));
5684 if !(scale > 0.0 && scale.is_finite()) {
5685 return None;
5686 }
5687 for i in 0..order {
5688 vector[i] = next[i] / scale;
5689 }
5690 radius = scale;
5691 iterations += 1;
5692 }
5693 Some(radius)
5694 };
5695 let mut signed: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5696 let mut absolute: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5697 for i in 0..order {
5698 signed[i][i] = 1.0;
5699 absolute[i][i] = 1.0;
5700 }
5701 let mut log_lyapunov = 0.0_f64;
5702 let mut worst_step = 0.0_f64;
5703 let mut steps = 0usize;
5704 for t in (order + 1)..(nodes.len() - 1) {
5705 let (Some(gain), Some(before), Some(after)) =
5706 (gains.get(&t), predicted.get(&(t - 1)), predicted.get(&t))
5707 else {
5708 continue;
5709 };
5710 let delta = nodes[t + 1].x - nodes[t].x;
5711 let ball_f = ball_transition(Ball::exact(delta), order);
5712 let mut transition: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5713 let mut update: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5714 for i in 0..order {
5715 update[i][i] = 1.0;
5716 for j in 0..order {
5717 transition[i][j] = ball_f[i][j].value;
5718 }
5719 }
5720 for i in 0..order {
5721 update[i][0] -= gain[i];
5722 }
5723 let closed = mat_mul(&transition, &update, order);
5725 let mut next_signed: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5726 let mut next_absolute: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5727 for i in 0..order {
5728 for j in 0..order {
5729 for k in 0..order {
5730 next_signed[i][j] += closed[i][k] * signed[k][j];
5731 next_absolute[i][j] += closed[i][k].abs() * absolute[k][j];
5732 }
5733 }
5734 }
5735 signed = next_signed;
5736 absolute = next_absolute;
5737 let Ok(inverse_after) = mat_inv(after, order, "lyapunov weight") else {
5739 continue;
5740 };
5741 let congruence = mat_mul(
5742 &mat_mul(&closed, before, order),
5743 &mat_t(&closed, order),
5744 order,
5745 );
5746 let Some(squared) = spectral_radius(&mat_mul(&inverse_after, &congruence, order))
5747 else {
5748 continue;
5749 };
5750 let factor = squared.max(0.0).sqrt();
5751 worst_step = worst_step.max(factor);
5752 log_lyapunov += factor.ln();
5753 steps += 1;
5754 if steps % 20 == 0 {
5755 eprintln!(
5756 "after {steps} steps: ||prod Psi|| = {:.6e}, ||prod |Psi||| = {:.6e}, \
5757 prod ||S_t|| = {:.6e}",
5758 max_norm(&signed),
5759 max_norm(&absolute),
5760 log_lyapunov.exp()
5761 );
5762 }
5763 }
5764 let contracted = max_norm(&signed);
5765 let inflated = max_norm(&absolute);
5766 let lyapunov = log_lyapunov.exp();
5767 eprintln!(
5768 "closed loop over {steps} steps: signed {contracted:.6e}, absolute {inflated:.6e}, \
5769 lyapunov {lyapunov:.6e}; per step signed {:.4}, absolute {:.4}, lyapunov {:.6}, \
5770 worst single step {worst_step:.6}",
5771 contracted.powf(1.0 / steps as f64),
5772 inflated.powf(1.0 / steps as f64),
5773 lyapunov.powf(1.0 / steps as f64)
5774 );
5775 assert!(
5776 contracted < 1.0,
5777 "the closed-loop product does not contract ({contracted:e} over {steps} steps); \
5778 the filter's own stability is the premise of every width argument here"
5779 );
5780 assert!(
5781 inflated > 1.0e10,
5782 "the absolute closed-loop product no longer explodes ({inflated:e} over {steps} \
5783 steps). If that is a repair, the recursion-level enclosures can be tightened \
5784 directly and this test is where the new factor is recorded"
5785 );
5786 assert!(
5787 worst_step <= 1.0 + 1.0e-9,
5788 "the Riccati identity `Psi P Psi^T + G = P_next` with `G >= 0` makes every \
5789 `||S_t||_2` at most one; the largest measured is {worst_step}, so either the \
5790 traced covariance is not the one the recursion produced or the identity is \
5791 being read wrong"
5792 );
5793 assert!(
5794 lyapunov >= contracted,
5795 "the Lyapunov product {lyapunov:e} must bound the signed product {contracted:e} \
5796 it stands in for"
5797 );
5798 }
5799
5800 #[test]
5810 fn centred_riccati_mean_enclosure_stays_below_search_resolution() {
5811 let (x, y, w) = dgp_2300();
5812 let order = 3;
5813 let log_lambda = -16.6135_f64;
5814 let (nodes, within, n_obs, _response_origin) =
5815 pool_nodes(&x, &y, &w, order).expect("pool");
5816 let q_value =
5817 gam_problem::checked_exp_log_strength(-log_lambda).expect("inverse log strength");
5818 let q = Ball::certified(
5819 q_value,
5820 gam_math::score_opt::certified_exp(-log_lambda).expect("certified exponential"),
5821 );
5822 let mut trace: Vec<BallTraceRecord> = Vec::new();
5823 run_filter_ball_traced(&nodes, q, order, Some(&mut trace))
5824 .expect("the repaired filter must certify the former failure point");
5825 let mean: Vec<(usize, Ball)> = trace
5826 .iter()
5827 .filter(|(_, name, _)| *name == "mean_a0")
5828 .map(|(node, _, ball)| (*node, *ball))
5829 .collect();
5830 assert_eq!(
5831 mean.len(),
5832 nodes.len() - order,
5833 "every proper filter node must expose a mean certificate"
5834 );
5835 let resolution = f64::EPSILON.sqrt();
5836 let widest_value = mean
5837 .iter()
5838 .fold(0.0_f64, |widest, (_, ball)| widest.max(ball.value.abs()));
5839 assert!(
5840 widest_value < 1.0e2,
5841 "the filtered mean's VALUE left O(1) at order {order}, rho {log_lambda}: \
5842 {widest_value:e}"
5843 );
5844 for (node, ball) in mean {
5845 assert!(
5846 ball.is_finite(),
5847 "mean enclosure is non-finite at node {node}"
5848 );
5849 let width = ball.hi - ball.lo;
5850 let scaled_resolution = resolution * (1.0 + ball.value.abs());
5851 assert!(
5852 width <= scaled_resolution,
5853 "mean enclosure at node {node} is {width:e} wide, exceeding the \
5854 scale-aware search resolution {scaled_resolution:e}"
5855 );
5856 }
5857 certified_concentrated_criterion_jet(&nodes, within, n_obs, log_lambda, order)
5858 .expect("the criterion consuming the repaired pass must certify");
5859 }
5860
5861 fn concentrated_criterion(
5863 nodes: &[PooledNode],
5864 ssr_within: f64,
5865 n_obs: usize,
5866 log_lambda: f64,
5867 order: usize,
5868 ) -> Result<f64, String> {
5869 Ok(concentrated_criterion_jet(nodes, ssr_within, n_obs, log_lambda, order)?.0)
5870 }
5871 use super::*;
5872
5873 #[test]
5874 fn concentrated_score_jet_matches_test_only_differences() {
5875 let x = [0.0, 0.07, 0.19, 0.41, 0.41, 0.68, 1.0, 1.37];
5876 let y = [0.2, -0.4, 0.8, 0.1, 0.35, -0.2, 0.7, 0.15];
5877 let w = [1.0, 2.0, 0.7, 1.4, 0.9, 3.0, 1.2, 0.8];
5878 for order in 1..=MAX_ORDER {
5879 let (nodes, within, n_obs, _response_origin) =
5880 pool_nodes(&x, &y, &w, order).expect("pooled data");
5881 for &rho in &[-4.0, -0.3, 2.5] {
5882 let (value, d1, d2, d3) =
5883 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5884 .expect("analytic score jet");
5885 let h = 2.0e-4;
5888 let fm = concentrated_criterion(&nodes, within, n_obs, rho - h, order)
5889 .expect("left score");
5890 let fp = concentrated_criterion(&nodes, within, n_obs, rho + h, order)
5891 .expect("right score");
5892 let fm2 = concentrated_criterion(&nodes, within, n_obs, rho - 2.0 * h, order)
5893 .expect("far left score");
5894 let fp2 = concentrated_criterion(&nodes, within, n_obs, rho + 2.0 * h, order)
5895 .expect("far right score");
5896 let d1_fd = (fp - fm) / (2.0 * h);
5897 let d2_fd = (fp - 2.0 * value + fm) / (h * h);
5898 let d3_fd = (fp2 - 2.0 * fp + 2.0 * fm - fm2) / (2.0 * h * h * h);
5899 let left_ball =
5903 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho - h, order)
5904 .expect("left value ball");
5905 let right_ball =
5906 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho + h, order)
5907 .expect("right value ball");
5908 let finite_difference = right_ball
5909 .value
5910 .sub(left_ball.value)
5911 .div_positive(Ball::exact(2.0 * h));
5912 let proper_modes = (nodes.len() - order) as f64;
5913 let residual_dof = (n_obs - order) as f64;
5914 let third_bound = 0.5 * (0.25 * proper_modes + 6.0 * residual_dof);
5915 let truncation = third_bound * h * h / 6.0;
5916 let certified_center =
5917 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5918 .expect("center derivative ball");
5919 assert!(
5920 certified_center.derivative.hi >= finite_difference.lo - truncation
5921 && certified_center.derivative.lo <= finite_difference.hi + truncation,
5922 "order={order} rho={rho}: analytic derivative ball {:?} is disjoint \
5923 from independently value-differenced {:?} ± {truncation:e}",
5924 certified_center.derivative,
5925 finite_difference
5926 );
5927 let d1_scale = 1.0 + d1.abs().max(d1_fd.abs());
5928 let d2_scale = 1.0 + d2.abs().max(d2_fd.abs());
5929 let d3_scale = 1.0 + d3.abs().max(d3_fd.abs());
5930 assert!(
5931 (d1 - d1_fd).abs() <= 2.0e-6 * d1_scale,
5932 "order={order} rho={rho}: analytic d1={d1}, FD={d1_fd}"
5933 );
5934 assert!(
5935 (d2 - d2_fd).abs() <= 2.0e-4 * d2_scale,
5936 "order={order} rho={rho}: analytic d2={d2}, FD={d2_fd}"
5937 );
5938 assert!(
5939 (d3 - d3_fd).abs() <= 5.0e-3 * d3_scale,
5940 "order={order} rho={rho}: analytic d3={d3}, FD={d3_fd}"
5941 );
5942 }
5943 }
5944 }
5945
5946 #[test]
5947 fn directed_score_balls_contain_independent_scalar_jets_across_scales() {
5948 let base_x = [0.0, 0.03, 0.11, 0.27, 0.52, 0.81, 1.17, 1.6];
5949 let y = [2.0e3, -4.0e2, 8.0e2, 1.0e2, 3.5e2, -2.0e2, 7.0e2, 1.5e2];
5950 let w = [1.0e-4, 2.0e4, 0.7, 1.4e3, 9.0e-3, 3.0e2, 1.2, 8.0e-2];
5951 for order in 1..=MAX_ORDER {
5952 for scale in [1.0e-1_f64, 1.0, 1.0e2] {
5953 let x: Vec<f64> = base_x.iter().map(|value| scale * value).collect();
5954 let (nodes, within, n_obs, _response_origin) =
5955 pool_nodes(&x, &y, &w, order).expect("adversarial pooled data");
5956 let rho = (2 * order - 1) as f64 * scale.ln() + 0.35;
5957 let certified =
5958 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5959 .expect("directed score recurrence");
5960 let scalar = concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5961 .expect("independent scalar recurrence");
5962 for (name, ball, reference) in [
5963 ("value", certified.value, scalar.0),
5964 ("derivative", certified.derivative, scalar.1),
5965 ("curvature", certified.curvature, scalar.2),
5966 ("third", certified.third, scalar.3),
5967 ] {
5968 assert!(
5969 ball.interval().contains(reference),
5970 "order={order} scale={scale:e}: scalar {name} {reference} escaped {ball:?}"
5971 );
5972 }
5973
5974 let point_sample = ScoreSample {
5975 x: rho,
5976 value: certified.jet.value,
5977 derivative: certified.jet.derivative,
5978 curvature: certified.jet.curvature,
5979 third: certified.jet.third,
5980 };
5981 let point_enclosure = concentrated_criterion_enclosure(
5982 nodes.len(),
5983 n_obs,
5984 point_sample,
5985 point_sample,
5986 certified,
5987 certified,
5988 order,
5989 )
5990 .expect("degenerate point enclosure");
5991 assert_eq!(
5992 point_enclosure.derivative,
5993 certified.derivative.interval(),
5994 "a zero-width cell must preserve the certified point derivative exactly"
5995 );
5996 assert_eq!(
5997 point_enclosure.curvature,
5998 certified.curvature.interval(),
5999 "a zero-width cell must preserve the certified point curvature exactly"
6000 );
6001 assert_eq!(
6002 point_enclosure.score.value,
6003 certified.value.interval(),
6004 "a zero-width cell must preserve the certified point score exactly"
6005 );
6006
6007 let rho_right = rho + 0.125;
6008 let right =
6009 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho_right, order)
6010 .expect("right endpoint ball");
6011 let enclosure = concentrated_criterion_enclosure(
6012 nodes.len(),
6013 n_obs,
6014 ScoreSample {
6015 x: rho,
6016 value: certified.jet.value,
6017 derivative: certified.jet.derivative,
6018 curvature: certified.jet.curvature,
6019 third: certified.jet.third,
6020 },
6021 ScoreSample {
6022 x: rho_right,
6023 value: right.jet.value,
6024 derivative: right.jet.derivative,
6025 curvature: right.jet.curvature,
6026 third: right.jet.third,
6027 },
6028 certified,
6029 right,
6030 order,
6031 )
6032 .expect("endpoint-anchored enclosure");
6033 for certificate in [certified, right] {
6034 assert!(
6035 enclosure.derivative.lo <= certificate.derivative.lo
6036 && enclosure.derivative.hi >= certificate.derivative.hi,
6037 "exact endpoint derivative escaped the cell enclosure"
6038 );
6039 assert!(
6040 enclosure.curvature.lo <= certificate.curvature.lo
6041 && enclosure.curvature.hi >= certificate.curvature.hi,
6042 "exact endpoint curvature escaped the cell enclosure"
6043 );
6044 assert!(
6045 enclosure.score.value.lo <= certificate.value.lo
6046 && enclosure.score.value.hi >= certificate.value.hi,
6047 "exact endpoint score escaped the cell enclosure"
6048 );
6049 }
6050 }
6051 }
6052 }
6053
6054 #[test]
6061 fn nearest_endpoint_taylor_hull_contains_dense_cell_and_tightens_every_channel() {
6062 let n = 60usize;
6063 let mut x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
6064 x[7] = x[6];
6065 let y: Vec<f64> = x
6066 .iter()
6067 .enumerate()
6068 .map(|(i, &xi)| {
6069 (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
6070 })
6071 .collect();
6072 let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * (i % 3) as f64).collect();
6073 let order = 3usize;
6074 let (nodes, within, n_obs, _response_origin) =
6075 pool_nodes(&x, &y, &w, order).expect("pooled data");
6076 let lo = 13.759_277_343_75;
6077 let hi = 13.760_375_976_562_5;
6078 let left = certified_concentrated_criterion_jet(&nodes, within, n_obs, lo, order)
6079 .expect("left endpoint certificate");
6080 let right = certified_concentrated_criterion_jet(&nodes, within, n_obs, hi, order)
6081 .expect("right endpoint certificate");
6082 let sample = |rho: f64, certificate: CertifiedCriterionJet| ScoreSample {
6083 x: rho,
6084 value: certificate.jet.value,
6085 derivative: certificate.jet.derivative,
6086 curvature: certificate.jet.curvature,
6087 third: certificate.jet.third,
6088 };
6089 let nearest = concentrated_criterion_enclosure(
6090 nodes.len(),
6091 n_obs,
6092 sample(lo, left),
6093 sample(hi, right),
6094 left,
6095 right,
6096 order,
6097 )
6098 .expect("nearest-endpoint enclosure");
6099
6100 for step in 0..=256 {
6101 let rho = lo + (hi - lo) * step as f64 / 256.0;
6102 let (value, derivative, curvature, _) =
6103 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6104 .expect("independent scalar jet");
6105 assert!(
6106 nearest.score.value.contains(value),
6107 "dense score sample at rho={rho:.17} escaped {:?}",
6108 nearest.score.value
6109 );
6110 assert!(
6111 nearest.derivative.contains(derivative),
6112 "dense derivative sample at rho={rho:.17} escaped {:?}",
6113 nearest.derivative
6114 );
6115 assert!(
6116 nearest.curvature.contains(curvature),
6117 "dense curvature sample at rho={rho:.17} escaped {:?}",
6118 nearest.curvature
6119 );
6120 }
6121
6122 let width = Ball::exact(hi).sub(Ball::exact(lo));
6127 let width2 = width.square();
6128 let width3 = width2.mul(width);
6129 let width4 = width2.square();
6130 let fourth_abs_bound = Ball::exact((nodes.len() - order) as f64)
6131 .scale(0.25)
6132 .add(Ball::exact((n_obs - order) as f64).scale(26.0))
6133 .scale(0.5);
6134 let value_remainder = fourth_abs_bound
6135 .mul(width4)
6136 .div_positive(Ball::exact(24.0))
6137 .hi;
6138 let derivative_remainder = fourth_abs_bound
6139 .mul(width3)
6140 .div_positive(Ball::exact(6.0))
6141 .hi;
6142 let curvature_remainder = fourth_abs_bound.mul(width2).scale(0.5).hi;
6143 let full_cell_from_endpoint =
6144 |certificate: CertifiedCriterionJet, displacement: ClosedInterval| {
6145 let d = Ball::certified(0.0, displacement);
6146 let d2 = d.square();
6147 let d3 = d2.mul(d);
6148 let value = certificate
6149 .value
6150 .add(certificate.derivative.mul(d))
6151 .add(certificate.curvature.mul(d2).scale(0.5))
6152 .add(certificate.third.mul(d3).div_positive(Ball::exact(6.0)))
6153 .interval()
6154 .add(ClosedInterval::new(-value_remainder, value_remainder));
6155 let derivative = certificate
6156 .derivative
6157 .add(certificate.curvature.mul(d))
6158 .add(certificate.third.mul(d2).scale(0.5))
6159 .interval()
6160 .add(ClosedInterval::new(
6161 -derivative_remainder,
6162 derivative_remainder,
6163 ));
6164 let curvature = certificate
6165 .curvature
6166 .add(certificate.third.mul(d))
6167 .interval()
6168 .add(ClosedInterval::new(
6169 -curvature_remainder,
6170 curvature_remainder,
6171 ));
6172 (value, derivative, curvature)
6173 };
6174 let old_left = full_cell_from_endpoint(left, ClosedInterval::new(0.0, width.hi));
6175 let old_right = full_cell_from_endpoint(right, ClosedInterval::new(-width.hi, 0.0));
6176 let old_value = ClosedInterval::new(
6177 old_left.0.lo.min(old_right.0.lo),
6178 old_left.0.hi.max(old_right.0.hi),
6179 );
6180 let old_derivative = ClosedInterval::new(
6181 old_left.1.lo.min(old_right.1.lo),
6182 old_left.1.hi.max(old_right.1.hi),
6183 );
6184 let old_curvature = ClosedInterval::new(
6185 old_left.2.lo.min(old_right.2.lo),
6186 old_left.2.hi.max(old_right.2.hi),
6187 );
6188 for (name, tightened, full_width) in [
6189 ("score", nearest.score.value, old_value),
6190 ("derivative", nearest.derivative, old_derivative),
6191 ("curvature", nearest.curvature, old_curvature),
6192 ] {
6193 assert!(
6194 tightened.hi - tightened.lo < full_width.hi - full_width.lo,
6195 "nearest-endpoint {name} enclosure {tightened:?} was not strictly \
6196 narrower than full-width theorem {full_width:?}"
6197 );
6198 }
6199 assert!(
6200 nearest.derivative.hi < 0.0,
6201 "the corrected theorem must certify the live #2614 cell's negative slope: {:?}",
6202 nearest.derivative
6203 );
6204
6205 let shifted_lo = 16.126_831_054_687_5;
6212 let shifted_hi = 16.127_929_687_5;
6213 assert_eq!(
6214 shifted_hi - shifted_lo,
6215 hi - lo,
6216 "the old-theorem comparison below shares the measured dyadic width"
6217 );
6218 let shifted_left =
6219 certified_concentrated_criterion_jet(&nodes, within, n_obs, shifted_lo, order)
6220 .expect("shifted left endpoint certificate");
6221 let shifted_right =
6222 certified_concentrated_criterion_jet(&nodes, within, n_obs, shifted_hi, order)
6223 .expect("shifted right endpoint certificate");
6224 let shifted = concentrated_criterion_enclosure(
6225 nodes.len(),
6226 n_obs,
6227 sample(shifted_lo, shifted_left),
6228 sample(shifted_hi, shifted_right),
6229 shifted_left,
6230 shifted_right,
6231 order,
6232 )
6233 .expect("shifted endpoint-third enclosure");
6234 for step in 0..=256 {
6235 let rho = shifted_lo + (shifted_hi - shifted_lo) * step as f64 / 256.0;
6236 let (value, derivative, curvature, _) =
6237 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6238 .expect("shifted independent scalar jet");
6239 assert!(
6240 shifted.score.value.contains(value),
6241 "shifted dense score at rho={rho:.17} escaped {:?}",
6242 shifted.score.value
6243 );
6244 assert!(
6245 shifted.derivative.contains(derivative),
6246 "shifted dense derivative at rho={rho:.17} escaped {:?}",
6247 shifted.derivative
6248 );
6249 assert!(
6250 shifted.curvature.contains(curvature),
6251 "shifted dense curvature at rho={rho:.17} escaped {:?}",
6252 shifted.curvature
6253 );
6254 }
6255 let shifted_old_left =
6256 full_cell_from_endpoint(shifted_left, ClosedInterval::new(0.0, width.hi));
6257 let shifted_old_right =
6258 full_cell_from_endpoint(shifted_right, ClosedInterval::new(-width.hi, 0.0));
6259 for (name, tightened, old_left, old_right) in [
6260 (
6261 "score",
6262 shifted.score.value,
6263 shifted_old_left.0,
6264 shifted_old_right.0,
6265 ),
6266 (
6267 "derivative",
6268 shifted.derivative,
6269 shifted_old_left.1,
6270 shifted_old_right.1,
6271 ),
6272 (
6273 "curvature",
6274 shifted.curvature,
6275 shifted_old_left.2,
6276 shifted_old_right.2,
6277 ),
6278 ] {
6279 let full_width =
6280 ClosedInterval::new(old_left.lo.min(old_right.lo), old_left.hi.max(old_right.hi));
6281 assert!(
6282 tightened.hi - tightened.lo < full_width.hi - full_width.lo,
6283 "endpoint-third {name} enclosure {tightened:?} was not strictly \
6284 narrower than the full-width L4 theorem {full_width:?}"
6285 );
6286 }
6287 assert!(
6288 shifted.derivative.hi < 0.0,
6289 "the endpoint-third theorem must certify the shifted #2614 cell's \
6290 negative slope: {:?}",
6291 shifted.derivative
6292 );
6293 }
6294
6295 #[test]
6296 fn spline_consumer_preserves_a_valid_resolution_flat_optimum_category() {
6297 let optimum = ScoreSample {
6298 x: -0.25,
6299 value: 3.0,
6300 derivative: 0.0,
6301 curvature: 0.0,
6302 third: 0.0,
6303 };
6304 let bracket = ClosedInterval::new(-0.5, 0.0);
6305 let max_score_gap = 0.125;
6306 let score_resolution = 0.25;
6307 let search = ScoreSearchResult {
6308 optimum,
6309 location: ScoreOptimumLocation::ResolutionFlat(0),
6310 lower_boundary: ScoreSample { x: -1.0, ..optimum },
6311 upper_boundary: ScoreSample { x: 1.0, ..optimum },
6312 stationary_points: Vec::new(),
6313 resolution_flat_regions: vec![gam_math::score_opt::ResolutionFlatRegion {
6314 sample: optimum,
6315 bracket,
6316 score: ClosedInterval::new(2.875, 3.0),
6317 max_score_gap,
6318 score_resolution,
6319 }],
6320 dominated_regions: Vec::new(),
6321 value_certificate: gam_math::score_opt::GlobalScoreCertificate {
6322 selected: ClosedInterval::point(3.0),
6323 maximum: ClosedInterval::new(3.0, 3.125),
6324 maximum_excess: max_score_gap,
6325 comparison_resolution: score_resolution,
6326 },
6327 };
6328 assert_eq!(
6329 spline_optimum_proof(&search).expect("valid resolution-flat proof"),
6330 SplineOptimumProof::ResolutionFlat {
6331 bracket,
6332 max_score_gap,
6333 score_resolution,
6334 },
6335 "the spline consumer must preserve the producer's successful typed category"
6336 );
6337
6338 let mut invalid = search;
6339 invalid.resolution_flat_regions[0].max_score_gap =
6340 invalid.resolution_flat_regions[0].score_resolution + f64::EPSILON;
6341 assert!(
6342 matches!(
6343 spline_optimum_proof(&invalid),
6344 Err(SplineScoreProofError::Search(_))
6345 ),
6346 "a malformed producer certificate must still fail instead of being accepted"
6347 );
6348 }
6349
6350 #[test]
6351 fn spline_consumer_retains_the_producers_stationary_curvature_proof() {
6352 let optimum = ScoreSample {
6353 x: -9.084_292_923_99,
6354 value: 3.0,
6355 derivative: 0.0,
6356 curvature: -1.0,
6357 third: 0.0,
6358 };
6359 let bracket = ClosedInterval::new(-9.084_292_924_175_005, -9.084_292_923_812_374);
6360 let producer_curvature = ClosedInterval::new(-6.4, -0.2);
6361 let point_score = ScoreValueEnclosure {
6362 value: ClosedInterval::new(2.999, 3.001),
6363 evaluation_error: 0.001,
6364 };
6365 let search = ScoreSearchResult {
6366 optimum,
6367 location: ScoreOptimumLocation::Stationary(0),
6368 lower_boundary: ScoreSample {
6369 x: -10.0,
6370 ..optimum
6371 },
6372 upper_boundary: ScoreSample { x: -8.0, ..optimum },
6373 stationary_points: vec![gam_math::score_opt::StationaryPoint {
6374 sample: optimum,
6375 bracket,
6376 score: point_score,
6377 curvature: producer_curvature,
6378 }],
6379 resolution_flat_regions: Vec::new(),
6380 dominated_regions: Vec::new(),
6381 value_certificate: gam_math::score_opt::GlobalScoreCertificate {
6382 selected: point_score.value,
6383 maximum: point_score.value,
6384 maximum_excess: 0.0,
6385 comparison_resolution: 0.002,
6386 },
6387 };
6388 let SplineOptimumProof::Kkt { bracket: got, kind } =
6389 spline_optimum_proof(&search).expect("valid stationary proof")
6390 else {
6391 panic!("stationary producer category was not preserved");
6392 };
6393 assert_eq!(got, bracket);
6394 assert_eq!(
6395 kind,
6396 SplineKktKind::Stationary {
6397 curvature: producer_curvature,
6398 }
6399 );
6400
6401 let local_enclosure = DerivativeEnclosure {
6402 score: point_score,
6403 derivative: ClosedInterval::new(-1.2e-9, 1.2e-9),
6404 curvature: ClosedInterval::new(-6.39, 0.0064),
6407 };
6408 let (holds, consumed_curvature) = spline_kkt_holds(kind, local_enclosure);
6409 assert!(holds, "the final derivative still contains the unique root");
6410 assert_eq!(consumed_curvature, producer_curvature);
6411 }
6412
6413 #[test]
6414 fn derivative_secant_recovers_weighted_order3_root_curvature_sign() {
6415 let (x, y, w) = dgp_2300();
6416 let order = 3usize;
6417 let (nodes, within, n_obs, _response_origin) =
6418 pool_nodes(&x, &y, &w, order).expect("weighted pool");
6419 let lo = -2.337_075_252_506_015;
6421 let hi = -2.337_040_920_230_624;
6422 let left = certified_concentrated_criterion_jet(&nodes, within, n_obs, lo, order)
6423 .expect("weighted left endpoint");
6424 let right = certified_concentrated_criterion_jet(&nodes, within, n_obs, hi, order)
6425 .expect("weighted right endpoint");
6426 assert!(
6427 left.curvature.interval().contains_zero() && right.curvature.interval().contains_zero(),
6428 "the oracle must exercise the loose direct covariance-d2 path"
6429 );
6430 let sample = |rho: f64, certificate: CertifiedCriterionJet| ScoreSample {
6431 x: rho,
6432 value: certificate.jet.value,
6433 derivative: certificate.jet.derivative,
6434 curvature: certificate.jet.curvature,
6435 third: certificate.jet.third,
6436 };
6437 let enclosure = concentrated_criterion_enclosure(
6438 nodes.len(),
6439 n_obs,
6440 sample(lo, left),
6441 sample(hi, right),
6442 left,
6443 right,
6444 order,
6445 )
6446 .expect("secant curvature enclosure");
6447 assert!(
6448 enclosure.curvature.hi < 0.0,
6449 "the derivative secant must recover strict concavity: {:?}",
6450 enclosure.curvature
6451 );
6452 assert!(
6453 enclosure.derivative.lo > 0.0,
6454 "integrating the secant curvature from both endpoints must preserve \
6455 the live cell's positive slope: {:?}",
6456 enclosure.derivative
6457 );
6458 for step in 0..=256 {
6459 let rho = lo + (hi - lo) * step as f64 / 256.0;
6460 let (_, derivative, curvature, _) =
6461 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6462 .expect("independent weighted scalar jet");
6463 assert!(
6464 enclosure.derivative.contains(derivative),
6465 "weighted scalar derivative {derivative} at rho={rho:.17} escaped {:?}",
6466 enclosure.derivative
6467 );
6468 assert!(
6469 enclosure.curvature.contains(curvature),
6470 "weighted scalar curvature {curvature} at rho={rho:.17} escaped {:?}",
6471 enclosure.curvature
6472 );
6473 }
6474 }
6475
6476 #[test]
6477 fn score_proof_refuses_exactly_when_diffuse_innovation_ball_contains_zero() {
6478 assert_eq!(
6479 Ball::ZERO.square(),
6480 Ball::ZERO,
6481 "structural zero must survive squaring exactly"
6482 );
6483 assert_eq!(
6484 Ball::ONE.square(),
6485 Ball::ONE,
6486 "the exact unit covariance must not acquire artificial width"
6487 );
6488 let tiny = f64::from_bits(1);
6489 let nodes = [
6490 PooledNode {
6491 x: 0.0,
6492 y: 0.0,
6493 w: 1.0,
6494 },
6495 PooledNode {
6496 x: tiny,
6497 y: 1.0,
6498 w: 1.0,
6499 },
6500 PooledNode {
6501 x: 1.0,
6502 y: -1.0,
6503 w: 1.0,
6504 },
6505 ];
6506 let error = run_filter_ball(&nodes, Ball::ONE, 2)
6507 .expect_err("an underflow-wide diffuse innovation cannot be divided soundly");
6508 assert!(matches!(
6509 error,
6510 SplineScoreProofError::InnovationContainsZero {
6511 node: 1,
6512 kind: SplineInnovationKind::Diffuse,
6513 ..
6514 }
6515 ));
6516 }
6517
6518 fn hand_built_state(order: usize) -> SplineScanState {
6524 let knots = vec![0.0, 0.25, 0.6, 1.0, 1.4];
6525 let knot_count = knots.len();
6526 let tri = order * (order + 1) / 2;
6527 SplineScanState {
6528 order,
6529 state: (0..order * knot_count)
6530 .map(|i| 0.1 + 0.07 * i as f64)
6531 .collect(),
6532 cov: (0..tri * knot_count)
6534 .map(|i| {
6535 if i % tri == 0 {
6536 0.5 + 0.01 * i as f64
6537 } else {
6538 0.02
6539 }
6540 })
6541 .collect(),
6542 gain: (0..order * order * knot_count)
6543 .map(|i| 0.03 * ((i % 5) as f64))
6544 .collect(),
6545 node_weight: (0..knot_count).map(|i| 1.0 + 0.25 * i as f64).collect(),
6546 knots,
6547 log_lambda: 0.35,
6548 sigma2: 1.75,
6549 restricted_loglik: -12.5,
6550 log_likelihood: -8.25,
6551 training_sample_size: std::num::NonZeroU64::new(64).expect("64 is nonzero"),
6552 data_sse: 3.25,
6553 }
6554 }
6555
6556 #[test]
6574 fn persistence_seam_round_trips_without_the_optimizer_2614() {
6575 for order in 1..=MAX_ORDER {
6576 let built = hand_built_state(order);
6577 let fit = SplineScanFit::from_state(&built).expect("hand-built state must restore");
6578 let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
6579 let parsed: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
6580 let restored = SplineScanFit::from_state(&parsed).expect("restore fit");
6581
6582 assert_eq!(fit.order, restored.order, "order drifted (m={order})");
6583 assert_eq!(fit.knots, restored.knots, "knots drifted (m={order})");
6584 assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
6585 assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
6586 assert_eq!(
6587 fit.log_likelihood.to_bits(),
6588 restored.log_likelihood.to_bits()
6589 );
6590 assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
6591 assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
6592 assert_eq!(fit.training_sample_size(), restored.training_sample_size());
6593
6594 for &xq in &[-0.3, 0.0, 0.13, 0.6, 1.0, 1.4, 1.9] {
6596 let (m0, v0) = fit.predict(xq).expect("predict original");
6597 let (m1, v1) = restored.predict(xq).expect("predict restored");
6598 assert_eq!(
6599 m0.to_bits(),
6600 m1.to_bits(),
6601 "mean drift at x={xq} (m={order})"
6602 );
6603 assert_eq!(
6604 v0.to_bits(),
6605 v1.to_bits(),
6606 "variance drift at x={xq} (m={order})"
6607 );
6608 }
6609
6610 let mut bad = fit.to_state();
6612 bad.cov.truncate(bad.cov.len() - 1);
6613 SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
6614 let mut bad = fit.to_state();
6615 bad.sigma2 = -1.0;
6616 SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
6617 let mut bad = fit.to_state();
6618 bad.knots[2] = bad.knots[1];
6619 SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
6620 }
6621 }
6622
6623 #[test]
6631 fn fixed_scan_evaluator_uses_a_response_translation_free_chart_2790() {
6632 let x = [0.0, 0.0, 0.25, 0.5, 0.5, 1.0, 1.5, 2.0];
6633 let y: [f64; 8] = [0.0, 0.25, -0.5, 0.75, 1.0, -0.25, 0.5, 0.125];
6634 let shift = 1024.0_f64;
6635 let shifted_y = y.map(|value| value + shift);
6636 let w = [0.3, 1.7, 2.25, 0.6, 1.4, 3.1, 0.75, 2.6];
6637
6638 for order in 1..=MAX_ORDER {
6639 let plain = fit_spline_scan_at(&x, &y, &w, -1.25, None, order)
6640 .expect("plain fixed-lambda scan");
6641 let shifted = fit_spline_scan_at(&x, &shifted_y, &w, -1.25, None, order)
6642 .expect("shifted fixed-lambda scan");
6643 assert!(
6644 plain.knots.len() < x.len(),
6645 "fixture must exercise tied-row pooling"
6646 );
6647 assert_eq!(plain.knots, shifted.knots);
6648 assert_eq!(plain.node_weight, shifted.node_weight);
6649
6650 for (label, left, right) in [
6651 ("sigma2", plain.sigma2, shifted.sigma2),
6652 (
6653 "restricted log likelihood",
6654 plain.restricted_loglik,
6655 shifted.restricted_loglik,
6656 ),
6657 ("Gaussian log likelihood", plain.log_likelihood, shifted.log_likelihood),
6658 ("data SSE", plain.data_sse, shifted.data_sse),
6659 ("EDF", plain.edf(), shifted.edf()),
6660 ] {
6661 assert_eq!(
6662 left.to_bits(),
6663 right.to_bits(),
6664 "order {order}: {label} moved under a constant response shift"
6665 );
6666 }
6667 for node in 0..plain.knots.len() {
6668 assert_eq!(
6669 shifted.mean[node].to_bits(),
6670 (plain.mean[node] + shift).to_bits(),
6671 "order {order}: fitted level at node {node} is not exactly equivariant"
6672 );
6673 assert_eq!(
6674 shifted.var[node].to_bits(),
6675 plain.var[node].to_bits(),
6676 "order {order}: posterior variance moved at node {node}"
6677 );
6678 }
6679 assert_eq!(shifted.deriv, plain.deriv);
6680 }
6681 }
6682
6683 #[test]
6690 fn deviance_is_data_sse_not_penalized_quadratic() {
6691 let x = [0.0, 1.0];
6692 let y = [0.0, 1.0];
6693 let w = [1.0, 1.0];
6694 let fit = fit_spline_scan_at(&x, &y, &w, 0.0, None, 1).expect("order-1 fit");
6695 let manual: f64 = x
6697 .iter()
6698 .zip(&y)
6699 .zip(&w)
6700 .map(|((&xi, &yi), &wi)| {
6701 let (m, _) = fit.predict(xi).expect("predict at knot");
6702 wi * (yi - m) * (yi - m)
6703 })
6704 .sum();
6705 assert!(
6706 (fit.deviance() - manual).abs() <= 1e-12 * manual.max(1e-300),
6707 "deviance {} != recomputed data SSE {manual}",
6708 fit.deviance()
6709 );
6710 assert!(
6711 (fit.deviance() - 2.0 / 9.0).abs() < 1e-10,
6712 "deviance {} != 2/9",
6713 fit.deviance()
6714 );
6715 let reml_quadratic = fit.sigma2 * (fit.training_sample_size() as f64 - fit.order as f64);
6717 assert!(fit.deviance() < reml_quadratic);
6718 }
6719
6720 #[test]
6721 fn full_gaussian_log_likelihood_keeps_raw_weight_normalizer_and_round_trips() {
6722 let x = [0.0, 0.0, 1.0, 2.0];
6725 let y = [0.2, -0.1, 0.8, 1.4];
6726 let w = [0.5, 2.0, 1.5, 3.0];
6727 let sigma2 = 1.7;
6728 let fit = fit_spline_scan_at(&x, &y, &w, 0.2, Some(sigma2), 1)
6729 .expect("weighted order-1 fit");
6730
6731 let sum_log_weights = w.iter().map(|weight| weight.ln()).sum::<f64>();
6732 let expected = -0.5
6733 * (fit.deviance() / sigma2
6734 + x.len() as f64 * (std::f64::consts::TAU.ln() + sigma2.ln())
6735 - sum_log_weights);
6736 assert!(
6737 (fit.log_likelihood - expected).abs() <= 1e-12 * expected.abs().max(1.0)
6738 );
6739
6740 let pooled_log_weights = fit
6741 .node_weight
6742 .iter()
6743 .map(|weight| weight.ln())
6744 .sum::<f64>();
6745 let pooled_wrong = -0.5
6746 * (fit.deviance() / sigma2
6747 + x.len() as f64 * (std::f64::consts::TAU.ln() + sigma2.ln())
6748 - pooled_log_weights);
6749 assert!(
6750 (fit.log_likelihood - pooled_wrong).abs() > 1e-3,
6751 "raw-row weight normalizer must not collapse to pooled weights"
6752 );
6753
6754 let restored = SplineScanFit::from_state(&fit.to_state()).expect("restore fit");
6755 assert_eq!(
6756 fit.log_likelihood.to_bits(),
6757 restored.log_likelihood.to_bits()
6758 );
6759
6760 let mut incomplete = serde_json::to_value(fit.to_state()).expect("serialize state");
6761 incomplete
6762 .as_object_mut()
6763 .expect("state serializes as an object")
6764 .remove("log_likelihood");
6765 let error = serde_json::from_value::<SplineScanState>(incomplete)
6766 .expect_err("log_likelihood is a required wire field");
6767 assert!(error.to_string().contains("missing field `log_likelihood`"));
6768 }
6769}