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 training_sample_size: std::num::NonZeroUsize,
3408 pub data_sse: f64,
3414 smoothed_state: Vec<Vec2>,
3416 smoothed_cov: Vec<Mat2>,
3418 rts_gain: Vec<Mat2>,
3420 q: f64,
3422 node_weight: Vec<f64>,
3424}
3425
3426fn pool_nodes(
3429 x: &[f64],
3430 y: &[f64],
3431 w: &[f64],
3432 order: usize,
3433) -> Result<(Vec<PooledNode>, f64, usize), String> {
3434 let n = x.len();
3435 if y.len() != n || w.len() != n {
3436 return Err(format!(
3437 "spline scan: length mismatch x={n}, y={}, w={}",
3438 y.len(),
3439 w.len()
3440 ));
3441 }
3442 for i in 0..n {
3443 if !(x[i].is_finite() && y[i].is_finite() && w[i].is_finite() && w[i] > 0.0) {
3444 return Err(format!(
3445 "spline scan: non-finite or non-positive input at row {i} (x={}, y={}, w={})",
3446 x[i], y[i], w[i]
3447 ));
3448 }
3449 }
3450 let mut perm: Vec<usize> = (0..n).collect();
3451 perm.sort_by(|&i, &j| x[i].total_cmp(&x[j]));
3452 let mut nodes: Vec<PooledNode> = Vec::new();
3453 for &i in &perm {
3454 match nodes.last_mut() {
3455 Some(last) if last.x == x[i] => {
3456 let w_new = last.w + w[i];
3457 last.y = (last.y * last.w + y[i] * w[i]) / w_new;
3458 last.w = w_new;
3459 }
3460 _ => nodes.push(PooledNode {
3461 x: x[i],
3462 y: y[i],
3463 w: w[i],
3464 }),
3465 }
3466 }
3467 if nodes.len() < order + 1 {
3469 return Err(format!(
3470 "spline scan: order {order} needs at least {} distinct abscissae, got {}",
3471 order + 1,
3472 nodes.len()
3473 ));
3474 }
3475 let mut ssr_within = 0.0;
3477 let mut k = 0usize;
3478 for &i in &perm {
3479 while nodes[k].x != x[i] {
3480 k += 1;
3481 }
3482 let d = y[i] - nodes[k].y;
3483 ssr_within += w[i] * d * d;
3484 }
3485 Ok((nodes, ssr_within, n))
3486}
3487
3488fn concentrated_criterion_jet(
3495 nodes: &[PooledNode],
3496 ssr_within: f64,
3497 n_obs: usize,
3498 log_lambda: f64,
3499 order: usize,
3500) -> Result<(f64, f64, f64, f64), String> {
3501 let q = gam_problem::checked_exp_log_strength(-log_lambda)
3502 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
3503 let pass = run_filter::<false>(nodes, q, order)?;
3504 let dof = (n_obs - order) as f64;
3507 let rss = pass.sum_v2_over_f + ssr_within;
3508 if rss <= 0.0 {
3509 return Err("spline scan: degenerate zero residual sum".to_string());
3510 }
3511 let sigma2 = rss / dof;
3512 if pass.n_proper != nodes.len() - order {
3513 return Err(format!(
3514 "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
3515 nodes.len() - order,
3516 pass.n_proper
3517 ));
3518 }
3519 let rss_d1 = pass.sum_v2_over_f_d1;
3520 let rss_d2 = pass.sum_v2_over_f_d2;
3521 let rss_d3 = pass.sum_v2_over_f_d3;
3522 let rss_log_d1 = rss_d1 / rss;
3523 let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
3524 let rss_log_d3 = rss_d3 / rss - 3.0 * (rss_d2 / rss) * rss_log_d1
3525 + 2.0 * rss_log_d1 * rss_log_d1 * rss_log_d1;
3526 Ok((
3527 -0.5 * (pass.sum_log_f + dof * sigma2.ln()),
3528 -0.5 * (pass.sum_log_f_d1 + dof * rss_log_d1),
3529 -0.5 * (pass.sum_log_f_d2 + dof * rss_log_d2),
3530 -0.5 * (pass.sum_log_f_d3 + dof * rss_log_d3),
3531 ))
3532}
3533
3534#[derive(Clone, Copy, Debug)]
3535struct CertifiedCriterionJet {
3536 jet: ScoreJet,
3537 value: Ball,
3538 derivative: Ball,
3539 curvature: Ball,
3540 third: Ball,
3541 curvature_source: BoundSource,
3545 third_source: BoundSource,
3546}
3547
3548impl CertifiedCriterionJet {
3549 fn weakened_anchor(self) -> Option<(BoundSource, BoundSource)> {
3562 if matches!(
3563 (self.curvature_source, self.third_source),
3564 (BoundSource::EndpointJet, BoundSource::EndpointJet)
3565 ) {
3566 None
3567 } else {
3568 Some((self.curvature_source, self.third_source))
3569 }
3570 }
3571}
3572
3573#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3575enum BoundSource {
3576 EndpointJet,
3578 AnalyticGlobalBound,
3592}
3593
3594fn curvature_global_bound(proper_modes: f64, residual_dof: f64) -> f64 {
3598 0.5 * (0.25 * proper_modes + 2.0 * residual_dof)
3599}
3600
3601fn third_derivative_global_bound(proper_modes: f64, residual_dof: f64) -> f64 {
3602 0.5 * (0.25 * proper_modes + 6.0 * residual_dof)
3603}
3604
3605fn fifth_derivative_global_bound(proper_modes: Ball, residual_dof: Ball) -> Ball {
3627 proper_modes
3628 .scale(18.75)
3629 .add(residual_dof.scale(690.0))
3630 .scale(0.5)
3631}
3632
3633fn intersect_with_global_bound(ball: Ball, bound: f64) -> (Ball, BoundSource) {
3647 if ball.is_finite() {
3648 let lo = ball.lo.max(-bound);
3649 let hi = ball.hi.min(bound);
3650 if lo <= hi {
3651 return (
3652 Ball {
3653 value: ball.value.clamp(lo, hi),
3654 lo,
3655 hi,
3656 },
3657 BoundSource::EndpointJet,
3658 );
3659 }
3660 }
3661 (
3662 Ball {
3663 value: ball.value.clamp(-bound, bound),
3664 lo: -bound,
3665 hi: bound,
3666 },
3667 BoundSource::AnalyticGlobalBound,
3668 )
3669}
3670
3671fn certified_concentrated_criterion_jet(
3674 nodes: &[PooledNode],
3675 ssr_within: f64,
3676 n_obs: usize,
3677 log_lambda: f64,
3678 order: usize,
3679) -> Result<CertifiedCriterionJet, SplineScoreProofError> {
3680 let q_value = gam_problem::checked_exp_log_strength(-log_lambda).map_err(|error| {
3681 SplineScoreProofError::InvalidInput(format!("spline scan inverse log strength: {error}"))
3682 })?;
3683 let q_enclosure = gam_math::score_opt::certified_exp(-log_lambda).ok_or(
3684 SplineScoreProofError::InvalidArithmetic {
3685 context: "inverse log-strength exponential",
3686 },
3687 )?;
3688 let q = Ball::certified(q_value, q_enclosure);
3689 let pass = run_filter_ball(nodes, q, order)?;
3690 if pass.n_proper != nodes.len() - order {
3691 return Err(SplineScoreProofError::InvalidInput(format!(
3692 "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
3693 nodes.len() - order,
3694 pass.n_proper
3695 )));
3696 }
3697
3698 let dof = Ball::exact((n_obs - order) as f64);
3699 let rss = pass.sum_v2_over_f.add(Ball::exact(ssr_within));
3700 if !(rss.lo > 0.0) {
3701 return Err(SplineScoreProofError::NonPositiveProfileResidual {
3702 enclosure: rss.interval(),
3703 });
3704 }
3705 let sigma2 = rss.div_positive(dof);
3706 let rss_d1 = pass.sum_v2_over_f_d1;
3707 let rss_d2 = pass.sum_v2_over_f_d2;
3708 let rss_d3 = pass.sum_v2_over_f_d3;
3709 let mut rss_log_d1 = rss_d1.div_positive(rss);
3710 intersect_with_exact_range(&mut rss_log_d1, 0.0, 1.0);
3719 let rss_log_d2 = rss_d2.div_positive(rss).sub(rss_log_d1.square());
3720 let rss_log_d3 = rss_d3
3721 .div_positive(rss)
3722 .sub(rss_d2.div_positive(rss).mul(rss_log_d1).scale(3.0))
3723 .add(rss_log_d1.square().mul(rss_log_d1).scale(2.0));
3724 let value = pass
3725 .sum_log_f
3726 .add(dof.mul(sigma2.ln_positive()))
3727 .scale(-0.5);
3728 let derivative = pass.sum_log_f_d1.add(dof.mul(rss_log_d1)).scale(-0.5);
3729 let curvature = pass.sum_log_f_d2.add(dof.mul(rss_log_d2)).scale(-0.5);
3730 let third = pass.sum_log_f_d3.add(dof.mul(rss_log_d3)).scale(-0.5);
3731 if [value, derivative]
3732 .into_iter()
3733 .any(|ball| !ball.is_finite())
3734 {
3735 return Err(SplineScoreProofError::InvalidArithmetic {
3736 context: "concentrated criterion",
3737 });
3738 }
3739 let proper_modes = (nodes.len() - order) as f64;
3747 let residual_dof = (n_obs - order) as f64;
3748 let (curvature, curvature_source) = intersect_with_global_bound(
3749 curvature,
3750 curvature_global_bound(proper_modes, residual_dof),
3751 );
3752 let (third, third_source) = intersect_with_global_bound(
3753 third,
3754 third_derivative_global_bound(proper_modes, residual_dof),
3755 );
3756 Ok(CertifiedCriterionJet {
3757 jet: ScoreJet {
3758 value: value.value,
3759 derivative: derivative.value,
3760 curvature: curvature.value,
3761 third: third.value,
3762 },
3763 value,
3764 derivative,
3765 curvature,
3766 third,
3767 curvature_source,
3768 third_source,
3769 })
3770}
3771
3772fn concentrated_criterion_enclosure(
3841 n_nodes: usize,
3842 n_obs: usize,
3843 left: ScoreSample,
3844 right: ScoreSample,
3845 left_certificate: CertifiedCriterionJet,
3846 right_certificate: CertifiedCriterionJet,
3847 order: usize,
3848) -> Result<DerivativeEnclosure, SplineScoreProofError> {
3849 let (lo, hi) = (left.x, right.x);
3850 if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
3851 return Err(SplineScoreProofError::InvalidInput(format!(
3852 "spline scan: invalid score-enclosure interval [{lo}, {hi}]"
3853 )));
3854 }
3855 if lo == hi {
3856 return Ok(DerivativeEnclosure {
3857 score: ScoreValueEnclosure {
3858 value: ClosedInterval::new(
3859 left_certificate.value.lo.min(right_certificate.value.lo),
3860 left_certificate.value.hi.max(right_certificate.value.hi),
3861 ),
3862 evaluation_error: left_certificate
3863 .value
3864 .forward_error()
3865 .max(right_certificate.value.forward_error()),
3866 },
3867 derivative: ClosedInterval::new(
3868 left_certificate
3869 .derivative
3870 .lo
3871 .min(right_certificate.derivative.lo),
3872 left_certificate
3873 .derivative
3874 .hi
3875 .max(right_certificate.derivative.hi),
3876 ),
3877 curvature: ClosedInterval::new(
3878 left_certificate
3879 .curvature
3880 .lo
3881 .min(right_certificate.curvature.lo),
3882 left_certificate
3883 .curvature
3884 .hi
3885 .max(right_certificate.curvature.hi),
3886 ),
3887 });
3888 }
3889 let width = Ball::exact(hi).sub(Ball::exact(lo));
3890 if !(width.lo > 0.0) {
3891 return Err(SplineScoreProofError::InvalidArithmetic {
3892 context: "positive score-enclosure width",
3893 });
3894 }
3895 let proper_modes = Ball::exact((n_nodes - order) as f64);
3896 let residual_dof = Ball::exact((n_obs - order) as f64);
3897 let fifth_abs_bound = fifth_derivative_global_bound(proper_modes, residual_dof);
3898 let third_abs_bound = proper_modes
3899 .scale(0.25)
3900 .add(residual_dof.scale(6.0))
3901 .scale(0.5);
3902 for (side, weakened) in [
3908 ("left", left_certificate.weakened_anchor()),
3909 ("right", right_certificate.weakened_anchor()),
3910 ] {
3911 if let Some((curvature_source, third_source)) = weakened {
3912 log::debug!(
3913 "spline scan enclosure: {side} endpoint curvature anchored by \
3914 {curvature_source:?}, third order by {third_source:?}. A global-bound \
3915 anchor keeps the search CERTIFIED and widens its tail cells -- half rate \
3916 in place of fourth-order rate -- so it costs cells, never soundness."
3917 );
3918 }
3919 }
3920 let half_width = width.scale(0.5);
3921 let width2 = width.square();
3922 let width3 = width2.mul(width);
3923 let width4 = width2.square();
3924 let width5 = width4.mul(width);
3925 let value_remainder = fifth_abs_bound
3926 .mul(width5)
3927 .div_positive(Ball::exact(960.0))
3928 .hi;
3929 let derivative_remainder = fifth_abs_bound
3930 .mul(width4)
3931 .div_positive(Ball::exact(128.0))
3932 .hi;
3933 let curvature_remainder = fifth_abs_bound
3934 .mul(width3)
3935 .div_positive(Ball::exact(24.0))
3936 .hi;
3937 let third_slope = right_certificate
3938 .third
3939 .sub(left_certificate.third)
3940 .div_positive(width);
3941
3942 let endpoint_enclosure = |certificate: CertifiedCriterionJet,
3948 displacement: ClosedInterval,
3949 value_remainder: f64,
3950 derivative_remainder: f64,
3951 curvature_remainder: f64| {
3952 let d = Ball::certified(0.0, displacement);
3953 let d2 = d.square();
3954 let d3 = d2.mul(d);
3955 let d4 = d2.square();
3956 let value = certificate
3957 .value
3958 .add(certificate.derivative.mul(d))
3959 .add(certificate.curvature.mul(d2).scale(0.5))
3960 .add(certificate.third.mul(d3).div_positive(Ball::exact(6.0)))
3961 .add(third_slope.mul(d4).div_positive(Ball::exact(24.0)))
3962 .interval()
3963 .add(ClosedInterval::new(-value_remainder, value_remainder));
3964 let derivative = certificate
3965 .derivative
3966 .add(certificate.curvature.mul(d))
3967 .add(certificate.third.mul(d2).scale(0.5))
3968 .add(third_slope.mul(d3).div_positive(Ball::exact(6.0)))
3969 .interval()
3970 .add(ClosedInterval::new(
3971 -derivative_remainder,
3972 derivative_remainder,
3973 ));
3974 let curvature = certificate
3975 .curvature
3976 .add(certificate.third.mul(d))
3977 .add(third_slope.mul(d2).scale(0.5))
3978 .interval()
3979 .add(ClosedInterval::new(
3980 -curvature_remainder,
3981 curvature_remainder,
3982 ));
3983 (value, derivative, curvature)
3984 };
3985
3986 let (left_value, left_derivative, left_curvature) = endpoint_enclosure(
3987 left_certificate,
3988 ClosedInterval::new(0.0, half_width.hi),
3989 value_remainder,
3990 derivative_remainder,
3991 curvature_remainder,
3992 );
3993 let (right_value, right_derivative, right_curvature) = endpoint_enclosure(
3994 right_certificate,
3995 ClosedInterval::new(-half_width.hi, 0.0),
3996 value_remainder,
3997 derivative_remainder,
3998 curvature_remainder,
3999 );
4000 let half_cell_score = ClosedInterval::new(
4001 left_value.lo.min(right_value.lo),
4002 left_value.hi.max(right_value.hi),
4003 );
4004 let full_value_remainder = fifth_abs_bound
4005 .mul(width5)
4006 .div_positive(Ball::exact(80.0))
4007 .hi;
4008 let full_derivative_remainder = fifth_abs_bound
4009 .mul(width4)
4010 .div_positive(Ball::exact(24.0))
4011 .hi;
4012 let full_curvature_remainder = fifth_abs_bound
4013 .mul(width3)
4014 .div_positive(Ball::exact(12.0))
4015 .hi;
4016 let (full_left_value, _, _) = endpoint_enclosure(
4017 left_certificate,
4018 ClosedInterval::new(0.0, width.hi),
4019 full_value_remainder,
4020 full_derivative_remainder,
4021 full_curvature_remainder,
4022 );
4023 let (full_right_value, _, _) = endpoint_enclosure(
4024 right_certificate,
4025 ClosedInterval::new(-width.hi, 0.0),
4026 full_value_remainder,
4027 full_derivative_remainder,
4028 full_curvature_remainder,
4029 );
4030 let score_value = ClosedInterval::new(
4031 half_cell_score
4032 .lo
4033 .max(full_left_value.lo)
4034 .max(full_right_value.lo),
4035 half_cell_score
4036 .hi
4037 .min(full_left_value.hi)
4038 .min(full_right_value.hi),
4039 );
4040 if !(score_value.lo <= score_value.hi) {
4041 return Err(SplineScoreProofError::InvalidArithmetic {
4042 context: "endpoint score-enclosure intersection",
4043 });
4044 }
4045 let endpoint_third_derivative = ClosedInterval::new(
4046 left_derivative.lo.min(right_derivative.lo),
4047 left_derivative.hi.max(right_derivative.hi),
4048 );
4049 let endpoint_third_curvature = ClosedInterval::new(
4050 left_curvature.lo.min(right_curvature.lo),
4051 left_curvature.hi.max(right_curvature.hi),
4052 );
4053 let derivative_secant = right_certificate
4054 .derivative
4055 .sub(left_certificate.derivative)
4056 .div_positive(width);
4057 let secant_radius = third_abs_bound.mul(width).hi;
4058 let secant_curvature = derivative_secant
4059 .interval()
4060 .add(ClosedInterval::new(-secant_radius, secant_radius));
4061 let curvature = ClosedInterval::new(
4062 endpoint_third_curvature.lo.max(secant_curvature.lo),
4063 endpoint_third_curvature.hi.min(secant_curvature.hi),
4064 );
4065 if !(curvature.lo <= curvature.hi) {
4066 return Err(SplineScoreProofError::InvalidArithmetic {
4067 context: "curvature secant intersection",
4068 });
4069 }
4070 let curvature_ball = Ball::certified(0.0, curvature);
4071 let derivative_from_left = left_certificate
4072 .derivative
4073 .add(curvature_ball.mul(Ball::certified(0.0, ClosedInterval::new(0.0, width.hi))))
4074 .interval();
4075 let derivative_from_right = right_certificate
4076 .derivative
4077 .add(curvature_ball.mul(Ball::certified(0.0, ClosedInterval::new(-width.hi, 0.0))))
4078 .interval();
4079 let derivative_from_curvature = ClosedInterval::new(
4080 derivative_from_left.lo.max(derivative_from_right.lo),
4081 derivative_from_left.hi.min(derivative_from_right.hi),
4082 );
4083 let derivative = ClosedInterval::new(
4084 endpoint_third_derivative
4085 .lo
4086 .max(derivative_from_curvature.lo),
4087 endpoint_third_derivative
4088 .hi
4089 .min(derivative_from_curvature.hi),
4090 );
4091 if !(derivative.lo <= derivative.hi) {
4092 return Err(SplineScoreProofError::InvalidArithmetic {
4093 context: "derivative curvature-integral intersection",
4094 });
4095 }
4096 let evaluation_error = left_certificate
4097 .value
4098 .forward_error()
4099 .max(right_certificate.value.forward_error());
4100 Ok(DerivativeEnclosure {
4101 score: ScoreValueEnclosure {
4102 value: score_value,
4103 evaluation_error,
4104 },
4105 derivative,
4106 curvature,
4107 })
4108}
4109
4110fn leading_block_smooth(
4143 sm_state: &mut [Vec2],
4144 sm_cov: &mut [Mat2],
4145 gains: &mut [Mat2],
4146 nodes: &[PooledNode],
4147 q: f64,
4148 order: usize,
4149) -> Result<(), String> {
4150 let nb = order - 1; let pin = order - 1; let d = nb * order; let mut lambda = vec![vec![0.0_f64; d]; d];
4154 let mut b_const = vec![0.0_f64; d];
4155 let mut bmat = vec![vec![0.0_f64; order]; d]; for t in 0..order - 1 {
4159 let delta = nodes[t + 1].x - nodes[t].x;
4160 let f = transition(delta, order);
4161 let qn = process_noise(delta, q, order);
4162 let a = mat_inv(&qn, order, "leading-block increment noise")?; let ft = mat_t(&f, order);
4164 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 {
4169 for j in 0..order {
4170 lambda[t * order + i][t * order + j] += ftaf[i][j];
4171 }
4172 }
4173 if t + 1 <= nb - 1 {
4174 for i in 0..order {
4177 for j in 0..order {
4178 lambda[(t + 1) * order + i][(t + 1) * order + j] += a[i][j];
4179 lambda[t * order + i][(t + 1) * order + j] -= fta[i][j];
4180 lambda[(t + 1) * order + i][t * order + j] -= af[i][j];
4181 }
4182 }
4183 } else {
4184 for i in 0..order {
4187 for j in 0..order {
4188 bmat[t * order + i][j] += fta[i][j];
4189 }
4190 }
4191 }
4192 }
4193 for t in 0..nb {
4195 let w = nodes[t].w;
4196 lambda[t * order][t * order] += w;
4197 b_const[t * order] += w * nodes[t].y;
4198 }
4199
4200 let sigma = dense_spd_inverse(&lambda, "leading-block precision")?;
4202 let dvec: Vec<f64> = (0..d)
4203 .map(|i| (0..d).map(|k| sigma[i][k] * b_const[k]).sum())
4204 .collect();
4205 let cmat: Vec<Vec<f64>> = (0..d)
4206 .map(|i| {
4207 (0..order)
4208 .map(|j| (0..d).map(|k| sigma[i][k] * bmat[k][j]).sum())
4209 .collect()
4210 })
4211 .collect();
4212
4213 let ahat_p = sm_state[pin];
4215 let vp = sm_cov[pin];
4216 let cvp: Vec<Vec<f64>> = (0..d)
4218 .map(|i| {
4219 (0..order)
4220 .map(|j| (0..order).map(|k| cmat[i][k] * vp[k][j]).sum())
4221 .collect()
4222 })
4223 .collect();
4224 let mean_u: Vec<f64> = (0..d)
4226 .map(|i| (0..order).map(|j| cmat[i][j] * ahat_p[j]).sum::<f64>() + dvec[i])
4227 .collect();
4228 let cov_u: Vec<Vec<f64>> = (0..d)
4230 .map(|i| {
4231 (0..d)
4232 .map(|k| (0..order).map(|j| cvp[i][j] * cmat[k][j]).sum::<f64>() + sigma[i][k])
4233 .collect()
4234 })
4235 .collect();
4236
4237 for j in 0..nb {
4239 for i in 0..order {
4240 sm_state[j][i] = mean_u[j * order + i];
4241 }
4242 let mut cov = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4243 for i in 0..order {
4244 for k in 0..order {
4245 cov[i][k] = cov_u[j * order + i][j * order + k];
4246 }
4247 }
4248 symmetrize(&mut cov, order);
4249 sm_cov[j] = cov;
4250 }
4251 for j in 0..nb {
4255 let mut cross = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4256 if j + 1 <= nb - 1 {
4257 for i in 0..order {
4259 for k in 0..order {
4260 cross[i][k] = cov_u[j * order + i][(j + 1) * order + k];
4261 }
4262 }
4263 } else {
4264 for i in 0..order {
4266 for k in 0..order {
4267 cross[i][k] = cvp[j * order + i][k];
4268 }
4269 }
4270 }
4271 let denom_inv = mat_inv(&sm_cov[j + 1], order, "leading-block gain denominator")?;
4272 gains[j] = mat_mul(&cross, &denom_inv, order);
4273 }
4274 Ok(())
4275}
4276
4277pub fn fit_spline_scan_at(
4280 x: &[f64],
4281 y: &[f64],
4282 w: &[f64],
4283 log_lambda: f64,
4284 sigma2: Option<f64>,
4285 order: usize,
4286) -> Result<SplineScanFit, String> {
4287 if order == 0 || order > MAX_ORDER {
4288 return Err(format!(
4289 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
4290 ));
4291 }
4292 let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
4293 let q = gam_problem::checked_exp_log_strength(-log_lambda)
4294 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
4295 let pass = run_filter::<true>(&nodes, q, order)?;
4296 let n = nodes.len();
4297 let dof = (n_obs - order) as f64;
4298 let sigma2 = match sigma2 {
4299 Some(s) => {
4300 if !(s.is_finite() && s > 0.0) {
4301 return Err(format!("spline scan: invalid sigma2 {s}"));
4302 }
4303 s
4304 }
4305 None => (pass.sum_v2_over_f + ssr_within) / dof,
4306 };
4307 let rss = pass.sum_v2_over_f + ssr_within;
4312 let restricted_loglik = -0.5 * (pass.sum_log_f + dof * sigma2.ln() + rss / sigma2);
4313
4314 let mut sm_state = vec![[0.0_f64; MAX_ORDER]; n];
4325 let mut sm_cov = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
4326 let mut gains = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
4327 sm_state[n - 1] = pass.steps[n - 1].a_filt;
4328 sm_cov[n - 1] = pass.steps[n - 1].p_filt;
4329 for t in (order - 1..n - 1).rev() {
4330 let p_next_pred = &pass.steps[t + 1].p_pred;
4331 let delta = nodes[t + 1].x - nodes[t].x;
4332 let f_t = transition(delta, order);
4333 let p_inv = mat_inv(p_next_pred, order, "RTS predicted covariance")?;
4334 let g = mat_mul(
4335 &mat_mul(&pass.steps[t].p_filt, &mat_t(&f_t, order), order),
4336 &p_inv,
4337 order,
4338 );
4339 let mut dm: Vec2 = [0.0; MAX_ORDER];
4340 for i in 0..order {
4341 dm[i] = sm_state[t + 1][i] - pass.steps[t + 1].a_pred[i];
4342 }
4343 let corr = mat_vec(&g, &dm, order);
4344 for i in 0..order {
4345 sm_state[t][i] = pass.steps[t].a_filt[i] + corr[i];
4346 }
4347 let dp = mat_sub(&sm_cov[t + 1], p_next_pred, order);
4348 let mut cov = mat_add(
4349 &pass.steps[t].p_filt,
4350 &mat_mul(&mat_mul(&g, &dp, order), &mat_t(&g, order), order),
4351 order,
4352 );
4353 symmetrize(&mut cov, order);
4354 sm_cov[t] = cov;
4355 gains[t] = g;
4356 }
4357 if order >= 2 {
4360 leading_block_smooth(&mut sm_state, &mut sm_cov, &mut gains, &nodes, q, order)?;
4361 }
4362
4363 let knots: Vec<f64> = nodes.iter().map(|n| n.x).collect();
4364 let mean: Vec<f64> = sm_state.iter().map(|s| s[0]).collect();
4365 let deriv: Option<Vec<f64>> = (order >= 2).then(|| sm_state.iter().map(|s| s[1]).collect());
4368 let var: Vec<f64> = sm_cov.iter().map(|p| p[0][0] * sigma2).collect();
4369 let data_sse = ssr_within
4374 + nodes
4375 .iter()
4376 .zip(mean.iter())
4377 .map(|(node, &fhat)| {
4378 let r = node.y - fhat;
4379 node.w * r * r
4380 })
4381 .sum::<f64>();
4382 Ok(SplineScanFit {
4383 order,
4384 knots,
4385 mean,
4386 deriv,
4387 var,
4388 log_lambda,
4389 sigma2,
4390 restricted_loglik,
4391 training_sample_size: std::num::NonZeroUsize::new(n_obs)
4392 .expect("pool_nodes requires at least one training row"),
4393 data_sse,
4394 smoothed_state: sm_state,
4395 smoothed_cov: sm_cov,
4396 rts_gain: gains,
4397 q,
4398 node_weight: nodes.iter().map(|n| n.w).collect(),
4399 })
4400}
4401
4402#[derive(Clone, Copy, Debug, PartialEq)]
4403enum SplineKktKind {
4404 LowerBoundary,
4405 UpperBoundary,
4406 Stationary { curvature: ClosedInterval },
4407}
4408
4409#[derive(Clone, Copy, Debug, PartialEq)]
4410enum SplineOptimumProof {
4411 Kkt {
4412 bracket: ClosedInterval,
4413 kind: SplineKktKind,
4414 },
4415 ResolutionFlat {
4419 bracket: ClosedInterval,
4420 max_score_gap: f64,
4421 score_resolution: f64,
4422 },
4423}
4424
4425fn spline_optimum_proof(
4435 search: &ScoreSearchResult,
4436) -> Result<SplineOptimumProof, SplineScoreProofError> {
4437 match search.location {
4438 ScoreOptimumLocation::LowerBoundary => Ok(SplineOptimumProof::Kkt {
4439 bracket: ClosedInterval::point(search.lower_boundary.x),
4440 kind: SplineKktKind::LowerBoundary,
4441 }),
4442 ScoreOptimumLocation::UpperBoundary => Ok(SplineOptimumProof::Kkt {
4443 bracket: ClosedInterval::point(search.upper_boundary.x),
4444 kind: SplineKktKind::UpperBoundary,
4445 }),
4446 ScoreOptimumLocation::Stationary(index) => {
4447 let stationary = search.stationary_points.get(index).ok_or_else(|| {
4448 SplineScoreProofError::Search(
4449 "optimizer returned an invalid stationary-point index".to_string(),
4450 )
4451 })?;
4452 Ok(SplineOptimumProof::Kkt {
4453 bracket: stationary.bracket,
4454 kind: SplineKktKind::Stationary {
4455 curvature: stationary.curvature,
4456 },
4457 })
4458 }
4459 ScoreOptimumLocation::ResolutionFlat(index) => {
4460 let flat = search.resolution_flat_regions.get(index).ok_or_else(|| {
4461 SplineScoreProofError::Search(
4462 "optimizer returned an invalid resolution-flat index".to_string(),
4463 )
4464 })?;
4465 if !(flat.max_score_gap.is_finite()
4466 && flat.max_score_gap >= 0.0
4467 && flat.score_resolution.is_finite()
4468 && flat.score_resolution >= 0.0
4469 && flat.max_score_gap <= flat.score_resolution
4470 && flat.bracket.contains(search.optimum.x)
4471 && flat.sample.x.to_bits() == search.optimum.x.to_bits())
4472 {
4473 return Err(SplineScoreProofError::Search(format!(
4474 "optimizer returned an invalid resolution-flat certificate: selected {}, \
4475 representative {}, bracket {:?}, maximum score gap {}, score resolution {}",
4476 search.optimum.x,
4477 flat.sample.x,
4478 flat.bracket,
4479 flat.max_score_gap,
4480 flat.score_resolution
4481 )));
4482 }
4483 Ok(SplineOptimumProof::ResolutionFlat {
4484 bracket: flat.bracket,
4485 max_score_gap: flat.max_score_gap,
4486 score_resolution: flat.score_resolution,
4487 })
4488 }
4489 }
4490}
4491
4492fn spline_kkt_holds(
4493 kind: SplineKktKind,
4494 final_enclosure: DerivativeEnclosure,
4495) -> (bool, ClosedInterval) {
4496 match kind {
4497 SplineKktKind::LowerBoundary => (
4498 final_enclosure.derivative.hi <= 0.0,
4499 final_enclosure.curvature,
4500 ),
4501 SplineKktKind::UpperBoundary => (
4502 final_enclosure.derivative.lo >= 0.0,
4503 final_enclosure.curvature,
4504 ),
4505 SplineKktKind::Stationary { curvature } => (
4506 final_enclosure.derivative.contains_zero() && curvature.hi < 0.0,
4513 curvature,
4514 ),
4515 }
4516}
4517
4518pub fn fit_spline_scan(
4523 x: &[f64],
4524 y: &[f64],
4525 w: &[f64],
4526 order: usize,
4527) -> Result<SplineScanFit, SplineScoreProofError> {
4528 if order == 0 || order > MAX_ORDER {
4529 return Err(SplineScoreProofError::InvalidInput(format!(
4530 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
4531 )));
4532 }
4533 let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
4534 let first_x = nodes
4549 .first()
4550 .ok_or_else(|| {
4551 SplineScoreProofError::InvalidInput(
4552 "spline scan: pooled data unexpectedly contain no nodes".to_string(),
4553 )
4554 })?
4555 .x;
4556 let last_x = nodes
4557 .last()
4558 .ok_or_else(|| {
4559 SplineScoreProofError::InvalidInput(
4560 "spline scan: pooled data unexpectedly contain no nodes".to_string(),
4561 )
4562 })?
4563 .x;
4564 let span = last_x - first_x;
4565 if !(span.is_finite() && span > 0.0) {
4566 return Err(SplineScoreProofError::InvalidInput(format!(
4567 "spline scan: pooled covariate span must be finite and positive, got {span}"
4568 )));
4569 }
4570 let log_span = gam_math::score_opt::certified_ln_positive(span).ok_or(
4571 SplineScoreProofError::InvalidArithmetic {
4572 context: "covariate-span logarithm",
4573 },
4574 )?;
4575 let log_span_representative = log_span.lo + 0.5 * (log_span.hi - log_span.lo);
4576 let scale_shift = (2 * order - 1) as f64 * log_span_representative;
4577 let lo_anchor = LOG_LAMBDA_LO + scale_shift;
4578 let hi_anchor = LOG_LAMBDA_HI + scale_shift;
4579 let n_nodes = nodes.len();
4580 let endpoint_certificates = RefCell::new(HashMap::<u64, CertifiedCriterionJet>::new());
4581 let search = maximize_score_1d(
4582 lo_anchor,
4583 hi_anchor,
4584 f64::EPSILON.sqrt(),
4585 |ll| {
4586 let certificate =
4587 certified_concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order)?;
4588 endpoint_certificates
4589 .borrow_mut()
4590 .insert(ll.to_bits(), certificate);
4591 Ok(certificate.jet)
4592 },
4593 |left, right| {
4594 let certificates = endpoint_certificates.borrow();
4595 let left_certificate = certificates
4596 .get(&left.x.to_bits())
4597 .copied()
4598 .ok_or(SplineScoreProofError::MissingEndpointCertificate { log_lambda: left.x })?;
4599 let right_certificate = certificates.get(&right.x.to_bits()).copied().ok_or(
4600 SplineScoreProofError::MissingEndpointCertificate {
4601 log_lambda: right.x,
4602 },
4603 )?;
4604 concentrated_criterion_enclosure(
4605 n_nodes,
4606 n_obs,
4607 left,
4608 right,
4609 left_certificate,
4610 right_certificate,
4611 order,
4612 )
4613 },
4614 )
4615 .map_err(|error| match error {
4616 gam_math::score_opt::ScoreSearchError::PointEvaluation { source, .. }
4617 | gam_math::score_opt::ScoreSearchError::EnclosureEvaluation { source, .. } => source,
4618 other => SplineScoreProofError::Search(other.to_string()),
4619 })?;
4620 if search.value_certificate.maximum_excess > search.value_certificate.comparison_resolution {
4621 return Err(SplineScoreProofError::GlobalValueOrderingUnresolved {
4622 maximum_excess: search.value_certificate.maximum_excess,
4623 comparison_resolution: search.value_certificate.comparison_resolution,
4624 });
4625 }
4626 match spline_optimum_proof(&search)? {
4627 SplineOptimumProof::Kkt {
4628 bracket: kkt_bracket,
4629 kind: kkt_kind,
4630 } => {
4631 let kkt_enclosure = {
4632 let certificates = endpoint_certificates.borrow();
4633 let left_certificate = certificates.get(&kkt_bracket.lo.to_bits()).copied().ok_or(
4634 SplineScoreProofError::MissingEndpointCertificate {
4635 log_lambda: kkt_bracket.lo,
4636 },
4637 )?;
4638 let right_certificate = certificates
4639 .get(&kkt_bracket.hi.to_bits())
4640 .copied()
4641 .ok_or(SplineScoreProofError::MissingEndpointCertificate {
4642 log_lambda: kkt_bracket.hi,
4643 })?;
4644 let sample = |log_lambda: f64, certificate: CertifiedCriterionJet| ScoreSample {
4645 x: log_lambda,
4646 value: certificate.jet.value,
4647 derivative: certificate.jet.derivative,
4648 curvature: certificate.jet.curvature,
4649 third: certificate.jet.third,
4650 };
4651 concentrated_criterion_enclosure(
4652 n_nodes,
4653 n_obs,
4654 sample(kkt_bracket.lo, left_certificate),
4655 sample(kkt_bracket.hi, right_certificate),
4656 left_certificate,
4657 right_certificate,
4658 order,
4659 )?
4660 };
4661 let (kkt_holds, kkt_curvature) = spline_kkt_holds(kkt_kind, kkt_enclosure);
4662 if !kkt_holds {
4663 return Err(SplineScoreProofError::OptimumKktUncertified {
4664 location: search.location,
4665 bracket: kkt_bracket,
4666 derivative: kkt_enclosure.derivative,
4667 curvature: kkt_curvature,
4668 });
4669 }
4670 }
4671 SplineOptimumProof::ResolutionFlat {
4672 bracket,
4673 max_score_gap,
4674 score_resolution,
4675 } => {
4676 log::debug!(
4677 "spline scan: accepting certified resolution-flat REML optimum on \
4678 {bracket:?}; maximum score gap {max_score_gap:e} <= comparison \
4679 resolution {score_resolution:e}"
4680 );
4681 }
4682 }
4683 let selected_certificate = endpoint_certificates
4688 .borrow()
4689 .get(&search.optimum.x.to_bits())
4690 .copied()
4691 .ok_or_else(|| {
4692 SplineScoreProofError::Search(format!(
4693 "spline scan: selected log lambda {} has no cached score certificate",
4694 search.optimum.x
4695 ))
4696 })?;
4697 let independent =
4698 concentrated_criterion_jet(&nodes, ssr_within, n_obs, search.optimum.x, order)
4699 .map_err(SplineScoreProofError::Computation)?;
4700 for (name, ball, scalar) in [
4701 ("value", selected_certificate.value, independent.0),
4702 ("derivative", selected_certificate.derivative, independent.1),
4703 ("curvature", selected_certificate.curvature, independent.2),
4704 ("third", selected_certificate.third, independent.3),
4705 ] {
4706 if !ball.interval().contains(scalar) {
4707 return Err(SplineScoreProofError::Computation(format!(
4708 "spline scan: selected {name} scalar {scalar} escapes its directed score ball {:?}",
4709 ball.interval()
4710 )));
4711 }
4712 }
4713 fit_spline_scan_at(x, y, w, search.optimum.x, None, order)
4714 .map_err(SplineScoreProofError::Computation)
4715}
4716
4717#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
4731pub struct SplineScanState {
4732 #[serde(default = "default_spline_scan_order")]
4736 pub order: usize,
4737 pub knots: Vec<f64>,
4738 pub state: Vec<f64>,
4740 pub cov: Vec<f64>,
4743 pub gain: Vec<f64>,
4746 pub node_weight: Vec<f64>,
4748 pub log_lambda: f64,
4749 pub sigma2: f64,
4750 pub restricted_loglik: f64,
4751 pub training_sample_size: std::num::NonZeroU64,
4753 pub data_sse: f64,
4758}
4759
4760fn default_spline_scan_order() -> usize {
4763 2
4764}
4765
4766impl SplineScanFit {
4767 pub fn to_state(&self) -> SplineScanState {
4769 let order = self.order;
4770 let tri = order * (order + 1) / 2;
4771 let nk = self.knots.len();
4772 let mut state = Vec::with_capacity(order * nk);
4773 for s in &self.smoothed_state {
4774 state.extend_from_slice(&s[..order]);
4775 }
4776 let mut cov = Vec::with_capacity(tri * nk);
4777 for c in &self.smoothed_cov {
4778 for i in 0..order {
4779 for j in i..order {
4780 cov.push(c[i][j]);
4781 }
4782 }
4783 }
4784 let mut gain = Vec::with_capacity(order * order * nk);
4785 for g in &self.rts_gain {
4786 for i in 0..order {
4787 for j in 0..order {
4788 gain.push(g[i][j]);
4789 }
4790 }
4791 }
4792 SplineScanState {
4793 order: self.order,
4794 knots: self.knots.clone(),
4795 state,
4796 cov,
4797 gain,
4798 node_weight: self.node_weight.clone(),
4799 log_lambda: self.log_lambda,
4800 sigma2: self.sigma2,
4801 restricted_loglik: self.restricted_loglik,
4802 training_sample_size: std::num::NonZeroU64::new(
4803 u64::try_from(self.training_sample_size.get())
4804 .expect("SplineScanFit row count exceeds the persistence format"),
4805 )
4806 .expect("SplineScanFit construction requires training rows"),
4807 data_sse: self.data_sse,
4808 }
4809 }
4810
4811 pub fn from_state(state: &SplineScanState) -> Result<Self, String> {
4819 let order = state.order;
4820 if order == 0 || order > MAX_ORDER {
4821 return Err(format!(
4822 "spline scan state: order must be in 1..={MAX_ORDER}, got {order}"
4823 ));
4824 }
4825 let m = state.knots.len();
4826 if m < order + 1 {
4827 return Err(format!(
4828 "spline scan state: order {order} needs at least {} knots, got {m}",
4829 order + 1
4830 ));
4831 }
4832 let tri = order * (order + 1) / 2;
4833 if state.state.len() != order * m
4834 || state.cov.len() != tri * m
4835 || state.gain.len() != order * order * m
4836 || state.node_weight.len() != m
4837 {
4838 return Err(format!(
4839 "spline scan state: inconsistent lengths (order={order}, m={m}, state={}, cov={}, gain={}, weights={})",
4840 state.state.len(),
4841 state.cov.len(),
4842 state.gain.len(),
4843 state.node_weight.len()
4844 ));
4845 }
4846 let all = state
4847 .state
4848 .iter()
4849 .chain(&state.cov)
4850 .chain(&state.gain)
4851 .chain(&state.knots)
4852 .chain(&state.node_weight);
4853 for (i, v) in all.enumerate() {
4854 if !v.is_finite() {
4855 return Err(format!("spline scan state: non-finite entry at {i}"));
4856 }
4857 }
4858 gam_problem::validate_log_strength(state.log_lambda)
4859 .map_err(|error| format!("spline scan state: {error}"))?;
4860 if !(state.restricted_loglik.is_finite() && state.sigma2.is_finite() && state.sigma2 > 0.0)
4861 {
4862 return Err(format!(
4863 "spline scan state: invalid scalars (log_lambda={}, sigma2={}, restricted_loglik={})",
4864 state.log_lambda, state.sigma2, state.restricted_loglik
4865 ));
4866 }
4867 if !(state.data_sse.is_finite() && state.data_sse >= 0.0) {
4868 return Err(format!(
4869 "spline scan state: invalid data_sse {}",
4870 state.data_sse
4871 ));
4872 }
4873 if state.knots.windows(2).any(|kk| !(kk[0] < kk[1])) {
4874 return Err("spline scan state: knots must be strictly increasing".to_string());
4875 }
4876 if state.node_weight.iter().any(|&w| w <= 0.0) {
4877 return Err("spline scan state: node weights must be positive".to_string());
4878 }
4879 let smoothed_state: Vec<Vec2> = state
4880 .state
4881 .chunks_exact(order)
4882 .map(|s| {
4883 let mut v = [0.0_f64; MAX_ORDER];
4884 v[..order].copy_from_slice(s);
4885 v
4886 })
4887 .collect();
4888 let smoothed_cov: Vec<Mat2> = state
4889 .cov
4890 .chunks_exact(tri)
4891 .map(|c| {
4892 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4893 let mut idx = 0;
4894 for i in 0..order {
4895 for j in i..order {
4896 mm[i][j] = c[idx];
4897 mm[j][i] = c[idx];
4898 idx += 1;
4899 }
4900 }
4901 mm
4902 })
4903 .collect();
4904 let rts_gain: Vec<Mat2> = state
4905 .gain
4906 .chunks_exact(order * order)
4907 .map(|g| {
4908 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
4909 for i in 0..order {
4910 for j in 0..order {
4911 mm[i][j] = g[i * order + j];
4912 }
4913 }
4914 mm
4915 })
4916 .collect();
4917 let sigma2 = state.sigma2;
4918 let training_sample_size =
4919 usize::try_from(state.training_sample_size.get()).map_err(|_| {
4920 format!(
4921 "spline scan state: training_sample_size {} exceeds this platform's usize",
4922 state.training_sample_size
4923 )
4924 })?;
4925 Ok(Self {
4926 order,
4927 knots: state.knots.clone(),
4928 mean: smoothed_state.iter().map(|s| s[0]).collect(),
4929 deriv: (order >= 2).then(|| smoothed_state.iter().map(|s| s[1]).collect()),
4930 var: smoothed_cov.iter().map(|c| c[0][0] * sigma2).collect(),
4931 log_lambda: state.log_lambda,
4932 sigma2,
4933 restricted_loglik: state.restricted_loglik,
4934 training_sample_size: std::num::NonZeroUsize::new(training_sample_size)
4935 .expect("nonzero wire count remains nonzero after conversion"),
4936 data_sse: state.data_sse,
4937 smoothed_state,
4938 smoothed_cov,
4939 rts_gain,
4940 q: gam_problem::checked_exp_log_strength(-state.log_lambda)
4941 .map_err(|error| format!("spline scan inverse log strength: {error}"))?,
4942 node_weight: state.node_weight.clone(),
4943 })
4944 }
4945
4946 pub fn predict(&self, x_new: f64) -> Result<(f64, f64), String> {
4953 if !x_new.is_finite() {
4954 return Err("spline scan: non-finite prediction abscissa".to_string());
4955 }
4956 let n = self.knots.len();
4957 let order = self.order;
4958 let first = self.knots[0];
4959 let last = self.knots[n - 1];
4960 if x_new <= first {
4961 let delta = first - x_new;
4962 let f_t = transition(delta, order);
4964 let f_inv = mat_inv(&f_t, order, "backward extrapolation transition")?;
4965 let mean_s = mat_vec(&f_inv, &self.smoothed_state[0], order);
4966 let qm = process_noise(delta, self.q, order);
4967 let cov = mat_add(
4968 &mat_mul(
4969 &mat_mul(&f_inv, &self.smoothed_cov[0], order),
4970 &mat_t(&f_inv, order),
4971 order,
4972 ),
4973 &mat_mul(&mat_mul(&f_inv, &qm, order), &mat_t(&f_inv, order), order),
4974 order,
4975 );
4976 return Ok((mean_s[0], cov[0][0] * self.sigma2));
4977 }
4978 if x_new >= last {
4979 let delta = x_new - last;
4980 let f_t = transition(delta, order);
4981 let mean_s = mat_vec(&f_t, &self.smoothed_state[n - 1], order);
4982 let cov = mat_add(
4983 &mat_mul(
4984 &mat_mul(&f_t, &self.smoothed_cov[n - 1], order),
4985 &mat_t(&f_t, order),
4986 order,
4987 ),
4988 &process_noise(delta, self.q, order),
4989 order,
4990 );
4991 return Ok((mean_s[0], cov[0][0] * self.sigma2));
4992 }
4993 let t = match self.knots.binary_search_by(|k| k.total_cmp(&x_new)) {
4995 Ok(idx) => return Ok((self.mean[idx], self.var[idx])),
4996 Err(idx) => idx - 1,
4997 };
4998 let (xa, xb) = (self.knots[t], self.knots[t + 1]);
4999 let (d1, d2) = (x_new - xa, xb - x_new);
5000 let (f1m, f2m) = (transition(d1, order), transition(d2, order));
5001 let (q1, q2) = (
5002 process_noise(d1, self.q, order),
5003 process_noise(d2, self.q, order),
5004 );
5005 let q1_inv = mat_inv(&q1, order, "bridge left noise")?;
5006 let q2_inv = mat_inv(&q2, order, "bridge right noise")?;
5007 let lambda = mat_add(
5010 &q1_inv,
5011 &mat_mul(&mat_mul(&mat_t(&f2m, order), &q2_inv, order), &f2m, order),
5012 order,
5013 );
5014 let lam_inv = mat_inv(&lambda, order, "bridge precision")?;
5015 let ca = mat_mul(&lam_inv, &mat_mul(&q1_inv, &f1m, order), order);
5016 let cb = mat_mul(
5017 &lam_inv,
5018 &mat_mul(&mat_t(&f2m, order), &q2_inv, order),
5019 order,
5020 );
5021 let ma = mat_vec(&ca, &self.smoothed_state[t], order);
5022 let mb = mat_vec(&cb, &self.smoothed_state[t + 1], order);
5023 let mut mean_s = [0.0_f64; MAX_ORDER];
5024 for i in 0..order {
5025 mean_s[i] = ma[i] + mb[i];
5026 }
5027 let cross = mat_mul(&self.rts_gain[t], &self.smoothed_cov[t + 1], order);
5030 let mut cov = mat_add(
5031 &mat_add(
5032 &mat_mul(
5033 &mat_mul(&ca, &self.smoothed_cov[t], order),
5034 &mat_t(&ca, order),
5035 order,
5036 ),
5037 &mat_mul(
5038 &mat_mul(&cb, &self.smoothed_cov[t + 1], order),
5039 &mat_t(&cb, order),
5040 order,
5041 ),
5042 order,
5043 ),
5044 &lam_inv,
5045 order,
5046 );
5047 let cab = mat_mul(&mat_mul(&ca, &cross, order), &mat_t(&cb, order), order);
5048 cov = mat_add(&cov, &mat_add(&cab, &mat_t(&cab, order), order), order);
5049 symmetrize(&mut cov, order);
5050 Ok((mean_s[0], cov[0][0] * self.sigma2))
5051 }
5052
5053 pub fn edf(&self) -> f64 {
5067 self.node_weight
5068 .iter()
5069 .zip(self.smoothed_cov.iter())
5070 .map(|(w, c)| w * c[0][0])
5071 .sum()
5072 }
5073
5074 pub fn deriv_at_knot(&self, t: usize) -> Option<(f64, f64)> {
5081 (self.order >= 2).then(|| {
5082 (
5083 self.smoothed_state[t][1],
5084 self.smoothed_cov[t][1][1] * self.sigma2,
5085 )
5086 })
5087 }
5088
5089 pub fn lambda(&self) -> f64 {
5091 gam_problem::checked_exp_log_strength(self.log_lambda)
5092 .expect("SplineScanFit construction validates its private log strength")
5093 }
5094
5095 pub fn log_lambda(&self) -> f64 {
5096 self.log_lambda
5097 }
5098
5099 pub fn training_sample_size(&self) -> usize {
5101 self.training_sample_size.get()
5102 }
5103
5104 pub fn deviance(&self) -> f64 {
5113 self.data_sse
5114 }
5115}
5116
5117#[cfg(test)]
5118mod tests {
5119 fn covariance_zonotope_from_symmetric_matrix(
5126 matrix: &BallMat,
5127 order: usize,
5128 ) -> Zonotope<COVARIANCE_D1_DIM> {
5129 let mut state = Zonotope::<COVARIANCE_D1_DIM>::zeroed(order * order);
5130 for i in 0..order {
5131 for j in i..order {
5132 let value = matrix[i][j].value;
5133 state.center[i * order + j] = value;
5134 state.center[j * order + i] = value;
5135 let radius = [
5136 (value - matrix[i][j].lo).abs(),
5137 (matrix[i][j].hi - value).abs(),
5138 (value - matrix[j][i].lo).abs(),
5139 (matrix[j][i].hi - value).abs(),
5140 ]
5141 .into_iter()
5142 .fold(0.0_f64, f64::max);
5143 if radius > 0.0 {
5144 let mut generator = [0.0_f64; COVARIANCE_D1_DIM];
5145 let radius = next_up_ball(radius);
5146 generator[i * order + j] = radius;
5147 generator[j * order + i] = radius;
5148 state.generators.push(generator);
5149 }
5150 }
5151 }
5152 state
5153 }
5154
5155
5156
5157 #[test]
5162 fn zonotope_compaction_retains_correlation_before_axis_roundoff() {
5163 let mut state = Zonotope::<2>::zeroed(2);
5164 state.generators.push([1.0, -1.0]);
5165 for i in 0..ZONOTOPE_GENERATOR_CAP {
5166 state
5167 .generators
5168 .push(if i % 2 == 0 { [0.25, 0.0] } else { [0.0, 0.25] });
5169 }
5170
5171 state.compact();
5172
5173 assert!(state.generators.len() <= ZONOTOPE_GENERATOR_CAP);
5174 assert!(
5175 state
5176 .generators
5177 .iter()
5178 .any(|generator| *generator == [1.0, -1.0]),
5179 "compaction discarded the only signed correlation direction"
5180 );
5181 }
5182
5183 #[test]
5189 fn shared_q_process_noise_injections_accumulate_and_cancel_as_one_generator() {
5190 let q = Ball {
5191 value: 10.0,
5192 lo: 9.0,
5193 hi: 11.0,
5194 };
5195 let noise = ball_process_noise_taylor(Ball::exact(2.0), q, 1);
5196 let g = noise.shared_q[0];
5197 assert!(g > 0.0);
5198
5199 let identity = zonotope_identity_map::<COVARIANCE_D1_DIM>(1);
5200 let mut accumulated = Zonotope::<COVARIANCE_D1_DIM>::zeroed(1);
5201 assert!(accumulated.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5202 assert!(accumulated.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5203 assert_eq!(accumulated.shared_q[0], 2.0 * g);
5204
5205 let mut negative_identity = [[Ball::ZERO; COVARIANCE_D1_DIM]; COVARIANCE_D1_DIM];
5206 negative_identity[0][0] = Ball::exact(-1.0);
5207 let mut cancelled = Zonotope::<COVARIANCE_D1_DIM>::zeroed(1);
5208 assert!(cancelled.apply_with_shared_q(&identity, &noise.constant, &noise.shared_q,));
5209 assert!(cancelled.apply_with_shared_q(
5210 &negative_identity,
5211 &noise.constant,
5212 &noise.shared_q,
5213 ));
5214 assert_eq!(cancelled.shared_q[0], 0.0);
5215
5216 let old_independent_radius = 2.0 * g.abs();
5217 assert!(
5218 ball_radius_about_value(cancelled.coordinate(0)) < old_independent_radius * 1.0e-10,
5219 "independent qQ axes would retain radius {old_independent_radius:e}, \
5220 but the shared-q cancellation left {:?}",
5221 cancelled.coordinate(0),
5222 );
5223 }
5224
5225 #[test]
5226 fn centred_riccati_zonotope_contains_an_off_centre_covariance_and_noise() {
5227 let centres = [[4.0, 1.0, 0.3], [1.0, 3.0, 0.2], [0.3, 0.2, 2.0]];
5228 let radii = [[0.2, 0.1, 0.08], [0.1, 0.2, 0.07], [0.08, 0.07, 0.2]];
5229 let mut enclosure = [[Ball::ZERO; MAX_ORDER]; MAX_ORDER];
5230 for i in 0..MAX_ORDER {
5231 for j in 0..MAX_ORDER {
5232 enclosure[i][j] = Ball {
5233 value: centres[i][j],
5234 lo: centres[i][j] - radii[i][j],
5235 hi: centres[i][j] + radii[i][j],
5236 };
5237 }
5238 }
5239 let mut state = covariance_zonotope_from_symmetric_matrix(&enclosure, MAX_ORDER);
5240 let observation_variance = Ball {
5241 value: 1.2,
5242 lo: 1.1,
5243 hi: 1.3,
5244 };
5245 assert!(covariance_zonotope_measurement_update(
5246 &mut state,
5247 observation_variance,
5248 MAX_ORDER,
5249 ));
5250
5251 let actual = [[4.1, 0.95, 0.35], [0.95, 3.1, 0.15], [0.35, 0.15, 1.9]];
5252 let actual_r = 1.25;
5253 let innovation = actual[0][0] + actual_r;
5254 for i in 0..MAX_ORDER {
5255 for j in 0..MAX_ORDER {
5256 let updated = actual[i][j] - actual[i][0] * actual[0][j] / innovation;
5257 assert!(
5258 state
5259 .coordinate(i * MAX_ORDER + j)
5260 .interval()
5261 .contains(updated),
5262 "updated covariance ({i},{j})={updated} escaped {:?}",
5263 state.coordinate(i * MAX_ORDER + j).interval()
5264 );
5265 }
5266 }
5267 }
5268
5269 #[test]
5277 fn weighted_scan_dgp_2300_search_terminates_in_bounded_evaluations() {
5278 let (x, y, w) = dgp_2300();
5282 std::thread::scope(|scope| {
5296 for order in 1..=MAX_ORDER {
5297 let (x, y, w) = (&x, &y, &w);
5298 scope.spawn(move || {
5299 let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order).expect("pool");
5300 let span = nodes.last().unwrap().x - nodes.first().unwrap().x;
5301 let scale_shift = (2 * order - 1) as f64 * span.ln();
5302 let lo = LOG_LAMBDA_LO + scale_shift;
5303 let hi = LOG_LAMBDA_HI + scale_shift;
5304
5305 let n_nodes = nodes.len();
5306 let evals = std::cell::Cell::new(0u64);
5307 let last_x = std::cell::Cell::new(f64::NAN);
5308 let endpoint_certificates =
5309 RefCell::new(HashMap::<u64, CertifiedCriterionJet>::new());
5310 let budget = 4_096u64;
5311 let result = gam_math::score_opt::maximize_score_1d(
5312 lo,
5313 hi,
5314 f64::EPSILON.sqrt(),
5315 |ll| {
5316 let count = evals.get() + 1;
5317 evals.set(count);
5318 last_x.set(ll);
5319 assert!(
5320 count <= budget,
5321 "order-{order} certified scan search exceeded {budget} criterion \
5322 evaluations (last log-lambda sample {ll:.9}; bracket \
5323 [{lo:.3}, {hi:.3}]) — non-terminating subdivision reproduced"
5324 );
5325 let certificate = certified_concentrated_criterion_jet(
5326 &nodes, ssr_within, n_obs, ll, order,
5327 )?;
5328 endpoint_certificates
5329 .borrow_mut()
5330 .insert(ll.to_bits(), certificate);
5331 Ok(certificate.jet)
5332 },
5333 |a, b| {
5334 let certificates = endpoint_certificates.borrow();
5335 let left = certificates.get(&a.x.to_bits()).copied().ok_or(
5336 SplineScoreProofError::MissingEndpointCertificate {
5337 log_lambda: a.x,
5338 },
5339 )?;
5340 let right = certificates.get(&b.x.to_bits()).copied().ok_or(
5341 SplineScoreProofError::MissingEndpointCertificate {
5342 log_lambda: b.x,
5343 },
5344 )?;
5345 concentrated_criterion_enclosure(
5346 n_nodes, n_obs, a, b, left, right, order,
5347 )
5348 },
5349 );
5350 match result {
5351 Ok(search) => assert!(
5352 search.optimum.x.is_finite(),
5353 "order-{order} search must return a finite optimum"
5354 ),
5355 Err(error) => panic!(
5356 "order-{order} weighted scan search failed after {} evaluations \
5357 (last x {:.9}): {error:?}",
5358 evals.get(),
5359 last_x.get()
5360 ),
5361 }
5362 });
5363 }
5364 });
5365 }
5366
5367 fn dgp_2300() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
5371 let n = 180usize;
5372 let mut state: u64 = 0x2300_2300_2300_2300;
5373 let mut next_unit = move || {
5374 state ^= state << 13;
5375 state ^= state >> 7;
5376 state ^= state << 17;
5377 (state >> 11) as f64 / (1u64 << 53) as f64
5378 };
5379 let mut x = Vec::with_capacity(n);
5380 let mut y = Vec::with_capacity(n);
5381 let mut w = Vec::with_capacity(n);
5382 for i in 0..n {
5383 let xi = -2.0 + 4.0 * (i as f64) / ((n - 1) as f64);
5384 let wi: f64 = if xi < 0.0 { 1.0 } else { 9.0 };
5385 let u1 = next_unit().max(1e-12);
5386 let u2 = next_unit();
5387 let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
5388 x.push(xi);
5389 w.push(wi);
5390 y.push(0.4 + (1.3 * xi).sin() + (0.45 / wi.sqrt()) * z);
5391 }
5392 (x, y, w)
5393 }
5394
5395 #[test]
5406 fn certified_ladder_reaches_endpoint_jets_across_the_search_domain() {
5407 let (x, y, w) = dgp_2300();
5408 let visited = [
5409 -24.0_f64,
5410 -20.0,
5411 -18.0,
5412 -16.6135,
5413 -13.841116916640328,
5415 -10.0,
5416 -6.0,
5417 0.0,
5418 6.0,
5419 ];
5420 for order in 1..=MAX_ORDER {
5421 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5422 for &log_lambda in &visited {
5423 let certificate = certified_concentrated_criterion_jet(
5424 &nodes, within, n_obs, log_lambda, order,
5425 )
5426 .unwrap_or_else(|error| {
5427 panic!(
5428 "order {order}, rho {log_lambda}: repaired certified ladder refused: \
5429 {error:?}"
5430 )
5431 });
5432 assert_eq!(
5433 certificate.curvature_source,
5434 BoundSource::EndpointJet,
5435 "order {order}, rho {log_lambda}: curvature lost its exact endpoint anchor"
5436 );
5437 assert_eq!(
5438 certificate.third_source,
5439 BoundSource::EndpointJet,
5440 "order {order}, rho {log_lambda}: third derivative lost its exact endpoint anchor"
5441 );
5442 }
5443 }
5444 }
5445
5446 #[test]
5462 fn the_certified_jet_contains_the_scalar_jet_and_stays_in_its_closed_form_range() {
5463 let (x, y, w) = dgp_2300();
5464 for order in 1..=MAX_ORDER {
5465 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5466 let proper_modes = (nodes.len() - order) as f64;
5467 let residual_dof = (n_obs - order) as f64;
5468 for &rho in &[
5469 -18.0_f64,
5470 -16.6135,
5471 -13.841116916640328,
5472 -10.0,
5473 -6.0,
5474 0.0,
5475 6.0,
5476 ] {
5477 let Ok(certificate) =
5478 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5479 else {
5480 continue;
5481 };
5482 let scalar = concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5483 .expect("independent scalar recurrence");
5484 assert!(
5485 certificate.value.interval().contains(scalar.0),
5486 "order={order} rho={rho}: scalar value {} escaped {:?}",
5487 scalar.0,
5488 certificate.value
5489 );
5490 assert!(
5491 certificate.derivative.interval().contains(scalar.1),
5492 "order={order} rho={rho}: scalar derivative {} escaped {:?}",
5493 scalar.1,
5494 certificate.derivative
5495 );
5496 let width = certificate.derivative.hi - certificate.derivative.lo;
5497 assert!(
5498 width < proper_modes + residual_dof,
5499 "order={order} rho={rho}: the certified derivative ball is {width:e} \
5500 wide, outside the closed-form range the accumulators are bounded to \
5501 ({:e}); the search cannot sign an interval that wide",
5502 0.5 * (proper_modes + residual_dof)
5503 );
5504 }
5505 }
5506 }
5507
5508 #[test]
5582 fn the_closed_loop_map_contracts_while_its_absolute_value_explodes() {
5583 let (x, y, w) = dgp_2300();
5584 let order = 3;
5585 let log_lambda = -16.6135_f64;
5587 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5588 let q_value =
5589 gam_problem::checked_exp_log_strength(-log_lambda).expect("inverse log strength");
5590 let q = Ball::certified(
5591 q_value,
5592 gam_math::score_opt::certified_exp(-log_lambda).expect("certified exponential"),
5593 );
5594 let mut trace: Vec<BallTraceRecord> = Vec::new();
5595 certified_concentrated_criterion_jet(&nodes, within, n_obs, log_lambda, order)
5596 .expect("the certified jet must exist at the rho this map is measured at");
5597 run_filter_ball_traced(&nodes, q, order, Some(&mut trace)).expect("traced pass");
5598 let mut gains: HashMap<usize, [f64; MAX_ORDER]> = HashMap::new();
5599 let mut predicted: HashMap<usize, Mat2> = HashMap::new();
5600 for (node, name, ball) in &trace {
5601 if let Some(coordinate) = GAIN_NAMES.iter().position(|candidate| candidate == name) {
5602 gains.entry(*node).or_insert([0.0; MAX_ORDER])[coordinate] = ball.value;
5603 }
5604 for (i, row) in P_NEXT_ENTRY_NAMES.iter().enumerate().take(order) {
5605 for (j, entry) in row.iter().enumerate().take(order) {
5606 if entry == name {
5607 predicted
5608 .entry(*node)
5609 .or_insert([[0.0; MAX_ORDER]; MAX_ORDER])[i][j] = ball.value;
5610 }
5611 }
5612 }
5613 }
5614 let max_norm = |matrix: &Mat2| -> f64 {
5615 let mut norm = 0.0_f64;
5616 for row in matrix.iter().take(order) {
5617 for entry in row.iter().take(order) {
5618 norm = norm.max(entry.abs());
5619 }
5620 }
5621 norm
5622 };
5623 let spectral_radius = |matrix: &Mat2| -> Option<f64> {
5627 let mut vector = [1.0_f64; MAX_ORDER];
5628 let mut radius = 0.0_f64;
5629 let mut iterations = 0usize;
5630 while iterations < 500 {
5631 let mut next = [0.0_f64; MAX_ORDER];
5632 for i in 0..order {
5633 for k in 0..order {
5634 next[i] += matrix[i][k] * vector[k];
5635 }
5636 }
5637 let scale = next
5638 .iter()
5639 .take(order)
5640 .fold(0.0_f64, |widest, entry| widest.max(entry.abs()));
5641 if !(scale > 0.0 && scale.is_finite()) {
5642 return None;
5643 }
5644 for i in 0..order {
5645 vector[i] = next[i] / scale;
5646 }
5647 radius = scale;
5648 iterations += 1;
5649 }
5650 Some(radius)
5651 };
5652 let mut signed: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5653 let mut absolute: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5654 for i in 0..order {
5655 signed[i][i] = 1.0;
5656 absolute[i][i] = 1.0;
5657 }
5658 let mut log_lyapunov = 0.0_f64;
5659 let mut worst_step = 0.0_f64;
5660 let mut steps = 0usize;
5661 for t in (order + 1)..(nodes.len() - 1) {
5662 let (Some(gain), Some(before), Some(after)) =
5663 (gains.get(&t), predicted.get(&(t - 1)), predicted.get(&t))
5664 else {
5665 continue;
5666 };
5667 let delta = nodes[t + 1].x - nodes[t].x;
5668 let ball_f = ball_transition(Ball::exact(delta), order);
5669 let mut transition: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5670 let mut update: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5671 for i in 0..order {
5672 update[i][i] = 1.0;
5673 for j in 0..order {
5674 transition[i][j] = ball_f[i][j].value;
5675 }
5676 }
5677 for i in 0..order {
5678 update[i][0] -= gain[i];
5679 }
5680 let closed = mat_mul(&transition, &update, order);
5682 let mut next_signed: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5683 let mut next_absolute: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
5684 for i in 0..order {
5685 for j in 0..order {
5686 for k in 0..order {
5687 next_signed[i][j] += closed[i][k] * signed[k][j];
5688 next_absolute[i][j] += closed[i][k].abs() * absolute[k][j];
5689 }
5690 }
5691 }
5692 signed = next_signed;
5693 absolute = next_absolute;
5694 let Ok(inverse_after) = mat_inv(after, order, "lyapunov weight") else {
5696 continue;
5697 };
5698 let congruence = mat_mul(
5699 &mat_mul(&closed, before, order),
5700 &mat_t(&closed, order),
5701 order,
5702 );
5703 let Some(squared) = spectral_radius(&mat_mul(&inverse_after, &congruence, order))
5704 else {
5705 continue;
5706 };
5707 let factor = squared.max(0.0).sqrt();
5708 worst_step = worst_step.max(factor);
5709 log_lyapunov += factor.ln();
5710 steps += 1;
5711 if steps % 20 == 0 {
5712 eprintln!(
5713 "after {steps} steps: ||prod Psi|| = {:.6e}, ||prod |Psi||| = {:.6e}, \
5714 prod ||S_t|| = {:.6e}",
5715 max_norm(&signed),
5716 max_norm(&absolute),
5717 log_lyapunov.exp()
5718 );
5719 }
5720 }
5721 let contracted = max_norm(&signed);
5722 let inflated = max_norm(&absolute);
5723 let lyapunov = log_lyapunov.exp();
5724 eprintln!(
5725 "closed loop over {steps} steps: signed {contracted:.6e}, absolute {inflated:.6e}, \
5726 lyapunov {lyapunov:.6e}; per step signed {:.4}, absolute {:.4}, lyapunov {:.6}, \
5727 worst single step {worst_step:.6}",
5728 contracted.powf(1.0 / steps as f64),
5729 inflated.powf(1.0 / steps as f64),
5730 lyapunov.powf(1.0 / steps as f64)
5731 );
5732 assert!(
5733 contracted < 1.0,
5734 "the closed-loop product does not contract ({contracted:e} over {steps} steps); \
5735 the filter's own stability is the premise of every width argument here"
5736 );
5737 assert!(
5738 inflated > 1.0e10,
5739 "the absolute closed-loop product no longer explodes ({inflated:e} over {steps} \
5740 steps). If that is a repair, the recursion-level enclosures can be tightened \
5741 directly and this test is where the new factor is recorded"
5742 );
5743 assert!(
5744 worst_step <= 1.0 + 1.0e-9,
5745 "the Riccati identity `Psi P Psi^T + G = P_next` with `G >= 0` makes every \
5746 `||S_t||_2` at most one; the largest measured is {worst_step}, so either the \
5747 traced covariance is not the one the recursion produced or the identity is \
5748 being read wrong"
5749 );
5750 assert!(
5751 lyapunov >= contracted,
5752 "the Lyapunov product {lyapunov:e} must bound the signed product {contracted:e} \
5753 it stands in for"
5754 );
5755 }
5756
5757 #[test]
5767 fn centred_riccati_mean_enclosure_stays_below_search_resolution() {
5768 let (x, y, w) = dgp_2300();
5769 let order = 3;
5770 let log_lambda = -16.6135_f64;
5771 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
5772 let q_value =
5773 gam_problem::checked_exp_log_strength(-log_lambda).expect("inverse log strength");
5774 let q = Ball::certified(
5775 q_value,
5776 gam_math::score_opt::certified_exp(-log_lambda).expect("certified exponential"),
5777 );
5778 let mut trace: Vec<BallTraceRecord> = Vec::new();
5779 run_filter_ball_traced(&nodes, q, order, Some(&mut trace))
5780 .expect("the repaired filter must certify the former failure point");
5781 let mean: Vec<(usize, Ball)> = trace
5782 .iter()
5783 .filter(|(_, name, _)| *name == "mean_a0")
5784 .map(|(node, _, ball)| (*node, *ball))
5785 .collect();
5786 assert_eq!(
5787 mean.len(),
5788 nodes.len() - order,
5789 "every proper filter node must expose a mean certificate"
5790 );
5791 let resolution = f64::EPSILON.sqrt();
5792 let widest_value = mean
5793 .iter()
5794 .fold(0.0_f64, |widest, (_, ball)| widest.max(ball.value.abs()));
5795 assert!(
5796 widest_value < 1.0e2,
5797 "the filtered mean's VALUE left O(1) at order {order}, rho {log_lambda}: \
5798 {widest_value:e}"
5799 );
5800 for (node, ball) in mean {
5801 assert!(
5802 ball.is_finite(),
5803 "mean enclosure is non-finite at node {node}"
5804 );
5805 let width = ball.hi - ball.lo;
5806 let scaled_resolution = resolution * (1.0 + ball.value.abs());
5807 assert!(
5808 width <= scaled_resolution,
5809 "mean enclosure at node {node} is {width:e} wide, exceeding the \
5810 scale-aware search resolution {scaled_resolution:e}"
5811 );
5812 }
5813 certified_concentrated_criterion_jet(&nodes, within, n_obs, log_lambda, order)
5814 .expect("the criterion consuming the repaired pass must certify");
5815 }
5816
5817 fn concentrated_criterion(
5819 nodes: &[PooledNode],
5820 ssr_within: f64,
5821 n_obs: usize,
5822 log_lambda: f64,
5823 order: usize,
5824 ) -> Result<f64, String> {
5825 Ok(concentrated_criterion_jet(nodes, ssr_within, n_obs, log_lambda, order)?.0)
5826 }
5827 use super::*;
5828
5829 #[test]
5830 fn concentrated_score_jet_matches_test_only_differences() {
5831 let x = [0.0, 0.07, 0.19, 0.41, 0.41, 0.68, 1.0, 1.37];
5832 let y = [0.2, -0.4, 0.8, 0.1, 0.35, -0.2, 0.7, 0.15];
5833 let w = [1.0, 2.0, 0.7, 1.4, 0.9, 3.0, 1.2, 0.8];
5834 for order in 1..=MAX_ORDER {
5835 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pooled data");
5836 for &rho in &[-4.0, -0.3, 2.5] {
5837 let (value, d1, d2, d3) =
5838 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5839 .expect("analytic score jet");
5840 let h = 2.0e-4;
5843 let fm = concentrated_criterion(&nodes, within, n_obs, rho - h, order)
5844 .expect("left score");
5845 let fp = concentrated_criterion(&nodes, within, n_obs, rho + h, order)
5846 .expect("right score");
5847 let fm2 = concentrated_criterion(&nodes, within, n_obs, rho - 2.0 * h, order)
5848 .expect("far left score");
5849 let fp2 = concentrated_criterion(&nodes, within, n_obs, rho + 2.0 * h, order)
5850 .expect("far right score");
5851 let d1_fd = (fp - fm) / (2.0 * h);
5852 let d2_fd = (fp - 2.0 * value + fm) / (h * h);
5853 let d3_fd = (fp2 - 2.0 * fp + 2.0 * fm - fm2) / (2.0 * h * h * h);
5854 let left_ball =
5858 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho - h, order)
5859 .expect("left value ball");
5860 let right_ball =
5861 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho + h, order)
5862 .expect("right value ball");
5863 let finite_difference = right_ball
5864 .value
5865 .sub(left_ball.value)
5866 .div_positive(Ball::exact(2.0 * h));
5867 let proper_modes = (nodes.len() - order) as f64;
5868 let residual_dof = (n_obs - order) as f64;
5869 let third_bound = 0.5 * (0.25 * proper_modes + 6.0 * residual_dof);
5870 let truncation = third_bound * h * h / 6.0;
5871 let certified_center =
5872 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5873 .expect("center derivative ball");
5874 assert!(
5875 certified_center.derivative.hi >= finite_difference.lo - truncation
5876 && certified_center.derivative.lo <= finite_difference.hi + truncation,
5877 "order={order} rho={rho}: analytic derivative ball {:?} is disjoint \
5878 from independently value-differenced {:?} ± {truncation:e}",
5879 certified_center.derivative,
5880 finite_difference
5881 );
5882 let d1_scale = 1.0 + d1.abs().max(d1_fd.abs());
5883 let d2_scale = 1.0 + d2.abs().max(d2_fd.abs());
5884 let d3_scale = 1.0 + d3.abs().max(d3_fd.abs());
5885 assert!(
5886 (d1 - d1_fd).abs() <= 2.0e-6 * d1_scale,
5887 "order={order} rho={rho}: analytic d1={d1}, FD={d1_fd}"
5888 );
5889 assert!(
5890 (d2 - d2_fd).abs() <= 2.0e-4 * d2_scale,
5891 "order={order} rho={rho}: analytic d2={d2}, FD={d2_fd}"
5892 );
5893 assert!(
5894 (d3 - d3_fd).abs() <= 5.0e-3 * d3_scale,
5895 "order={order} rho={rho}: analytic d3={d3}, FD={d3_fd}"
5896 );
5897 }
5898 }
5899 }
5900
5901 #[test]
5902 fn directed_score_balls_contain_independent_scalar_jets_across_scales() {
5903 let base_x = [0.0, 0.03, 0.11, 0.27, 0.52, 0.81, 1.17, 1.6];
5904 let y = [2.0e3, -4.0e2, 8.0e2, 1.0e2, 3.5e2, -2.0e2, 7.0e2, 1.5e2];
5905 let w = [1.0e-4, 2.0e4, 0.7, 1.4e3, 9.0e-3, 3.0e2, 1.2, 8.0e-2];
5906 for order in 1..=MAX_ORDER {
5907 for scale in [1.0e-1_f64, 1.0, 1.0e2] {
5908 let x: Vec<f64> = base_x.iter().map(|value| scale * value).collect();
5909 let (nodes, within, n_obs) =
5910 pool_nodes(&x, &y, &w, order).expect("adversarial pooled data");
5911 let rho = (2 * order - 1) as f64 * scale.ln() + 0.35;
5912 let certified =
5913 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5914 .expect("directed score recurrence");
5915 let scalar = concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
5916 .expect("independent scalar recurrence");
5917 for (name, ball, reference) in [
5918 ("value", certified.value, scalar.0),
5919 ("derivative", certified.derivative, scalar.1),
5920 ("curvature", certified.curvature, scalar.2),
5921 ("third", certified.third, scalar.3),
5922 ] {
5923 assert!(
5924 ball.interval().contains(reference),
5925 "order={order} scale={scale:e}: scalar {name} {reference} escaped {ball:?}"
5926 );
5927 }
5928
5929 let point_sample = ScoreSample {
5930 x: rho,
5931 value: certified.jet.value,
5932 derivative: certified.jet.derivative,
5933 curvature: certified.jet.curvature,
5934 third: certified.jet.third,
5935 };
5936 let point_enclosure = concentrated_criterion_enclosure(
5937 nodes.len(),
5938 n_obs,
5939 point_sample,
5940 point_sample,
5941 certified,
5942 certified,
5943 order,
5944 )
5945 .expect("degenerate point enclosure");
5946 assert_eq!(
5947 point_enclosure.derivative,
5948 certified.derivative.interval(),
5949 "a zero-width cell must preserve the certified point derivative exactly"
5950 );
5951 assert_eq!(
5952 point_enclosure.curvature,
5953 certified.curvature.interval(),
5954 "a zero-width cell must preserve the certified point curvature exactly"
5955 );
5956 assert_eq!(
5957 point_enclosure.score.value,
5958 certified.value.interval(),
5959 "a zero-width cell must preserve the certified point score exactly"
5960 );
5961
5962 let rho_right = rho + 0.125;
5963 let right =
5964 certified_concentrated_criterion_jet(&nodes, within, n_obs, rho_right, order)
5965 .expect("right endpoint ball");
5966 let enclosure = concentrated_criterion_enclosure(
5967 nodes.len(),
5968 n_obs,
5969 ScoreSample {
5970 x: rho,
5971 value: certified.jet.value,
5972 derivative: certified.jet.derivative,
5973 curvature: certified.jet.curvature,
5974 third: certified.jet.third,
5975 },
5976 ScoreSample {
5977 x: rho_right,
5978 value: right.jet.value,
5979 derivative: right.jet.derivative,
5980 curvature: right.jet.curvature,
5981 third: right.jet.third,
5982 },
5983 certified,
5984 right,
5985 order,
5986 )
5987 .expect("endpoint-anchored enclosure");
5988 for certificate in [certified, right] {
5989 assert!(
5990 enclosure.derivative.lo <= certificate.derivative.lo
5991 && enclosure.derivative.hi >= certificate.derivative.hi,
5992 "exact endpoint derivative escaped the cell enclosure"
5993 );
5994 assert!(
5995 enclosure.curvature.lo <= certificate.curvature.lo
5996 && enclosure.curvature.hi >= certificate.curvature.hi,
5997 "exact endpoint curvature escaped the cell enclosure"
5998 );
5999 assert!(
6000 enclosure.score.value.lo <= certificate.value.lo
6001 && enclosure.score.value.hi >= certificate.value.hi,
6002 "exact endpoint score escaped the cell enclosure"
6003 );
6004 }
6005 }
6006 }
6007 }
6008
6009 #[test]
6016 fn nearest_endpoint_taylor_hull_contains_dense_cell_and_tightens_every_channel() {
6017 let n = 60usize;
6018 let mut x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
6019 x[7] = x[6];
6020 let y: Vec<f64> = x
6021 .iter()
6022 .enumerate()
6023 .map(|(i, &xi)| {
6024 (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
6025 })
6026 .collect();
6027 let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * (i % 3) as f64).collect();
6028 let order = 3usize;
6029 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pooled data");
6030 let lo = 13.759_277_343_75;
6031 let hi = 13.760_375_976_562_5;
6032 let left = certified_concentrated_criterion_jet(&nodes, within, n_obs, lo, order)
6033 .expect("left endpoint certificate");
6034 let right = certified_concentrated_criterion_jet(&nodes, within, n_obs, hi, order)
6035 .expect("right endpoint certificate");
6036 let sample = |rho: f64, certificate: CertifiedCriterionJet| ScoreSample {
6037 x: rho,
6038 value: certificate.jet.value,
6039 derivative: certificate.jet.derivative,
6040 curvature: certificate.jet.curvature,
6041 third: certificate.jet.third,
6042 };
6043 let nearest = concentrated_criterion_enclosure(
6044 nodes.len(),
6045 n_obs,
6046 sample(lo, left),
6047 sample(hi, right),
6048 left,
6049 right,
6050 order,
6051 )
6052 .expect("nearest-endpoint enclosure");
6053
6054 for step in 0..=256 {
6055 let rho = lo + (hi - lo) * step as f64 / 256.0;
6056 let (value, derivative, curvature, _) =
6057 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6058 .expect("independent scalar jet");
6059 assert!(
6060 nearest.score.value.contains(value),
6061 "dense score sample at rho={rho:.17} escaped {:?}",
6062 nearest.score.value
6063 );
6064 assert!(
6065 nearest.derivative.contains(derivative),
6066 "dense derivative sample at rho={rho:.17} escaped {:?}",
6067 nearest.derivative
6068 );
6069 assert!(
6070 nearest.curvature.contains(curvature),
6071 "dense curvature sample at rho={rho:.17} escaped {:?}",
6072 nearest.curvature
6073 );
6074 }
6075
6076 let width = Ball::exact(hi).sub(Ball::exact(lo));
6081 let width2 = width.square();
6082 let width3 = width2.mul(width);
6083 let width4 = width2.square();
6084 let fourth_abs_bound = Ball::exact((nodes.len() - order) as f64)
6085 .scale(0.25)
6086 .add(Ball::exact((n_obs - order) as f64).scale(26.0))
6087 .scale(0.5);
6088 let value_remainder = fourth_abs_bound
6089 .mul(width4)
6090 .div_positive(Ball::exact(24.0))
6091 .hi;
6092 let derivative_remainder = fourth_abs_bound
6093 .mul(width3)
6094 .div_positive(Ball::exact(6.0))
6095 .hi;
6096 let curvature_remainder = fourth_abs_bound.mul(width2).scale(0.5).hi;
6097 let full_cell_from_endpoint =
6098 |certificate: CertifiedCriterionJet, displacement: ClosedInterval| {
6099 let d = Ball::certified(0.0, displacement);
6100 let d2 = d.square();
6101 let d3 = d2.mul(d);
6102 let value = certificate
6103 .value
6104 .add(certificate.derivative.mul(d))
6105 .add(certificate.curvature.mul(d2).scale(0.5))
6106 .add(certificate.third.mul(d3).div_positive(Ball::exact(6.0)))
6107 .interval()
6108 .add(ClosedInterval::new(-value_remainder, value_remainder));
6109 let derivative = certificate
6110 .derivative
6111 .add(certificate.curvature.mul(d))
6112 .add(certificate.third.mul(d2).scale(0.5))
6113 .interval()
6114 .add(ClosedInterval::new(
6115 -derivative_remainder,
6116 derivative_remainder,
6117 ));
6118 let curvature = certificate
6119 .curvature
6120 .add(certificate.third.mul(d))
6121 .interval()
6122 .add(ClosedInterval::new(
6123 -curvature_remainder,
6124 curvature_remainder,
6125 ));
6126 (value, derivative, curvature)
6127 };
6128 let old_left = full_cell_from_endpoint(left, ClosedInterval::new(0.0, width.hi));
6129 let old_right = full_cell_from_endpoint(right, ClosedInterval::new(-width.hi, 0.0));
6130 let old_value = ClosedInterval::new(
6131 old_left.0.lo.min(old_right.0.lo),
6132 old_left.0.hi.max(old_right.0.hi),
6133 );
6134 let old_derivative = ClosedInterval::new(
6135 old_left.1.lo.min(old_right.1.lo),
6136 old_left.1.hi.max(old_right.1.hi),
6137 );
6138 let old_curvature = ClosedInterval::new(
6139 old_left.2.lo.min(old_right.2.lo),
6140 old_left.2.hi.max(old_right.2.hi),
6141 );
6142 for (name, tightened, full_width) in [
6143 ("score", nearest.score.value, old_value),
6144 ("derivative", nearest.derivative, old_derivative),
6145 ("curvature", nearest.curvature, old_curvature),
6146 ] {
6147 assert!(
6148 tightened.hi - tightened.lo < full_width.hi - full_width.lo,
6149 "nearest-endpoint {name} enclosure {tightened:?} was not strictly \
6150 narrower than full-width theorem {full_width:?}"
6151 );
6152 }
6153 assert!(
6154 nearest.derivative.hi < 0.0,
6155 "the corrected theorem must certify the live #2614 cell's negative slope: {:?}",
6156 nearest.derivative
6157 );
6158
6159 let shifted_lo = 16.126_831_054_687_5;
6166 let shifted_hi = 16.127_929_687_5;
6167 assert_eq!(
6168 shifted_hi - shifted_lo,
6169 hi - lo,
6170 "the old-theorem comparison below shares the measured dyadic width"
6171 );
6172 let shifted_left =
6173 certified_concentrated_criterion_jet(&nodes, within, n_obs, shifted_lo, order)
6174 .expect("shifted left endpoint certificate");
6175 let shifted_right =
6176 certified_concentrated_criterion_jet(&nodes, within, n_obs, shifted_hi, order)
6177 .expect("shifted right endpoint certificate");
6178 let shifted = concentrated_criterion_enclosure(
6179 nodes.len(),
6180 n_obs,
6181 sample(shifted_lo, shifted_left),
6182 sample(shifted_hi, shifted_right),
6183 shifted_left,
6184 shifted_right,
6185 order,
6186 )
6187 .expect("shifted endpoint-third enclosure");
6188 for step in 0..=256 {
6189 let rho = shifted_lo + (shifted_hi - shifted_lo) * step as f64 / 256.0;
6190 let (value, derivative, curvature, _) =
6191 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6192 .expect("shifted independent scalar jet");
6193 assert!(
6194 shifted.score.value.contains(value),
6195 "shifted dense score at rho={rho:.17} escaped {:?}",
6196 shifted.score.value
6197 );
6198 assert!(
6199 shifted.derivative.contains(derivative),
6200 "shifted dense derivative at rho={rho:.17} escaped {:?}",
6201 shifted.derivative
6202 );
6203 assert!(
6204 shifted.curvature.contains(curvature),
6205 "shifted dense curvature at rho={rho:.17} escaped {:?}",
6206 shifted.curvature
6207 );
6208 }
6209 let shifted_old_left =
6210 full_cell_from_endpoint(shifted_left, ClosedInterval::new(0.0, width.hi));
6211 let shifted_old_right =
6212 full_cell_from_endpoint(shifted_right, ClosedInterval::new(-width.hi, 0.0));
6213 for (name, tightened, old_left, old_right) in [
6214 (
6215 "score",
6216 shifted.score.value,
6217 shifted_old_left.0,
6218 shifted_old_right.0,
6219 ),
6220 (
6221 "derivative",
6222 shifted.derivative,
6223 shifted_old_left.1,
6224 shifted_old_right.1,
6225 ),
6226 (
6227 "curvature",
6228 shifted.curvature,
6229 shifted_old_left.2,
6230 shifted_old_right.2,
6231 ),
6232 ] {
6233 let full_width =
6234 ClosedInterval::new(old_left.lo.min(old_right.lo), old_left.hi.max(old_right.hi));
6235 assert!(
6236 tightened.hi - tightened.lo < full_width.hi - full_width.lo,
6237 "endpoint-third {name} enclosure {tightened:?} was not strictly \
6238 narrower than the full-width L4 theorem {full_width:?}"
6239 );
6240 }
6241 assert!(
6242 shifted.derivative.hi < 0.0,
6243 "the endpoint-third theorem must certify the shifted #2614 cell's \
6244 negative slope: {:?}",
6245 shifted.derivative
6246 );
6247 }
6248
6249 #[test]
6250 fn spline_consumer_preserves_a_valid_resolution_flat_optimum_category() {
6251 let optimum = ScoreSample {
6252 x: -0.25,
6253 value: 3.0,
6254 derivative: 0.0,
6255 curvature: 0.0,
6256 third: 0.0,
6257 };
6258 let bracket = ClosedInterval::new(-0.5, 0.0);
6259 let max_score_gap = 0.125;
6260 let score_resolution = 0.25;
6261 let search = ScoreSearchResult {
6262 optimum,
6263 location: ScoreOptimumLocation::ResolutionFlat(0),
6264 lower_boundary: ScoreSample { x: -1.0, ..optimum },
6265 upper_boundary: ScoreSample { x: 1.0, ..optimum },
6266 stationary_points: Vec::new(),
6267 resolution_flat_regions: vec![gam_math::score_opt::ResolutionFlatRegion {
6268 sample: optimum,
6269 bracket,
6270 score: ClosedInterval::new(2.875, 3.0),
6271 max_score_gap,
6272 score_resolution,
6273 }],
6274 dominated_regions: Vec::new(),
6275 value_certificate: gam_math::score_opt::GlobalScoreCertificate {
6276 selected: ClosedInterval::point(3.0),
6277 maximum: ClosedInterval::new(3.0, 3.125),
6278 maximum_excess: max_score_gap,
6279 comparison_resolution: score_resolution,
6280 },
6281 };
6282 assert_eq!(
6283 spline_optimum_proof(&search).expect("valid resolution-flat proof"),
6284 SplineOptimumProof::ResolutionFlat {
6285 bracket,
6286 max_score_gap,
6287 score_resolution,
6288 },
6289 "the spline consumer must preserve the producer's successful typed category"
6290 );
6291
6292 let mut invalid = search;
6293 invalid.resolution_flat_regions[0].max_score_gap =
6294 invalid.resolution_flat_regions[0].score_resolution + f64::EPSILON;
6295 assert!(
6296 matches!(
6297 spline_optimum_proof(&invalid),
6298 Err(SplineScoreProofError::Search(_))
6299 ),
6300 "a malformed producer certificate must still fail instead of being accepted"
6301 );
6302 }
6303
6304 #[test]
6305 fn spline_consumer_retains_the_producers_stationary_curvature_proof() {
6306 let optimum = ScoreSample {
6307 x: -9.084_292_923_99,
6308 value: 3.0,
6309 derivative: 0.0,
6310 curvature: -1.0,
6311 third: 0.0,
6312 };
6313 let bracket = ClosedInterval::new(-9.084_292_924_175_005, -9.084_292_923_812_374);
6314 let producer_curvature = ClosedInterval::new(-6.4, -0.2);
6315 let point_score = ScoreValueEnclosure {
6316 value: ClosedInterval::new(2.999, 3.001),
6317 evaluation_error: 0.001,
6318 };
6319 let search = ScoreSearchResult {
6320 optimum,
6321 location: ScoreOptimumLocation::Stationary(0),
6322 lower_boundary: ScoreSample {
6323 x: -10.0,
6324 ..optimum
6325 },
6326 upper_boundary: ScoreSample { x: -8.0, ..optimum },
6327 stationary_points: vec![gam_math::score_opt::StationaryPoint {
6328 sample: optimum,
6329 bracket,
6330 score: point_score,
6331 curvature: producer_curvature,
6332 }],
6333 resolution_flat_regions: Vec::new(),
6334 dominated_regions: Vec::new(),
6335 value_certificate: gam_math::score_opt::GlobalScoreCertificate {
6336 selected: point_score.value,
6337 maximum: point_score.value,
6338 maximum_excess: 0.0,
6339 comparison_resolution: 0.002,
6340 },
6341 };
6342 let SplineOptimumProof::Kkt { bracket: got, kind } =
6343 spline_optimum_proof(&search).expect("valid stationary proof")
6344 else {
6345 panic!("stationary producer category was not preserved");
6346 };
6347 assert_eq!(got, bracket);
6348 assert_eq!(
6349 kind,
6350 SplineKktKind::Stationary {
6351 curvature: producer_curvature,
6352 }
6353 );
6354
6355 let local_enclosure = DerivativeEnclosure {
6356 score: point_score,
6357 derivative: ClosedInterval::new(-1.2e-9, 1.2e-9),
6358 curvature: ClosedInterval::new(-6.39, 0.0064),
6361 };
6362 let (holds, consumed_curvature) = spline_kkt_holds(kind, local_enclosure);
6363 assert!(holds, "the final derivative still contains the unique root");
6364 assert_eq!(consumed_curvature, producer_curvature);
6365 }
6366
6367 #[test]
6368 fn derivative_secant_recovers_weighted_order3_root_curvature_sign() {
6369 let (x, y, w) = dgp_2300();
6370 let order = 3usize;
6371 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("weighted pool");
6372 let lo = -2.337_075_252_506_015;
6374 let hi = -2.337_040_920_230_624;
6375 let left = certified_concentrated_criterion_jet(&nodes, within, n_obs, lo, order)
6376 .expect("weighted left endpoint");
6377 let right = certified_concentrated_criterion_jet(&nodes, within, n_obs, hi, order)
6378 .expect("weighted right endpoint");
6379 assert!(
6380 left.curvature.interval().contains_zero() && right.curvature.interval().contains_zero(),
6381 "the oracle must exercise the loose direct covariance-d2 path"
6382 );
6383 let sample = |rho: f64, certificate: CertifiedCriterionJet| ScoreSample {
6384 x: rho,
6385 value: certificate.jet.value,
6386 derivative: certificate.jet.derivative,
6387 curvature: certificate.jet.curvature,
6388 third: certificate.jet.third,
6389 };
6390 let enclosure = concentrated_criterion_enclosure(
6391 nodes.len(),
6392 n_obs,
6393 sample(lo, left),
6394 sample(hi, right),
6395 left,
6396 right,
6397 order,
6398 )
6399 .expect("secant curvature enclosure");
6400 assert!(
6401 enclosure.curvature.hi < 0.0,
6402 "the derivative secant must recover strict concavity: {:?}",
6403 enclosure.curvature
6404 );
6405 assert!(
6406 enclosure.derivative.lo > 0.0,
6407 "integrating the secant curvature from both endpoints must preserve \
6408 the live cell's positive slope: {:?}",
6409 enclosure.derivative
6410 );
6411 for step in 0..=256 {
6412 let rho = lo + (hi - lo) * step as f64 / 256.0;
6413 let (_, derivative, curvature, _) =
6414 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
6415 .expect("independent weighted scalar jet");
6416 assert!(
6417 enclosure.derivative.contains(derivative),
6418 "weighted scalar derivative {derivative} at rho={rho:.17} escaped {:?}",
6419 enclosure.derivative
6420 );
6421 assert!(
6422 enclosure.curvature.contains(curvature),
6423 "weighted scalar curvature {curvature} at rho={rho:.17} escaped {:?}",
6424 enclosure.curvature
6425 );
6426 }
6427 }
6428
6429 #[test]
6430 fn score_proof_refuses_exactly_when_diffuse_innovation_ball_contains_zero() {
6431 assert_eq!(
6432 Ball::ZERO.square(),
6433 Ball::ZERO,
6434 "structural zero must survive squaring exactly"
6435 );
6436 assert_eq!(
6437 Ball::ONE.square(),
6438 Ball::ONE,
6439 "the exact unit covariance must not acquire artificial width"
6440 );
6441 let tiny = f64::from_bits(1);
6442 let nodes = [
6443 PooledNode {
6444 x: 0.0,
6445 y: 0.0,
6446 w: 1.0,
6447 },
6448 PooledNode {
6449 x: tiny,
6450 y: 1.0,
6451 w: 1.0,
6452 },
6453 PooledNode {
6454 x: 1.0,
6455 y: -1.0,
6456 w: 1.0,
6457 },
6458 ];
6459 let error = run_filter_ball(&nodes, Ball::ONE, 2)
6460 .expect_err("an underflow-wide diffuse innovation cannot be divided soundly");
6461 assert!(matches!(
6462 error,
6463 SplineScoreProofError::InnovationContainsZero {
6464 node: 1,
6465 kind: SplineInnovationKind::Diffuse,
6466 ..
6467 }
6468 ));
6469 }
6470
6471 fn round_trip_predict_bit_for_bit(order: usize) {
6480 let n = 60usize;
6481 let x: Vec<f64> = (0..n).map(|i| (i as f64) / (n as f64 - 1.0)).collect();
6482 let mut x = x;
6484 x[7] = x[6];
6485 let y: Vec<f64> = x
6486 .iter()
6487 .enumerate()
6488 .map(|(i, &xi)| {
6489 (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
6490 })
6491 .collect();
6492 let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * ((i % 3) as f64)).collect();
6493 let fit = fit_spline_scan(&x, &y, &w, order).expect("scan fit");
6494 assert_eq!(fit.order, order);
6495 assert_eq!(fit.training_sample_size(), n);
6498
6499 let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
6500 let state: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
6501 let restored = SplineScanFit::from_state(&state).expect("restore fit");
6502
6503 assert_eq!(fit.training_sample_size(), restored.training_sample_size());
6504 if order == 2 {
6505 let mut pre_change = serde_json::to_value(fit.to_state()).expect("serialize state");
6506 pre_change
6507 .as_object_mut()
6508 .expect("spline state serializes as an object")
6509 .remove("training_sample_size");
6510 assert!(
6511 serde_json::from_value::<SplineScanState>(pre_change).is_err(),
6512 "pre-training-size spline state must not deserialize"
6513 );
6514 let mut zero = serde_json::to_value(fit.to_state()).expect("serialize state");
6515 zero.as_object_mut()
6516 .expect("spline state serializes as an object")
6517 .insert("training_sample_size".to_string(), serde_json::json!(0));
6518 assert!(
6519 serde_json::from_value::<SplineScanState>(zero).is_err(),
6520 "zero training rows must not deserialize"
6521 );
6522 }
6523 assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
6524 assert_eq!(fit.knots, restored.knots);
6525 assert_eq!(fit.mean, restored.mean);
6526 assert_eq!(fit.var, restored.var);
6527 assert_eq!(fit.deriv, restored.deriv);
6528 assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
6529 assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
6530 assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
6531 for t in 0..fit.knots.len() {
6532 match (fit.deriv_at_knot(t), restored.deriv_at_knot(t)) {
6533 (Some((d0, v0)), Some((d1, v1))) => {
6534 assert!(order >= 2);
6535 assert_eq!(d0.to_bits(), d1.to_bits());
6536 assert_eq!(v0.to_bits(), v1.to_bits());
6537 }
6538 (None, None) => assert_eq!(order, 1),
6539 _ => panic!("derivative availability drifted across the persistence seam"),
6540 }
6541 }
6542 for &xq in &[-0.2, 0.0, 0.013, 0.5, x[6], 0.987, 1.0, 1.3] {
6544 let (m0, v0) = fit.predict(xq).expect("predict original");
6545 let (m1, v1) = restored.predict(xq).expect("predict restored");
6546 assert_eq!(
6547 m0.to_bits(),
6548 m1.to_bits(),
6549 "mean drift at x={xq} (m={order})"
6550 );
6551 assert_eq!(
6552 v0.to_bits(),
6553 v1.to_bits(),
6554 "variance drift at x={xq} (m={order})"
6555 );
6556 }
6557
6558 let mut bad = fit.to_state();
6560 bad.cov.truncate(bad.cov.len() - 1);
6561 SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
6562 let mut bad = fit.to_state();
6563 bad.sigma2 = -1.0;
6564 SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
6565 let mut bad = fit.to_state();
6566 bad.knots[2] = bad.knots[1];
6567 SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
6568 }
6569
6570 #[test]
6571 fn state_snapshot_round_trips_predict_and_training_sample_size_bit_for_bit() {
6572 round_trip_predict_bit_for_bit(2);
6573 }
6574
6575 #[test]
6577 fn state_snapshot_round_trips_predict_bit_for_bit_order1() {
6578 round_trip_predict_bit_for_bit(1);
6579 }
6580
6581 #[test]
6582 fn state_snapshot_round_trips_predict_bit_for_bit_order3() {
6583 round_trip_predict_bit_for_bit(3);
6584 }
6585
6586 fn hand_built_state(order: usize) -> SplineScanState {
6592 let knots = vec![0.0, 0.25, 0.6, 1.0, 1.4];
6593 let knot_count = knots.len();
6594 let tri = order * (order + 1) / 2;
6595 SplineScanState {
6596 order,
6597 state: (0..order * knot_count)
6598 .map(|i| 0.1 + 0.07 * i as f64)
6599 .collect(),
6600 cov: (0..tri * knot_count)
6602 .map(|i| {
6603 if i % tri == 0 {
6604 0.5 + 0.01 * i as f64
6605 } else {
6606 0.02
6607 }
6608 })
6609 .collect(),
6610 gain: (0..order * order * knot_count)
6611 .map(|i| 0.03 * ((i % 5) as f64))
6612 .collect(),
6613 node_weight: (0..knot_count).map(|i| 1.0 + 0.25 * i as f64).collect(),
6614 knots,
6615 log_lambda: 0.35,
6616 sigma2: 1.75,
6617 restricted_loglik: -12.5,
6618 training_sample_size: std::num::NonZeroU64::new(64).expect("64 is nonzero"),
6619 data_sse: 3.25,
6620 }
6621 }
6622
6623 #[test]
6641 fn persistence_seam_round_trips_without_the_optimizer_2614() {
6642 for order in 1..=MAX_ORDER {
6643 let built = hand_built_state(order);
6644 let fit = SplineScanFit::from_state(&built).expect("hand-built state must restore");
6645 let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
6646 let parsed: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
6647 let restored = SplineScanFit::from_state(&parsed).expect("restore fit");
6648
6649 assert_eq!(fit.order, restored.order, "order drifted (m={order})");
6650 assert_eq!(fit.knots, restored.knots, "knots drifted (m={order})");
6651 assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
6652 assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
6653 assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
6654 assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
6655 assert_eq!(fit.training_sample_size(), restored.training_sample_size());
6656
6657 for &xq in &[-0.3, 0.0, 0.13, 0.6, 1.0, 1.4, 1.9] {
6659 let (m0, v0) = fit.predict(xq).expect("predict original");
6660 let (m1, v1) = restored.predict(xq).expect("predict restored");
6661 assert_eq!(
6662 m0.to_bits(),
6663 m1.to_bits(),
6664 "mean drift at x={xq} (m={order})"
6665 );
6666 assert_eq!(
6667 v0.to_bits(),
6668 v1.to_bits(),
6669 "variance drift at x={xq} (m={order})"
6670 );
6671 }
6672
6673 let mut bad = fit.to_state();
6675 bad.cov.truncate(bad.cov.len() - 1);
6676 SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
6677 let mut bad = fit.to_state();
6678 bad.sigma2 = -1.0;
6679 SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
6680 let mut bad = fit.to_state();
6681 bad.knots[2] = bad.knots[1];
6682 SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
6683 }
6684 }
6685
6686 fn dense_rw_truth(x: &[f64], y: &[f64], w: &[f64], log_lambda: f64) -> (Vec<f64>, Vec<f64>) {
6692 let n = x.len();
6693 let q = (-log_lambda).exp();
6694 let mut prec = vec![vec![0.0_f64; n]; n];
6695 let mut rhs = vec![0.0_f64; n];
6696 for t in 0..n {
6697 prec[t][t] += w[t];
6698 rhs[t] += w[t] * y[t];
6699 }
6700 for t in 0..n - 1 {
6701 let p = 1.0 / (q * (x[t + 1] - x[t]));
6702 prec[t][t] += p;
6703 prec[t + 1][t + 1] += p;
6704 prec[t][t + 1] -= p;
6705 prec[t + 1][t] -= p;
6706 }
6707 let mut aug = prec.clone();
6709 let mut inv = vec![vec![0.0_f64; n]; n];
6710 for i in 0..n {
6711 inv[i][i] = 1.0;
6712 }
6713 for col in 0..n {
6714 let piv = (col..n)
6715 .max_by(|&a, &b| aug[a][col].abs().total_cmp(&aug[b][col].abs()))
6716 .unwrap();
6717 aug.swap(col, piv);
6718 inv.swap(col, piv);
6719 let d = aug[col][col];
6720 for k in 0..n {
6721 aug[col][k] /= d;
6722 inv[col][k] /= d;
6723 }
6724 for r in 0..n {
6725 if r == col {
6726 continue;
6727 }
6728 let f = aug[r][col];
6729 if f == 0.0 {
6730 continue;
6731 }
6732 for k in 0..n {
6733 aug[r][k] -= f * aug[col][k];
6734 inv[r][k] -= f * inv[col][k];
6735 }
6736 }
6737 }
6738 let mean: Vec<f64> = (0..n)
6739 .map(|i| (0..n).map(|j| inv[i][j] * rhs[j]).sum())
6740 .collect();
6741 let var: Vec<f64> = (0..n).map(|i| inv[i][i]).collect();
6742 (mean, var)
6743 }
6744
6745 #[test]
6749 fn order_one_scan_matches_dense_random_walk_posterior() {
6750 let n = 30usize;
6751 let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
6752 let y: Vec<f64> = x
6753 .iter()
6754 .enumerate()
6755 .map(|(i, &xi)| 2.0 * xi + 0.4 * (5.0 * xi).sin() + 0.05 * ((i * 13 % 7) as f64 - 3.0))
6756 .collect();
6757 let w = vec![1.0_f64; n];
6758 let fit = fit_spline_scan(&x, &y, &w, 1).expect("order-1 scan fit");
6759 assert_eq!(fit.order, 1);
6760
6761 let (mean, var) = dense_rw_truth(&x, &y, &w, fit.log_lambda);
6762 for t in 0..n {
6763 assert!(
6764 (fit.mean[t] - mean[t]).abs() <= 1e-7 * mean[t].abs().max(1e-3),
6765 "order-1 mean mismatch at {t}: scan={} dense={}",
6766 fit.mean[t],
6767 mean[t]
6768 );
6769 let se_scan = fit.var[t].sqrt();
6770 let se_dense = (var[t] * fit.sigma2).sqrt();
6771 assert!(
6772 (se_scan - se_dense).abs() <= 1e-7 * se_dense.max(1e-12),
6773 "order-1 SE mismatch at {t}: scan={se_scan} dense={se_dense}"
6774 );
6775 }
6776 let dense_edf: f64 = w.iter().zip(var.iter()).map(|(wt, vt)| wt * vt).sum();
6778 assert!(
6779 (fit.edf() - dense_edf).abs() <= 1e-7 * dense_edf.max(1e-12),
6780 "order-1 EDF mismatch: scan={} dense={dense_edf}",
6781 fit.edf()
6782 );
6783 assert!(fit.deriv.is_none());
6787 assert!(fit.deriv_at_knot(0).is_none());
6788 }
6789
6790 #[test]
6797 fn deviance_is_data_sse_not_penalized_quadratic() {
6798 let x = [0.0, 1.0];
6799 let y = [0.0, 1.0];
6800 let w = [1.0, 1.0];
6801 let fit = fit_spline_scan_at(&x, &y, &w, 0.0, None, 1).expect("order-1 fit");
6802 let manual: f64 = x
6804 .iter()
6805 .zip(&y)
6806 .zip(&w)
6807 .map(|((&xi, &yi), &wi)| {
6808 let (m, _) = fit.predict(xi).expect("predict at knot");
6809 wi * (yi - m) * (yi - m)
6810 })
6811 .sum();
6812 assert!(
6813 (fit.deviance() - manual).abs() <= 1e-12 * manual.max(1e-300),
6814 "deviance {} != recomputed data SSE {manual}",
6815 fit.deviance()
6816 );
6817 assert!(
6818 (fit.deviance() - 2.0 / 9.0).abs() < 1e-10,
6819 "deviance {} != 2/9",
6820 fit.deviance()
6821 );
6822 let reml_quadratic = fit.sigma2 * (fit.training_sample_size() as f64 - fit.order as f64);
6824 assert!(fit.deviance() < reml_quadratic);
6825 }
6826}