1use gam_math::score_opt::{
57 ClosedInterval, DerivativeEnclosure, ScoreJet, ScoreSample, maximize_score_1d,
58};
59
60#[derive(Clone, Copy, Debug)]
62struct PooledNode {
63 x: f64,
64 y: f64,
66 w: f64,
68}
69
70const LOG_LAMBDA_LO: f64 = -18.0;
72const LOG_LAMBDA_HI: f64 = 18.0;
73const INNOVATION_VAR_FLOOR: f64 = 1e-300;
75
76const MAX_ORDER: usize = 3;
84
85type Mat2 = [[f64; MAX_ORDER]; MAX_ORDER];
91type Vec2 = [f64; MAX_ORDER];
92
93#[inline]
94fn mat_mul(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
95 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
96 for i in 0..m {
97 for j in 0..m {
98 let mut acc = 0.0;
99 for k in 0..m {
100 acc += a[i][k] * b[k][j];
101 }
102 c[i][j] = acc;
103 }
104 }
105 c
106}
107
108#[inline]
109fn mat_t(a: &Mat2, m: usize) -> Mat2 {
110 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
111 for i in 0..m {
112 for j in 0..m {
113 c[i][j] = a[j][i];
114 }
115 }
116 c
117}
118
119#[inline]
120fn mat_vec(a: &Mat2, v: &Vec2, m: usize) -> Vec2 {
121 let mut out = [0.0; MAX_ORDER];
122 for i in 0..m {
123 let mut acc = 0.0;
124 for j in 0..m {
125 acc += a[i][j] * v[j];
126 }
127 out[i] = acc;
128 }
129 out
130}
131
132#[inline]
133fn mat_add(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
134 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
135 for i in 0..m {
136 for j in 0..m {
137 c[i][j] = a[i][j] + b[i][j];
138 }
139 }
140 c
141}
142
143#[inline]
144fn mat_sub(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
145 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
146 for i in 0..m {
147 for j in 0..m {
148 c[i][j] = a[i][j] - b[i][j];
149 }
150 }
151 c
152}
153
154fn mat_inv(a: &Mat2, m: usize, what: &str) -> Result<Mat2, String> {
158 let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
159 match m {
160 1 => {
161 let d = a[0][0];
162 if !(d.is_finite() && d.abs() > 0.0) {
163 return Err(format!("spline scan: singular 1x1 in {what} (a00={d})"));
164 }
165 out[0][0] = 1.0 / d;
166 }
167 2 => {
168 let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
169 if !(det.is_finite() && det.abs() > 0.0) {
170 return Err(format!("spline scan: singular 2x2 in {what} (det={det})"));
171 }
172 out[0][0] = a[1][1] / det;
173 out[0][1] = -a[0][1] / det;
174 out[1][0] = -a[1][0] / det;
175 out[1][1] = a[0][0] / det;
176 }
177 3 => {
178 let c00 = a[1][1] * a[2][2] - a[1][2] * a[2][1];
180 let c01 = a[1][2] * a[2][0] - a[1][0] * a[2][2];
181 let c02 = a[1][0] * a[2][1] - a[1][1] * a[2][0];
182 let det = a[0][0] * c00 + a[0][1] * c01 + a[0][2] * c02;
183 if !(det.is_finite() && det.abs() > 0.0) {
184 return Err(format!("spline scan: singular 3x3 in {what} (det={det})"));
185 }
186 let inv_det = 1.0 / det;
187 out[0][0] = c00 * inv_det;
189 out[0][1] = (a[0][2] * a[2][1] - a[0][1] * a[2][2]) * inv_det;
190 out[0][2] = (a[0][1] * a[1][2] - a[0][2] * a[1][1]) * inv_det;
191 out[1][0] = c01 * inv_det;
192 out[1][1] = (a[0][0] * a[2][2] - a[0][2] * a[2][0]) * inv_det;
193 out[1][2] = (a[0][2] * a[1][0] - a[0][0] * a[1][2]) * inv_det;
194 out[2][0] = c02 * inv_det;
195 out[2][1] = (a[0][1] * a[2][0] - a[0][0] * a[2][1]) * inv_det;
196 out[2][2] = (a[0][0] * a[1][1] - a[0][1] * a[1][0]) * inv_det;
197 }
198 _ => return Err(format!("spline scan: unsupported order {m} in {what}")),
199 }
200 Ok(out)
201}
202
203fn dense_spd_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
219 let d = a.len();
220 let s: Vec<f64> = (0..d)
222 .map(|i| {
223 let dii = a[i][i];
224 if dii.is_finite() && dii > 0.0 {
225 1.0 / dii.sqrt()
226 } else {
227 1.0
228 }
229 })
230 .collect();
231 let a_s: Vec<Vec<f64>> = (0..d)
232 .map(|i| (0..d).map(|j| s[i] * a[i][j] * s[j]).collect())
233 .collect();
234 let mut inv_s = gauss_jordan_inverse(&a_s, what)?;
236 let mut resid = vec![vec![0.0_f64; d]; d]; for i in 0..d {
240 for j in 0..d {
241 let mut ax = 0.0;
242 for k in 0..d {
243 ax += a_s[i][k] * inv_s[k][j];
244 }
245 resid[i][j] = f64::from(u8::from(i == j)) - ax;
246 }
247 }
248 let mut delta = vec![vec![0.0_f64; d]; d]; for i in 0..d {
250 for j in 0..d {
251 let mut acc = 0.0;
252 for k in 0..d {
253 acc += inv_s[i][k] * resid[k][j];
254 }
255 delta[i][j] = acc;
256 }
257 }
258 for i in 0..d {
259 for j in 0..d {
260 inv_s[i][j] += delta[i][j];
261 }
262 }
263 Ok((0..d)
265 .map(|i| (0..d).map(|j| s[i] * inv_s[i][j] * s[j]).collect())
266 .collect())
267}
268
269fn gauss_jordan_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
271 let d = a.len();
272 let mut aug = a.to_vec();
273 let mut inv = vec![vec![0.0_f64; d]; d];
274 for i in 0..d {
275 inv[i][i] = 1.0;
276 }
277 for col in 0..d {
278 let piv = (col..d)
279 .max_by(|&i, &j| aug[i][col].abs().total_cmp(&aug[j][col].abs()))
280 .unwrap();
281 let p = aug[piv][col];
282 if !(p.is_finite() && p.abs() > 0.0) {
283 return Err(format!(
284 "spline scan: singular {d}x{d} in {what} (pivot={p})"
285 ));
286 }
287 aug.swap(col, piv);
288 inv.swap(col, piv);
289 let d_piv = aug[col][col];
290 for k in 0..d {
291 aug[col][k] /= d_piv;
292 inv[col][k] /= d_piv;
293 }
294 for r in 0..d {
295 if r == col {
296 continue;
297 }
298 let f = aug[r][col];
299 if f == 0.0 {
300 continue;
301 }
302 for k in 0..d {
303 aug[r][k] -= f * aug[col][k];
304 inv[r][k] -= f * inv[col][k];
305 }
306 }
307 }
308 Ok(inv)
309}
310
311#[inline]
314fn factorial(k: usize) -> f64 {
315 (1..=k).map(|v| v as f64).product::<f64>().max(1.0)
316}
317
318#[inline]
322fn transition(delta: f64, m: usize) -> Mat2 {
323 let mut f = [[0.0; MAX_ORDER]; MAX_ORDER];
324 for i in 0..m {
325 for j in i..m {
326 f[i][j] = delta.powi((j - i) as i32) / factorial(j - i);
327 }
328 }
329 f
330}
331
332#[inline]
337fn process_noise(delta: f64, q: f64, m: usize) -> Mat2 {
338 let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
339 for i in 0..m {
340 for j in 0..m {
341 let p = 2 * m - 1 - i - j;
342 out[i][j] = q * delta.powi(p as i32)
343 / (factorial(m - 1 - i) * factorial(m - 1 - j) * (p as f64));
344 }
345 }
346 out
347}
348
349#[inline]
351fn symmetrize(a: &mut Mat2, m: usize) {
352 for i in 0..m {
353 for j in (i + 1)..m {
354 let off = 0.5 * (a[i][j] + a[j][i]);
355 a[i][j] = off;
356 a[j][i] = off;
357 }
358 }
359}
360
361struct FilterStep {
363 a_filt: Vec2,
365 p_filt: Mat2,
366 a_pred: Vec2,
368 p_pred: Mat2,
369}
370
371struct FilterPass {
373 steps: Vec<FilterStep>,
374 sum_log_f: f64,
376 sum_log_f_d1: f64,
382 sum_log_f_d2: f64,
383 sum_log_f_d3: f64,
384 sum_v2_over_f: f64,
386 sum_v2_over_f_d1: f64,
388 sum_v2_over_f_d2: f64,
389 sum_v2_over_f_d3: f64,
390 n_proper: usize,
392}
393
394fn run_filter<const RECORD_STEPS: bool>(
406 nodes: &[PooledNode],
407 q: f64,
408 order: usize,
409) -> Result<FilterPass, String> {
410 let n = nodes.len();
411 let mut steps = Vec::with_capacity(if RECORD_STEPS { n } else { 0 });
412 let mut a: Vec2 = [0.0; MAX_ORDER];
417 let mut a_d1: Vec2 = [0.0; MAX_ORDER];
418 let mut a_d2: Vec2 = [0.0; MAX_ORDER];
419 let mut a_d3: Vec2 = [0.0; MAX_ORDER];
420 let mut p_star: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
421 let mut p_star_d1: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
422 let mut p_star_d2: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
423 let mut p_star_d3: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
424 let mut p_inf: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
425 for i in 0..order {
426 p_inf[i][i] = 1.0;
427 }
428 let mut diffuse_rank = order;
429 let mut sum_log_f = 0.0;
430 let mut sum_log_f_d1 = 0.0;
431 let mut sum_log_f_d2 = 0.0;
432 let mut sum_log_f_d3 = 0.0;
433 let mut sum_v2_over_f = 0.0;
434 let mut sum_v2_over_f_d1 = 0.0;
435 let mut sum_v2_over_f_d2 = 0.0;
436 let mut sum_v2_over_f_d3 = 0.0;
437 let mut n_proper = 0usize;
438 for t in 0..n {
439 let a_pred = a;
440 let p_pred = p_star;
441 let r = 1.0 / nodes[t].w;
442 let v = nodes[t].y - a[0];
443 let v_d1 = -a_d1[0];
444 let v_d2 = -a_d2[0];
445 let v_d3 = -a_d3[0];
446 let mut m_star: Vec2 = [0.0; MAX_ORDER];
448 let mut m_star_d1: Vec2 = [0.0; MAX_ORDER];
449 let mut m_star_d2: Vec2 = [0.0; MAX_ORDER];
450 let mut m_star_d3: Vec2 = [0.0; MAX_ORDER];
451 for i in 0..order {
452 m_star[i] = p_star[i][0];
453 m_star_d1[i] = p_star_d1[i][0];
454 m_star_d2[i] = p_star_d2[i][0];
455 m_star_d3[i] = p_star_d3[i][0];
456 }
457 let f_star = m_star[0] + r;
458 let f_star_d1 = m_star_d1[0];
459 let f_star_d2 = m_star_d2[0];
460 let f_star_d3 = m_star_d3[0];
461 let mut proper_update = diffuse_rank == 0;
462 if diffuse_rank > 0 {
463 let mut m_inf: Vec2 = [0.0; MAX_ORDER];
464 for i in 0..order {
465 m_inf[i] = p_inf[i][0];
466 }
467 let f_inf = m_inf[0];
468 if f_inf > INNOVATION_VAR_FLOOR {
469 for i in 0..order {
473 let k_inf = m_inf[i] / f_inf;
474 a[i] += k_inf * v;
475 a_d1[i] += k_inf * v_d1;
476 a_d2[i] += k_inf * v_d2;
477 a_d3[i] += k_inf * v_d3;
478 }
479 let mut p_new = p_star;
480 let mut p_new_d1 = p_star_d1;
481 let mut p_new_d2 = p_star_d2;
482 let mut p_new_d3 = p_star_d3;
483 for i in 0..order {
484 for j in 0..order {
485 p_new[i][j] += -m_inf[i] * m_star[j] / f_inf - m_star[i] * m_inf[j] / f_inf
486 + m_inf[i] * m_inf[j] * f_star / (f_inf * f_inf);
487 p_new_d1[i][j] += -m_inf[i] * m_star_d1[j] / f_inf
488 - m_star_d1[i] * m_inf[j] / f_inf
489 + m_inf[i] * m_inf[j] * f_star_d1 / (f_inf * f_inf);
490 p_new_d2[i][j] += -m_inf[i] * m_star_d2[j] / f_inf
491 - m_star_d2[i] * m_inf[j] / f_inf
492 + m_inf[i] * m_inf[j] * f_star_d2 / (f_inf * f_inf);
493 p_new_d3[i][j] += -m_inf[i] * m_star_d3[j] / f_inf
494 - m_star_d3[i] * m_inf[j] / f_inf
495 + m_inf[i] * m_inf[j] * f_star_d3 / (f_inf * f_inf);
496 }
497 }
498 p_star = p_new;
499 p_star_d1 = p_new_d1;
500 p_star_d2 = p_new_d2;
501 p_star_d3 = p_new_d3;
502 symmetrize(&mut p_star, order);
503 symmetrize(&mut p_star_d1, order);
504 symmetrize(&mut p_star_d2, order);
505 symmetrize(&mut p_star_d3, order);
506 for i in 0..order {
507 for j in 0..order {
508 p_inf[i][j] -= m_inf[i] * m_inf[j] / f_inf;
509 }
510 }
511 symmetrize(&mut p_inf, order);
512 diffuse_rank -= 1;
513 if diffuse_rank == 0 {
514 p_inf = [[0.0; MAX_ORDER]; MAX_ORDER];
515 }
516 } else {
517 proper_update = true;
520 }
521 }
522 if proper_update {
523 if f_star <= INNOVATION_VAR_FLOOR {
524 return Err("spline scan: non-positive innovation variance".to_string());
525 }
526 let inv_f = 1.0 / f_star;
527 let mut gain = [0.0; MAX_ORDER];
532 let mut gain_d1 = [0.0; MAX_ORDER];
533 let mut gain_d2 = [0.0; MAX_ORDER];
534 let mut gain_d3 = [0.0; MAX_ORDER];
535 for i in 0..order {
536 gain[i] = m_star[i] * inv_f;
537 gain_d1[i] = (m_star_d1[i] - gain[i] * f_star_d1) * inv_f;
538 gain_d2[i] =
539 (m_star_d2[i] - 2.0 * gain_d1[i] * f_star_d1 - gain[i] * f_star_d2) * inv_f;
540 gain_d3[i] = (m_star_d3[i]
541 - 3.0 * gain_d2[i] * f_star_d1
542 - 3.0 * gain_d1[i] * f_star_d2
543 - gain[i] * f_star_d3)
544 * inv_f;
545 }
546 let a_old_d1 = a_d1;
547 let a_old_d2 = a_d2;
548 let a_old_d3 = a_d3;
549 for i in 0..order {
550 a[i] += gain[i] * v;
551 a_d1[i] = a_old_d1[i] + gain_d1[i] * v + gain[i] * v_d1;
552 a_d2[i] = a_old_d2[i] + gain_d2[i] * v + 2.0 * gain_d1[i] * v_d1 + gain[i] * v_d2;
553 a_d3[i] = a_old_d3[i]
554 + gain_d3[i] * v
555 + 3.0 * gain_d2[i] * v_d1
556 + 3.0 * gain_d1[i] * v_d2
557 + gain[i] * v_d3;
558 }
559 let mut p_new = p_star;
560 let mut p_new_d1 = p_star_d1;
561 let mut p_new_d2 = p_star_d2;
562 let mut p_new_d3 = p_star_d3;
563 for i in 0..order {
564 for j in 0..order {
565 let mm = m_star[i] * m_star[j];
566 let mm_d1 = m_star_d1[i] * m_star[j] + m_star[i] * m_star_d1[j];
567 let mm_d2 = m_star_d2[i] * m_star[j]
568 + 2.0 * m_star_d1[i] * m_star_d1[j]
569 + m_star[i] * m_star_d2[j];
570 let mm_d3 = m_star_d3[i] * m_star[j]
571 + 3.0 * m_star_d2[i] * m_star_d1[j]
572 + 3.0 * m_star_d1[i] * m_star_d2[j]
573 + m_star[i] * m_star_d3[j];
574 let s0 = mm * inv_f;
575 let s1 = (mm_d1 - s0 * f_star_d1) * inv_f;
576 let s2 = (mm_d2 - 2.0 * s1 * f_star_d1 - s0 * f_star_d2) * inv_f;
577 let s3 = (mm_d3
578 - 3.0 * s2 * f_star_d1
579 - 3.0 * s1 * f_star_d2
580 - s0 * f_star_d3)
581 * inv_f;
582 p_new[i][j] -= s0;
583 p_new_d1[i][j] -= s1;
584 p_new_d2[i][j] -= s2;
585 p_new_d3[i][j] -= s3;
586 }
587 }
588 p_star = p_new;
589 p_star_d1 = p_new_d1;
590 p_star_d2 = p_new_d2;
591 p_star_d3 = p_new_d3;
592 symmetrize(&mut p_star, order);
593 symmetrize(&mut p_star_d1, order);
594 symmetrize(&mut p_star_d2, order);
595 symmetrize(&mut p_star_d3, order);
596
597 let vv = v * v;
598 let vv_d1 = 2.0 * v * v_d1;
599 let vv_d2 = 2.0 * (v_d1 * v_d1 + v * v_d2);
600 let vv_d3 = 2.0 * (v * v_d3 + 3.0 * v_d1 * v_d2);
601 let logf_d1 = f_star_d1 * inv_f;
602 let logf_d2 = f_star_d2 * inv_f - logf_d1 * logf_d1;
603 let logf_d3 = f_star_d3 * inv_f - 3.0 * (f_star_d2 * inv_f) * logf_d1
604 + 2.0 * logf_d1 * logf_d1 * logf_d1;
605 sum_log_f += f_star.ln();
606 sum_log_f_d1 += logf_d1;
607 sum_log_f_d2 += logf_d2;
608 sum_log_f_d3 += logf_d3;
609 let t0 = vv * inv_f;
610 let t1 = (vv_d1 - t0 * f_star_d1) * inv_f;
611 let t2 = (vv_d2 - 2.0 * t1 * f_star_d1 - t0 * f_star_d2) * inv_f;
612 let t3 = (vv_d3 - 3.0 * t2 * f_star_d1 - 3.0 * t1 * f_star_d2 - t0 * f_star_d3)
613 * inv_f;
614 sum_v2_over_f += t0;
615 sum_v2_over_f_d1 += t1;
616 sum_v2_over_f_d2 += t2;
617 sum_v2_over_f_d3 += t3;
618 n_proper += 1;
619 }
620 if RECORD_STEPS {
621 steps.push(FilterStep {
622 a_filt: a,
623 p_filt: p_star,
624 a_pred,
625 p_pred,
626 });
627 }
628 if t + 1 < n {
630 let delta = nodes[t + 1].x - nodes[t].x;
631 let f_t = transition(delta, order);
632 a = mat_vec(&f_t, &a, order);
633 a_d1 = mat_vec(&f_t, &a_d1, order);
634 a_d2 = mat_vec(&f_t, &a_d2, order);
635 a_d3 = mat_vec(&f_t, &a_d3, order);
636 let f_t_t = mat_t(&f_t, order);
637 let q_noise = process_noise(delta, q, order);
638 let mut p_next = mat_add(
639 &mat_mul(&mat_mul(&f_t, &p_star, order), &f_t_t, order),
640 &q_noise,
641 order,
642 );
643 let mut p_next_d1 = mat_sub(
644 &mat_mul(&mat_mul(&f_t, &p_star_d1, order), &f_t_t, order),
645 &q_noise,
646 order,
647 );
648 let mut p_next_d2 = mat_add(
649 &mat_mul(&mat_mul(&f_t, &p_star_d2, order), &f_t_t, order),
650 &q_noise,
651 order,
652 );
653 let mut p_next_d3 = mat_sub(
655 &mat_mul(&mat_mul(&f_t, &p_star_d3, order), &f_t_t, order),
656 &q_noise,
657 order,
658 );
659 symmetrize(&mut p_next, order);
660 symmetrize(&mut p_next_d1, order);
661 symmetrize(&mut p_next_d2, order);
662 symmetrize(&mut p_next_d3, order);
663 p_star = p_next;
664 p_star_d1 = p_next_d1;
665 p_star_d2 = p_next_d2;
666 p_star_d3 = p_next_d3;
667 if diffuse_rank > 0 {
668 let mut pi_next =
669 mat_mul(&mat_mul(&f_t, &p_inf, order), &mat_t(&f_t, order), order);
670 symmetrize(&mut pi_next, order);
671 p_inf = pi_next;
672 }
673 }
674 }
675 Ok(FilterPass {
676 steps,
677 sum_log_f,
678 sum_log_f_d1,
679 sum_log_f_d2,
680 sum_log_f_d3,
681 sum_v2_over_f,
682 sum_v2_over_f_d1,
683 sum_v2_over_f_d2,
684 sum_v2_over_f_d3,
685 n_proper,
686 })
687}
688
689#[derive(Clone, Debug)]
691pub struct SplineScanFit {
692 pub order: usize,
696 pub knots: Vec<f64>,
698 pub mean: Vec<f64>,
700 pub deriv: Option<Vec<f64>>,
705 pub var: Vec<f64>,
707 log_lambda: f64,
709 pub sigma2: f64,
711 pub restricted_loglik: f64,
715 pub n_obs: usize,
718 pub data_sse: f64,
724 smoothed_state: Vec<Vec2>,
726 smoothed_cov: Vec<Mat2>,
728 rts_gain: Vec<Mat2>,
730 q: f64,
732 node_weight: Vec<f64>,
734}
735
736fn pool_nodes(
739 x: &[f64],
740 y: &[f64],
741 w: &[f64],
742 order: usize,
743) -> Result<(Vec<PooledNode>, f64, usize), String> {
744 let n = x.len();
745 if y.len() != n || w.len() != n {
746 return Err(format!(
747 "spline scan: length mismatch x={n}, y={}, w={}",
748 y.len(),
749 w.len()
750 ));
751 }
752 for i in 0..n {
753 if !(x[i].is_finite() && y[i].is_finite() && w[i].is_finite() && w[i] > 0.0) {
754 return Err(format!(
755 "spline scan: non-finite or non-positive input at row {i} (x={}, y={}, w={})",
756 x[i], y[i], w[i]
757 ));
758 }
759 }
760 let mut perm: Vec<usize> = (0..n).collect();
761 perm.sort_by(|&i, &j| x[i].total_cmp(&x[j]));
762 let mut nodes: Vec<PooledNode> = Vec::new();
763 for &i in &perm {
764 match nodes.last_mut() {
765 Some(last) if last.x == x[i] => {
766 let w_new = last.w + w[i];
767 last.y = (last.y * last.w + y[i] * w[i]) / w_new;
768 last.w = w_new;
769 }
770 _ => nodes.push(PooledNode {
771 x: x[i],
772 y: y[i],
773 w: w[i],
774 }),
775 }
776 }
777 if nodes.len() < order + 1 {
779 return Err(format!(
780 "spline scan: order {order} needs at least {} distinct abscissae, got {}",
781 order + 1,
782 nodes.len()
783 ));
784 }
785 let mut ssr_within = 0.0;
787 let mut k = 0usize;
788 for &i in &perm {
789 while nodes[k].x != x[i] {
790 k += 1;
791 }
792 let d = y[i] - nodes[k].y;
793 ssr_within += w[i] * d * d;
794 }
795 Ok((nodes, ssr_within, n))
796}
797
798fn concentrated_criterion_jet(
805 nodes: &[PooledNode],
806 ssr_within: f64,
807 n_obs: usize,
808 log_lambda: f64,
809 order: usize,
810) -> Result<(f64, f64, f64, f64), String> {
811 let q = gam_problem::checked_exp_log_strength(-log_lambda)
812 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
813 let pass = run_filter::<false>(nodes, q, order)?;
814 let dof = (n_obs - order) as f64;
817 let rss = pass.sum_v2_over_f + ssr_within;
818 if rss <= 0.0 {
819 return Err("spline scan: degenerate zero residual sum".to_string());
820 }
821 let sigma2 = rss / dof;
822 if pass.n_proper != nodes.len() - order {
823 return Err(format!(
824 "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
825 nodes.len() - order,
826 pass.n_proper
827 ));
828 }
829 let rss_d1 = pass.sum_v2_over_f_d1;
830 let rss_d2 = pass.sum_v2_over_f_d2;
831 let rss_d3 = pass.sum_v2_over_f_d3;
832 let rss_log_d1 = rss_d1 / rss;
833 let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
834 let rss_log_d3 = rss_d3 / rss - 3.0 * (rss_d2 / rss) * rss_log_d1
835 + 2.0 * rss_log_d1 * rss_log_d1 * rss_log_d1;
836 Ok((
837 -0.5 * (pass.sum_log_f + dof * sigma2.ln()),
838 -0.5 * (pass.sum_log_f_d1 + dof * rss_log_d1),
839 -0.5 * (pass.sum_log_f_d2 + dof * rss_log_d2),
840 -0.5 * (pass.sum_log_f_d3 + dof * rss_log_d3),
841 ))
842}
843
844fn concentrated_criterion_enclosure(
871 n_nodes: usize,
872 n_obs: usize,
873 left: ScoreSample,
874 right: ScoreSample,
875 order: usize,
876) -> Result<DerivativeEnclosure, String> {
877 let (lo, hi) = (left.x, right.x);
878 if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
879 return Err(format!(
880 "spline scan: invalid score-enclosure interval [{lo}, {hi}]"
881 ));
882 }
883 let width = hi - lo;
884 let proper_modes = (n_nodes - order) as f64;
885 let residual_dof = (n_obs - order) as f64;
886 let fourth_abs_bound = 0.5 * (0.25 * proper_modes + 26.0 * residual_dof);
887 let curvature_endpoint_abs = left.curvature.abs().max(right.curvature.abs());
902 let third_endpoint_abs = left.third.abs().max(right.third.abs());
903 let derivative_radius = curvature_endpoint_abs * width
904 + 0.5 * third_endpoint_abs * width * width
905 + fourth_abs_bound * width * width * width / 6.0;
906 let curvature_radius = third_endpoint_abs * width + 0.5 * fourth_abs_bound * width * width;
909 Ok(DerivativeEnclosure {
910 derivative: ClosedInterval::outward(
911 (left.derivative - derivative_radius).min(right.derivative - derivative_radius),
912 (left.derivative + derivative_radius).max(right.derivative + derivative_radius),
913 ),
914 curvature: ClosedInterval::outward(
915 (left.curvature - curvature_radius).min(right.curvature - curvature_radius),
916 (left.curvature + curvature_radius).max(right.curvature + curvature_radius),
917 ),
918 })
919}
920
921fn leading_block_smooth(
954 sm_state: &mut [Vec2],
955 sm_cov: &mut [Mat2],
956 gains: &mut [Mat2],
957 nodes: &[PooledNode],
958 q: f64,
959 order: usize,
960) -> Result<(), String> {
961 let nb = order - 1; let pin = order - 1; let d = nb * order; let mut lambda = vec![vec![0.0_f64; d]; d];
965 let mut b_const = vec![0.0_f64; d];
966 let mut bmat = vec![vec![0.0_f64; order]; d]; for t in 0..order - 1 {
970 let delta = nodes[t + 1].x - nodes[t].x;
971 let f = transition(delta, order);
972 let qn = process_noise(delta, q, order);
973 let a = mat_inv(&qn, order, "leading-block increment noise")?; let ft = mat_t(&f, order);
975 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 {
980 for j in 0..order {
981 lambda[t * order + i][t * order + j] += ftaf[i][j];
982 }
983 }
984 if t + 1 <= nb - 1 {
985 for i in 0..order {
988 for j in 0..order {
989 lambda[(t + 1) * order + i][(t + 1) * order + j] += a[i][j];
990 lambda[t * order + i][(t + 1) * order + j] -= fta[i][j];
991 lambda[(t + 1) * order + i][t * order + j] -= af[i][j];
992 }
993 }
994 } else {
995 for i in 0..order {
998 for j in 0..order {
999 bmat[t * order + i][j] += fta[i][j];
1000 }
1001 }
1002 }
1003 }
1004 for t in 0..nb {
1006 let w = nodes[t].w;
1007 lambda[t * order][t * order] += w;
1008 b_const[t * order] += w * nodes[t].y;
1009 }
1010
1011 let sigma = dense_spd_inverse(&lambda, "leading-block precision")?;
1013 let dvec: Vec<f64> = (0..d)
1014 .map(|i| (0..d).map(|k| sigma[i][k] * b_const[k]).sum())
1015 .collect();
1016 let cmat: Vec<Vec<f64>> = (0..d)
1017 .map(|i| {
1018 (0..order)
1019 .map(|j| (0..d).map(|k| sigma[i][k] * bmat[k][j]).sum())
1020 .collect()
1021 })
1022 .collect();
1023
1024 let ahat_p = sm_state[pin];
1026 let vp = sm_cov[pin];
1027 let cvp: Vec<Vec<f64>> = (0..d)
1029 .map(|i| {
1030 (0..order)
1031 .map(|j| (0..order).map(|k| cmat[i][k] * vp[k][j]).sum())
1032 .collect()
1033 })
1034 .collect();
1035 let mean_u: Vec<f64> = (0..d)
1037 .map(|i| (0..order).map(|j| cmat[i][j] * ahat_p[j]).sum::<f64>() + dvec[i])
1038 .collect();
1039 let cov_u: Vec<Vec<f64>> = (0..d)
1041 .map(|i| {
1042 (0..d)
1043 .map(|k| (0..order).map(|j| cvp[i][j] * cmat[k][j]).sum::<f64>() + sigma[i][k])
1044 .collect()
1045 })
1046 .collect();
1047
1048 for j in 0..nb {
1050 for i in 0..order {
1051 sm_state[j][i] = mean_u[j * order + i];
1052 }
1053 let mut cov = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1054 for i in 0..order {
1055 for k in 0..order {
1056 cov[i][k] = cov_u[j * order + i][j * order + k];
1057 }
1058 }
1059 symmetrize(&mut cov, order);
1060 sm_cov[j] = cov;
1061 }
1062 for j in 0..nb {
1066 let mut cross = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1067 if j + 1 <= nb - 1 {
1068 for i in 0..order {
1070 for k in 0..order {
1071 cross[i][k] = cov_u[j * order + i][(j + 1) * order + k];
1072 }
1073 }
1074 } else {
1075 for i in 0..order {
1077 for k in 0..order {
1078 cross[i][k] = cvp[j * order + i][k];
1079 }
1080 }
1081 }
1082 let denom_inv = mat_inv(&sm_cov[j + 1], order, "leading-block gain denominator")?;
1083 gains[j] = mat_mul(&cross, &denom_inv, order);
1084 }
1085 Ok(())
1086}
1087
1088pub fn fit_spline_scan_at(
1091 x: &[f64],
1092 y: &[f64],
1093 w: &[f64],
1094 log_lambda: f64,
1095 sigma2: Option<f64>,
1096 order: usize,
1097) -> Result<SplineScanFit, String> {
1098 if order == 0 || order > MAX_ORDER {
1099 return Err(format!(
1100 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
1101 ));
1102 }
1103 let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
1104 let q = gam_problem::checked_exp_log_strength(-log_lambda)
1105 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
1106 let pass = run_filter::<true>(&nodes, q, order)?;
1107 let n = nodes.len();
1108 let dof = (n_obs - order) as f64;
1109 let sigma2 = match sigma2 {
1110 Some(s) => {
1111 if !(s.is_finite() && s > 0.0) {
1112 return Err(format!("spline scan: invalid sigma2 {s}"));
1113 }
1114 s
1115 }
1116 None => (pass.sum_v2_over_f + ssr_within) / dof,
1117 };
1118 let rss = pass.sum_v2_over_f + ssr_within;
1123 let restricted_loglik = -0.5 * (pass.sum_log_f + dof * sigma2.ln() + rss / sigma2);
1124
1125 let mut sm_state = vec![[0.0_f64; MAX_ORDER]; n];
1136 let mut sm_cov = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
1137 let mut gains = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
1138 sm_state[n - 1] = pass.steps[n - 1].a_filt;
1139 sm_cov[n - 1] = pass.steps[n - 1].p_filt;
1140 for t in (order - 1..n - 1).rev() {
1141 let p_next_pred = &pass.steps[t + 1].p_pred;
1142 let delta = nodes[t + 1].x - nodes[t].x;
1143 let f_t = transition(delta, order);
1144 let p_inv = mat_inv(p_next_pred, order, "RTS predicted covariance")?;
1145 let g = mat_mul(
1146 &mat_mul(&pass.steps[t].p_filt, &mat_t(&f_t, order), order),
1147 &p_inv,
1148 order,
1149 );
1150 let mut dm: Vec2 = [0.0; MAX_ORDER];
1151 for i in 0..order {
1152 dm[i] = sm_state[t + 1][i] - pass.steps[t + 1].a_pred[i];
1153 }
1154 let corr = mat_vec(&g, &dm, order);
1155 for i in 0..order {
1156 sm_state[t][i] = pass.steps[t].a_filt[i] + corr[i];
1157 }
1158 let dp = mat_sub(&sm_cov[t + 1], p_next_pred, order);
1159 let mut cov = mat_add(
1160 &pass.steps[t].p_filt,
1161 &mat_mul(&mat_mul(&g, &dp, order), &mat_t(&g, order), order),
1162 order,
1163 );
1164 symmetrize(&mut cov, order);
1165 sm_cov[t] = cov;
1166 gains[t] = g;
1167 }
1168 if order >= 2 {
1171 leading_block_smooth(&mut sm_state, &mut sm_cov, &mut gains, &nodes, q, order)?;
1172 }
1173
1174 let knots: Vec<f64> = nodes.iter().map(|n| n.x).collect();
1175 let mean: Vec<f64> = sm_state.iter().map(|s| s[0]).collect();
1176 let deriv: Option<Vec<f64>> = (order >= 2).then(|| sm_state.iter().map(|s| s[1]).collect());
1179 let var: Vec<f64> = sm_cov.iter().map(|p| p[0][0] * sigma2).collect();
1180 let data_sse = ssr_within
1185 + nodes
1186 .iter()
1187 .zip(mean.iter())
1188 .map(|(node, &fhat)| {
1189 let r = node.y - fhat;
1190 node.w * r * r
1191 })
1192 .sum::<f64>();
1193 Ok(SplineScanFit {
1194 order,
1195 knots,
1196 mean,
1197 deriv,
1198 var,
1199 log_lambda,
1200 sigma2,
1201 restricted_loglik,
1202 n_obs,
1203 data_sse,
1204 smoothed_state: sm_state,
1205 smoothed_cov: sm_cov,
1206 rts_gain: gains,
1207 q,
1208 node_weight: nodes.iter().map(|n| n.w).collect(),
1209 })
1210}
1211
1212pub fn fit_spline_scan(
1217 x: &[f64],
1218 y: &[f64],
1219 w: &[f64],
1220 order: usize,
1221) -> Result<SplineScanFit, String> {
1222 if order == 0 || order > MAX_ORDER {
1223 return Err(format!(
1224 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
1225 ));
1226 }
1227 let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
1228 let span = nodes.last().map(|n| n.x).unwrap_or(0.0) - nodes.first().map(|n| n.x).unwrap_or(0.0);
1243 let scale_shift = if span.is_finite() && span > 0.0 {
1244 (2 * order - 1) as f64 * span.ln()
1245 } else {
1246 0.0
1247 };
1248 let lo_anchor = LOG_LAMBDA_LO + scale_shift;
1249 let hi_anchor = LOG_LAMBDA_HI + scale_shift;
1250 let n_nodes = nodes.len();
1251 let search = maximize_score_1d(
1252 lo_anchor,
1253 hi_anchor,
1254 f64::EPSILON.sqrt(),
1255 |ll| {
1256 concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order).map(
1257 |(value, derivative, curvature, third)| ScoreJet {
1258 value,
1259 derivative,
1260 curvature,
1261 third,
1262 },
1263 )
1264 },
1265 |left, right| concentrated_criterion_enclosure(n_nodes, n_obs, left, right, order),
1266 )
1267 .map_err(|error| format!("spline scan: REML stationary isolation failed: {error}"))?;
1268 fit_spline_scan_at(x, y, w, search.optimum.x, None, order)
1269}
1270
1271#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1285pub struct SplineScanState {
1286 #[serde(default = "default_spline_scan_order")]
1290 pub order: usize,
1291 pub knots: Vec<f64>,
1292 pub state: Vec<f64>,
1294 pub cov: Vec<f64>,
1297 pub gain: Vec<f64>,
1300 pub node_weight: Vec<f64>,
1302 pub log_lambda: f64,
1303 pub sigma2: f64,
1304 pub restricted_loglik: f64,
1305 pub n_obs: u64,
1307 pub data_sse: f64,
1312}
1313
1314fn default_spline_scan_order() -> usize {
1317 2
1318}
1319
1320impl SplineScanFit {
1321 pub fn to_state(&self) -> SplineScanState {
1323 let order = self.order;
1324 let tri = order * (order + 1) / 2;
1325 let nk = self.knots.len();
1326 let mut state = Vec::with_capacity(order * nk);
1327 for s in &self.smoothed_state {
1328 state.extend_from_slice(&s[..order]);
1329 }
1330 let mut cov = Vec::with_capacity(tri * nk);
1331 for c in &self.smoothed_cov {
1332 for i in 0..order {
1333 for j in i..order {
1334 cov.push(c[i][j]);
1335 }
1336 }
1337 }
1338 let mut gain = Vec::with_capacity(order * order * nk);
1339 for g in &self.rts_gain {
1340 for i in 0..order {
1341 for j in 0..order {
1342 gain.push(g[i][j]);
1343 }
1344 }
1345 }
1346 SplineScanState {
1347 order: self.order,
1348 knots: self.knots.clone(),
1349 state,
1350 cov,
1351 gain,
1352 node_weight: self.node_weight.clone(),
1353 log_lambda: self.log_lambda,
1354 sigma2: self.sigma2,
1355 restricted_loglik: self.restricted_loglik,
1356 n_obs: self.n_obs as u64,
1357 data_sse: self.data_sse,
1358 }
1359 }
1360
1361 pub fn from_state(state: &SplineScanState) -> Result<Self, String> {
1369 let order = state.order;
1370 if order == 0 || order > MAX_ORDER {
1371 return Err(format!(
1372 "spline scan state: order must be in 1..={MAX_ORDER}, got {order}"
1373 ));
1374 }
1375 let m = state.knots.len();
1376 if m < order + 1 {
1377 return Err(format!(
1378 "spline scan state: order {order} needs at least {} knots, got {m}",
1379 order + 1
1380 ));
1381 }
1382 let tri = order * (order + 1) / 2;
1383 if state.state.len() != order * m
1384 || state.cov.len() != tri * m
1385 || state.gain.len() != order * order * m
1386 || state.node_weight.len() != m
1387 {
1388 return Err(format!(
1389 "spline scan state: inconsistent lengths (order={order}, m={m}, state={}, cov={}, gain={}, weights={})",
1390 state.state.len(),
1391 state.cov.len(),
1392 state.gain.len(),
1393 state.node_weight.len()
1394 ));
1395 }
1396 let all = state
1397 .state
1398 .iter()
1399 .chain(&state.cov)
1400 .chain(&state.gain)
1401 .chain(&state.knots)
1402 .chain(&state.node_weight);
1403 for (i, v) in all.enumerate() {
1404 if !v.is_finite() {
1405 return Err(format!("spline scan state: non-finite entry at {i}"));
1406 }
1407 }
1408 gam_problem::validate_log_strength(state.log_lambda)
1409 .map_err(|error| format!("spline scan state: {error}"))?;
1410 if !(state.restricted_loglik.is_finite() && state.sigma2.is_finite() && state.sigma2 > 0.0)
1411 {
1412 return Err(format!(
1413 "spline scan state: invalid scalars (log_lambda={}, sigma2={}, restricted_loglik={})",
1414 state.log_lambda, state.sigma2, state.restricted_loglik
1415 ));
1416 }
1417 if !(state.data_sse.is_finite() && state.data_sse >= 0.0) {
1418 return Err(format!(
1419 "spline scan state: invalid data_sse {}",
1420 state.data_sse
1421 ));
1422 }
1423 if state.knots.windows(2).any(|kk| !(kk[0] < kk[1])) {
1424 return Err("spline scan state: knots must be strictly increasing".to_string());
1425 }
1426 if state.node_weight.iter().any(|&w| w <= 0.0) {
1427 return Err("spline scan state: node weights must be positive".to_string());
1428 }
1429 let smoothed_state: Vec<Vec2> = state
1430 .state
1431 .chunks_exact(order)
1432 .map(|s| {
1433 let mut v = [0.0_f64; MAX_ORDER];
1434 v[..order].copy_from_slice(s);
1435 v
1436 })
1437 .collect();
1438 let smoothed_cov: Vec<Mat2> = state
1439 .cov
1440 .chunks_exact(tri)
1441 .map(|c| {
1442 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1443 let mut idx = 0;
1444 for i in 0..order {
1445 for j in i..order {
1446 mm[i][j] = c[idx];
1447 mm[j][i] = c[idx];
1448 idx += 1;
1449 }
1450 }
1451 mm
1452 })
1453 .collect();
1454 let rts_gain: Vec<Mat2> = state
1455 .gain
1456 .chunks_exact(order * order)
1457 .map(|g| {
1458 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1459 for i in 0..order {
1460 for j in 0..order {
1461 mm[i][j] = g[i * order + j];
1462 }
1463 }
1464 mm
1465 })
1466 .collect();
1467 let sigma2 = state.sigma2;
1468 if state.n_obs == 0 {
1469 return Err("spline scan state: n_obs must be positive".to_string());
1470 }
1471 let n_obs = state.n_obs as usize;
1472 Ok(Self {
1473 order,
1474 knots: state.knots.clone(),
1475 mean: smoothed_state.iter().map(|s| s[0]).collect(),
1476 deriv: (order >= 2).then(|| smoothed_state.iter().map(|s| s[1]).collect()),
1477 var: smoothed_cov.iter().map(|c| c[0][0] * sigma2).collect(),
1478 log_lambda: state.log_lambda,
1479 sigma2,
1480 restricted_loglik: state.restricted_loglik,
1481 n_obs,
1482 data_sse: state.data_sse,
1483 smoothed_state,
1484 smoothed_cov,
1485 rts_gain,
1486 q: gam_problem::checked_exp_log_strength(-state.log_lambda)
1487 .map_err(|error| format!("spline scan inverse log strength: {error}"))?,
1488 node_weight: state.node_weight.clone(),
1489 })
1490 }
1491
1492 pub fn predict(&self, x_new: f64) -> Result<(f64, f64), String> {
1499 if !x_new.is_finite() {
1500 return Err("spline scan: non-finite prediction abscissa".to_string());
1501 }
1502 let n = self.knots.len();
1503 let order = self.order;
1504 let first = self.knots[0];
1505 let last = self.knots[n - 1];
1506 if x_new <= first {
1507 let delta = first - x_new;
1508 let f_t = transition(delta, order);
1510 let f_inv = mat_inv(&f_t, order, "backward extrapolation transition")?;
1511 let mean_s = mat_vec(&f_inv, &self.smoothed_state[0], order);
1512 let qm = process_noise(delta, self.q, order);
1513 let cov = mat_add(
1514 &mat_mul(
1515 &mat_mul(&f_inv, &self.smoothed_cov[0], order),
1516 &mat_t(&f_inv, order),
1517 order,
1518 ),
1519 &mat_mul(&mat_mul(&f_inv, &qm, order), &mat_t(&f_inv, order), order),
1520 order,
1521 );
1522 return Ok((mean_s[0], cov[0][0] * self.sigma2));
1523 }
1524 if x_new >= last {
1525 let delta = x_new - last;
1526 let f_t = transition(delta, order);
1527 let mean_s = mat_vec(&f_t, &self.smoothed_state[n - 1], order);
1528 let cov = mat_add(
1529 &mat_mul(
1530 &mat_mul(&f_t, &self.smoothed_cov[n - 1], order),
1531 &mat_t(&f_t, order),
1532 order,
1533 ),
1534 &process_noise(delta, self.q, order),
1535 order,
1536 );
1537 return Ok((mean_s[0], cov[0][0] * self.sigma2));
1538 }
1539 let t = match self.knots.binary_search_by(|k| k.total_cmp(&x_new)) {
1541 Ok(idx) => return Ok((self.mean[idx], self.var[idx])),
1542 Err(idx) => idx - 1,
1543 };
1544 let (xa, xb) = (self.knots[t], self.knots[t + 1]);
1545 let (d1, d2) = (x_new - xa, xb - x_new);
1546 let (f1m, f2m) = (transition(d1, order), transition(d2, order));
1547 let (q1, q2) = (
1548 process_noise(d1, self.q, order),
1549 process_noise(d2, self.q, order),
1550 );
1551 let q1_inv = mat_inv(&q1, order, "bridge left noise")?;
1552 let q2_inv = mat_inv(&q2, order, "bridge right noise")?;
1553 let lambda = mat_add(
1556 &q1_inv,
1557 &mat_mul(&mat_mul(&mat_t(&f2m, order), &q2_inv, order), &f2m, order),
1558 order,
1559 );
1560 let lam_inv = mat_inv(&lambda, order, "bridge precision")?;
1561 let ca = mat_mul(&lam_inv, &mat_mul(&q1_inv, &f1m, order), order);
1562 let cb = mat_mul(
1563 &lam_inv,
1564 &mat_mul(&mat_t(&f2m, order), &q2_inv, order),
1565 order,
1566 );
1567 let ma = mat_vec(&ca, &self.smoothed_state[t], order);
1568 let mb = mat_vec(&cb, &self.smoothed_state[t + 1], order);
1569 let mut mean_s = [0.0_f64; MAX_ORDER];
1570 for i in 0..order {
1571 mean_s[i] = ma[i] + mb[i];
1572 }
1573 let cross = mat_mul(&self.rts_gain[t], &self.smoothed_cov[t + 1], order);
1576 let mut cov = mat_add(
1577 &mat_add(
1578 &mat_mul(
1579 &mat_mul(&ca, &self.smoothed_cov[t], order),
1580 &mat_t(&ca, order),
1581 order,
1582 ),
1583 &mat_mul(
1584 &mat_mul(&cb, &self.smoothed_cov[t + 1], order),
1585 &mat_t(&cb, order),
1586 order,
1587 ),
1588 order,
1589 ),
1590 &lam_inv,
1591 order,
1592 );
1593 let cab = mat_mul(&mat_mul(&ca, &cross, order), &mat_t(&cb, order), order);
1594 cov = mat_add(&cov, &mat_add(&cab, &mat_t(&cab, order), order), order);
1595 symmetrize(&mut cov, order);
1596 Ok((mean_s[0], cov[0][0] * self.sigma2))
1597 }
1598
1599 pub fn edf(&self) -> f64 {
1613 self.node_weight
1614 .iter()
1615 .zip(self.smoothed_cov.iter())
1616 .map(|(w, c)| w * c[0][0])
1617 .sum()
1618 }
1619
1620 pub fn deriv_at_knot(&self, t: usize) -> Option<(f64, f64)> {
1627 (self.order >= 2).then(|| {
1628 (
1629 self.smoothed_state[t][1],
1630 self.smoothed_cov[t][1][1] * self.sigma2,
1631 )
1632 })
1633 }
1634
1635 pub fn lambda(&self) -> f64 {
1637 gam_problem::checked_exp_log_strength(self.log_lambda)
1638 .expect("SplineScanFit construction validates its private log strength")
1639 }
1640
1641 pub fn log_lambda(&self) -> f64 {
1642 self.log_lambda
1643 }
1644
1645 pub fn n_obs(&self) -> usize {
1647 self.n_obs
1648 }
1649
1650 pub fn deviance(&self) -> f64 {
1659 self.data_sse
1660 }
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665
1666 #[test]
1674 fn weighted_scan_dgp_2300_search_terminates_in_bounded_evaluations() {
1675 let n = 180usize;
1678 let mut state: u64 = 0x2300_2300_2300_2300;
1679 let mut next_unit = move || {
1680 state ^= state << 13;
1681 state ^= state >> 7;
1682 state ^= state << 17;
1683 (state >> 11) as f64 / (1u64 << 53) as f64
1684 };
1685 let mut x = Vec::with_capacity(n);
1686 let mut y = Vec::with_capacity(n);
1687 let mut w = Vec::with_capacity(n);
1688 for i in 0..n {
1689 let xi = -2.0 + 4.0 * (i as f64) / ((n - 1) as f64);
1690 let wi: f64 = if xi < 0.0 { 1.0 } else { 9.0 };
1691 let u1 = next_unit().max(1e-12);
1692 let u2 = next_unit();
1693 let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
1694 x.push(xi);
1695 w.push(wi);
1696 y.push(0.4 + (1.3 * xi).sin() + (0.45 / wi.sqrt()) * z);
1697 }
1698 for order in 1..=MAX_ORDER {
1706 let (nodes, ssr_within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
1707 let span = nodes.last().unwrap().x - nodes.first().unwrap().x;
1708 let scale_shift = (2 * order - 1) as f64 * span.ln();
1709 let lo = LOG_LAMBDA_LO + scale_shift;
1710 let hi = LOG_LAMBDA_HI + scale_shift;
1711
1712 let n_nodes = nodes.len();
1713 let evals = std::cell::Cell::new(0u64);
1714 let last_x = std::cell::Cell::new(f64::NAN);
1715 let budget = 2_000_000u64;
1716 let result = gam_math::score_opt::maximize_score_1d(
1717 lo,
1718 hi,
1719 f64::EPSILON.sqrt(),
1720 |ll| {
1721 let count = evals.get() + 1;
1722 evals.set(count);
1723 last_x.set(ll);
1724 assert!(
1725 count <= budget,
1726 "order-{order} certified scan search exceeded {budget} criterion \
1727 evaluations (last log-lambda sample {ll:.9}; bracket \
1728 [{lo:.3}, {hi:.3}]) — non-terminating subdivision reproduced"
1729 );
1730 concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order).map(
1731 |(value, derivative, curvature, third)| gam_math::score_opt::ScoreJet {
1732 value,
1733 derivative,
1734 curvature,
1735 third,
1736 },
1737 )
1738 },
1739 |a, b| concentrated_criterion_enclosure(n_nodes, n_obs, a, b, order),
1740 );
1741 match result {
1742 Ok(search) => {
1743 assert!(
1744 search.optimum.x.is_finite(),
1745 "order-{order} search must return a finite optimum"
1746 );
1747 }
1748 Err(error) => panic!(
1749 "order-{order} weighted scan search failed after {} evaluations \
1750 (last x {:.9}): {error:?}",
1751 evals.get(),
1752 last_x.get()
1753 ),
1754 }
1755 }
1756 }
1757 fn concentrated_criterion(
1759 nodes: &[PooledNode],
1760 ssr_within: f64,
1761 n_obs: usize,
1762 log_lambda: f64,
1763 order: usize,
1764 ) -> Result<f64, String> {
1765 Ok(concentrated_criterion_jet(nodes, ssr_within, n_obs, log_lambda, order)?.0)
1766 }
1767 use super::*;
1768
1769 #[test]
1770 fn concentrated_score_jet_matches_test_only_differences() {
1771 let x = [0.0, 0.07, 0.19, 0.41, 0.41, 0.68, 1.0, 1.37];
1772 let y = [0.2, -0.4, 0.8, 0.1, 0.35, -0.2, 0.7, 0.15];
1773 let w = [1.0, 2.0, 0.7, 1.4, 0.9, 3.0, 1.2, 0.8];
1774 for order in 1..=MAX_ORDER {
1775 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pooled data");
1776 for &rho in &[-4.0, -0.3, 2.5] {
1777 let (value, d1, d2, d3) =
1778 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
1779 .expect("analytic score jet");
1780 let h = 2.0e-4;
1783 let fm = concentrated_criterion(&nodes, within, n_obs, rho - h, order)
1784 .expect("left score");
1785 let fp = concentrated_criterion(&nodes, within, n_obs, rho + h, order)
1786 .expect("right score");
1787 let fm2 = concentrated_criterion(&nodes, within, n_obs, rho - 2.0 * h, order)
1788 .expect("far left score");
1789 let fp2 = concentrated_criterion(&nodes, within, n_obs, rho + 2.0 * h, order)
1790 .expect("far right score");
1791 let d1_fd = (fp - fm) / (2.0 * h);
1792 let d2_fd = (fp - 2.0 * value + fm) / (h * h);
1793 let d3_fd = (fp2 - 2.0 * fp + 2.0 * fm - fm2) / (2.0 * h * h * h);
1794 let d1_scale = 1.0 + d1.abs().max(d1_fd.abs());
1795 let d2_scale = 1.0 + d2.abs().max(d2_fd.abs());
1796 let d3_scale = 1.0 + d3.abs().max(d3_fd.abs());
1797 assert!(
1798 (d1 - d1_fd).abs() <= 2.0e-6 * d1_scale,
1799 "order={order} rho={rho}: analytic d1={d1}, FD={d1_fd}"
1800 );
1801 assert!(
1802 (d2 - d2_fd).abs() <= 2.0e-4 * d2_scale,
1803 "order={order} rho={rho}: analytic d2={d2}, FD={d2_fd}"
1804 );
1805 assert!(
1806 (d3 - d3_fd).abs() <= 5.0e-3 * d3_scale,
1807 "order={order} rho={rho}: analytic d3={d3}, FD={d3_fd}"
1808 );
1809 }
1810 }
1811 }
1812
1813 fn round_trip_predict_bit_for_bit(order: usize) {
1822 let n = 60usize;
1823 let x: Vec<f64> = (0..n).map(|i| (i as f64) / (n as f64 - 1.0)).collect();
1824 let mut x = x;
1826 x[7] = x[6];
1827 let y: Vec<f64> = x
1828 .iter()
1829 .enumerate()
1830 .map(|(i, &xi)| {
1831 (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
1832 })
1833 .collect();
1834 let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * ((i % 3) as f64)).collect();
1835 let fit = fit_spline_scan(&x, &y, &w, order).expect("scan fit");
1836 assert_eq!(fit.order, order);
1837 assert_eq!(fit.n_obs, n);
1840
1841 let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
1842 let state: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
1843 let restored = SplineScanFit::from_state(&state).expect("restore fit");
1844
1845 assert_eq!(fit.n_obs, restored.n_obs);
1846 assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
1847 assert_eq!(fit.knots, restored.knots);
1848 assert_eq!(fit.mean, restored.mean);
1849 assert_eq!(fit.var, restored.var);
1850 assert_eq!(fit.deriv, restored.deriv);
1851 assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
1852 assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
1853 assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
1854 for t in 0..fit.knots.len() {
1855 match (fit.deriv_at_knot(t), restored.deriv_at_knot(t)) {
1856 (Some((d0, v0)), Some((d1, v1))) => {
1857 assert!(order >= 2);
1858 assert_eq!(d0.to_bits(), d1.to_bits());
1859 assert_eq!(v0.to_bits(), v1.to_bits());
1860 }
1861 (None, None) => assert_eq!(order, 1),
1862 _ => panic!("derivative availability drifted across the persistence seam"),
1863 }
1864 }
1865 for &xq in &[-0.2, 0.0, 0.013, 0.5, x[6], 0.987, 1.0, 1.3] {
1867 let (m0, v0) = fit.predict(xq).expect("predict original");
1868 let (m1, v1) = restored.predict(xq).expect("predict restored");
1869 assert_eq!(
1870 m0.to_bits(),
1871 m1.to_bits(),
1872 "mean drift at x={xq} (m={order})"
1873 );
1874 assert_eq!(
1875 v0.to_bits(),
1876 v1.to_bits(),
1877 "variance drift at x={xq} (m={order})"
1878 );
1879 }
1880
1881 let mut bad = fit.to_state();
1883 bad.cov.truncate(bad.cov.len() - 1);
1884 SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
1885 let mut bad = fit.to_state();
1886 bad.sigma2 = -1.0;
1887 SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
1888 let mut bad = fit.to_state();
1889 bad.knots[2] = bad.knots[1];
1890 SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
1891 }
1892
1893 #[test]
1894 fn state_snapshot_round_trips_predict_bit_for_bit() {
1895 round_trip_predict_bit_for_bit(2);
1896 }
1897
1898 #[test]
1900 fn state_snapshot_round_trips_predict_bit_for_bit_order1() {
1901 round_trip_predict_bit_for_bit(1);
1902 }
1903
1904 #[test]
1905 fn state_snapshot_round_trips_predict_bit_for_bit_order3() {
1906 round_trip_predict_bit_for_bit(3);
1907 }
1908
1909 fn dense_rw_truth(x: &[f64], y: &[f64], w: &[f64], log_lambda: f64) -> (Vec<f64>, Vec<f64>) {
1915 let n = x.len();
1916 let q = (-log_lambda).exp();
1917 let mut prec = vec![vec![0.0_f64; n]; n];
1918 let mut rhs = vec![0.0_f64; n];
1919 for t in 0..n {
1920 prec[t][t] += w[t];
1921 rhs[t] += w[t] * y[t];
1922 }
1923 for t in 0..n - 1 {
1924 let p = 1.0 / (q * (x[t + 1] - x[t]));
1925 prec[t][t] += p;
1926 prec[t + 1][t + 1] += p;
1927 prec[t][t + 1] -= p;
1928 prec[t + 1][t] -= p;
1929 }
1930 let mut aug = prec.clone();
1932 let mut inv = vec![vec![0.0_f64; n]; n];
1933 for i in 0..n {
1934 inv[i][i] = 1.0;
1935 }
1936 for col in 0..n {
1937 let piv = (col..n)
1938 .max_by(|&a, &b| aug[a][col].abs().total_cmp(&aug[b][col].abs()))
1939 .unwrap();
1940 aug.swap(col, piv);
1941 inv.swap(col, piv);
1942 let d = aug[col][col];
1943 for k in 0..n {
1944 aug[col][k] /= d;
1945 inv[col][k] /= d;
1946 }
1947 for r in 0..n {
1948 if r == col {
1949 continue;
1950 }
1951 let f = aug[r][col];
1952 if f == 0.0 {
1953 continue;
1954 }
1955 for k in 0..n {
1956 aug[r][k] -= f * aug[col][k];
1957 inv[r][k] -= f * inv[col][k];
1958 }
1959 }
1960 }
1961 let mean: Vec<f64> = (0..n)
1962 .map(|i| (0..n).map(|j| inv[i][j] * rhs[j]).sum())
1963 .collect();
1964 let var: Vec<f64> = (0..n).map(|i| inv[i][i]).collect();
1965 (mean, var)
1966 }
1967
1968 #[test]
1972 fn order_one_scan_matches_dense_random_walk_posterior() {
1973 let n = 30usize;
1974 let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
1975 let y: Vec<f64> = x
1976 .iter()
1977 .enumerate()
1978 .map(|(i, &xi)| 2.0 * xi + 0.4 * (5.0 * xi).sin() + 0.05 * ((i * 13 % 7) as f64 - 3.0))
1979 .collect();
1980 let w = vec![1.0_f64; n];
1981 let fit = fit_spline_scan(&x, &y, &w, 1).expect("order-1 scan fit");
1982 assert_eq!(fit.order, 1);
1983
1984 let (mean, var) = dense_rw_truth(&x, &y, &w, fit.log_lambda);
1985 for t in 0..n {
1986 assert!(
1987 (fit.mean[t] - mean[t]).abs() <= 1e-7 * mean[t].abs().max(1e-3),
1988 "order-1 mean mismatch at {t}: scan={} dense={}",
1989 fit.mean[t],
1990 mean[t]
1991 );
1992 let se_scan = fit.var[t].sqrt();
1993 let se_dense = (var[t] * fit.sigma2).sqrt();
1994 assert!(
1995 (se_scan - se_dense).abs() <= 1e-7 * se_dense.max(1e-12),
1996 "order-1 SE mismatch at {t}: scan={se_scan} dense={se_dense}"
1997 );
1998 }
1999 let dense_edf: f64 = w.iter().zip(var.iter()).map(|(wt, vt)| wt * vt).sum();
2001 assert!(
2002 (fit.edf() - dense_edf).abs() <= 1e-7 * dense_edf.max(1e-12),
2003 "order-1 EDF mismatch: scan={} dense={dense_edf}",
2004 fit.edf()
2005 );
2006 assert!(fit.deriv.is_none());
2010 assert!(fit.deriv_at_knot(0).is_none());
2011 }
2012
2013 #[test]
2020 fn deviance_is_data_sse_not_penalized_quadratic() {
2021 let x = [0.0, 1.0];
2022 let y = [0.0, 1.0];
2023 let w = [1.0, 1.0];
2024 let fit = fit_spline_scan_at(&x, &y, &w, 0.0, None, 1).expect("order-1 fit");
2025 let manual: f64 = x
2027 .iter()
2028 .zip(&y)
2029 .zip(&w)
2030 .map(|((&xi, &yi), &wi)| {
2031 let (m, _) = fit.predict(xi).expect("predict at knot");
2032 wi * (yi - m) * (yi - m)
2033 })
2034 .sum();
2035 assert!(
2036 (fit.deviance() - manual).abs() <= 1e-12 * manual.max(1e-300),
2037 "deviance {} != recomputed data SSE {manual}",
2038 fit.deviance()
2039 );
2040 assert!(
2041 (fit.deviance() - 2.0 / 9.0).abs() < 1e-10,
2042 "deviance {} != 2/9",
2043 fit.deviance()
2044 );
2045 let reml_quadratic = fit.sigma2 * (fit.n_obs as f64 - fit.order as f64);
2047 assert!(fit.deviance() < reml_quadratic);
2048 }
2049}