1use gam_math::score_opt::{ClosedInterval, DerivativeEnclosure, ScoreJet, maximize_score_1d};
57
58#[derive(Clone, Copy, Debug)]
60struct PooledNode {
61 x: f64,
62 y: f64,
64 w: f64,
66}
67
68const LOG_LAMBDA_LO: f64 = -18.0;
70const LOG_LAMBDA_HI: f64 = 18.0;
71const INNOVATION_VAR_FLOOR: f64 = 1e-300;
73
74const MAX_ORDER: usize = 3;
82
83type Mat2 = [[f64; MAX_ORDER]; MAX_ORDER];
89type Vec2 = [f64; MAX_ORDER];
90
91#[inline]
92fn mat_mul(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
93 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
94 for i in 0..m {
95 for j in 0..m {
96 let mut acc = 0.0;
97 for k in 0..m {
98 acc += a[i][k] * b[k][j];
99 }
100 c[i][j] = acc;
101 }
102 }
103 c
104}
105
106#[inline]
107fn mat_t(a: &Mat2, m: usize) -> Mat2 {
108 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
109 for i in 0..m {
110 for j in 0..m {
111 c[i][j] = a[j][i];
112 }
113 }
114 c
115}
116
117#[inline]
118fn mat_vec(a: &Mat2, v: &Vec2, m: usize) -> Vec2 {
119 let mut out = [0.0; MAX_ORDER];
120 for i in 0..m {
121 let mut acc = 0.0;
122 for j in 0..m {
123 acc += a[i][j] * v[j];
124 }
125 out[i] = acc;
126 }
127 out
128}
129
130#[inline]
131fn mat_add(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
132 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
133 for i in 0..m {
134 for j in 0..m {
135 c[i][j] = a[i][j] + b[i][j];
136 }
137 }
138 c
139}
140
141#[inline]
142fn mat_sub(a: &Mat2, b: &Mat2, m: usize) -> Mat2 {
143 let mut c = [[0.0; MAX_ORDER]; MAX_ORDER];
144 for i in 0..m {
145 for j in 0..m {
146 c[i][j] = a[i][j] - b[i][j];
147 }
148 }
149 c
150}
151
152fn mat_inv(a: &Mat2, m: usize, what: &str) -> Result<Mat2, String> {
156 let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
157 match m {
158 1 => {
159 let d = a[0][0];
160 if !(d.is_finite() && d.abs() > 0.0) {
161 return Err(format!("spline scan: singular 1x1 in {what} (a00={d})"));
162 }
163 out[0][0] = 1.0 / d;
164 }
165 2 => {
166 let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
167 if !(det.is_finite() && det.abs() > 0.0) {
168 return Err(format!("spline scan: singular 2x2 in {what} (det={det})"));
169 }
170 out[0][0] = a[1][1] / det;
171 out[0][1] = -a[0][1] / det;
172 out[1][0] = -a[1][0] / det;
173 out[1][1] = a[0][0] / det;
174 }
175 3 => {
176 let c00 = a[1][1] * a[2][2] - a[1][2] * a[2][1];
178 let c01 = a[1][2] * a[2][0] - a[1][0] * a[2][2];
179 let c02 = a[1][0] * a[2][1] - a[1][1] * a[2][0];
180 let det = a[0][0] * c00 + a[0][1] * c01 + a[0][2] * c02;
181 if !(det.is_finite() && det.abs() > 0.0) {
182 return Err(format!("spline scan: singular 3x3 in {what} (det={det})"));
183 }
184 let inv_det = 1.0 / det;
185 out[0][0] = c00 * inv_det;
187 out[0][1] = (a[0][2] * a[2][1] - a[0][1] * a[2][2]) * inv_det;
188 out[0][2] = (a[0][1] * a[1][2] - a[0][2] * a[1][1]) * inv_det;
189 out[1][0] = c01 * inv_det;
190 out[1][1] = (a[0][0] * a[2][2] - a[0][2] * a[2][0]) * inv_det;
191 out[1][2] = (a[0][2] * a[1][0] - a[0][0] * a[1][2]) * inv_det;
192 out[2][0] = c02 * inv_det;
193 out[2][1] = (a[0][1] * a[2][0] - a[0][0] * a[2][1]) * inv_det;
194 out[2][2] = (a[0][0] * a[1][1] - a[0][1] * a[1][0]) * inv_det;
195 }
196 _ => return Err(format!("spline scan: unsupported order {m} in {what}")),
197 }
198 Ok(out)
199}
200
201fn dense_spd_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
217 let d = a.len();
218 let s: Vec<f64> = (0..d)
220 .map(|i| {
221 let dii = a[i][i];
222 if dii.is_finite() && dii > 0.0 {
223 1.0 / dii.sqrt()
224 } else {
225 1.0
226 }
227 })
228 .collect();
229 let a_s: Vec<Vec<f64>> = (0..d)
230 .map(|i| (0..d).map(|j| s[i] * a[i][j] * s[j]).collect())
231 .collect();
232 let mut inv_s = gauss_jordan_inverse(&a_s, what)?;
234 let mut resid = vec![vec![0.0_f64; d]; d]; for i in 0..d {
238 for j in 0..d {
239 let mut ax = 0.0;
240 for k in 0..d {
241 ax += a_s[i][k] * inv_s[k][j];
242 }
243 resid[i][j] = f64::from(u8::from(i == j)) - ax;
244 }
245 }
246 let mut delta = vec![vec![0.0_f64; d]; d]; for i in 0..d {
248 for j in 0..d {
249 let mut acc = 0.0;
250 for k in 0..d {
251 acc += inv_s[i][k] * resid[k][j];
252 }
253 delta[i][j] = acc;
254 }
255 }
256 for i in 0..d {
257 for j in 0..d {
258 inv_s[i][j] += delta[i][j];
259 }
260 }
261 Ok((0..d)
263 .map(|i| (0..d).map(|j| s[i] * inv_s[i][j] * s[j]).collect())
264 .collect())
265}
266
267fn gauss_jordan_inverse(a: &[Vec<f64>], what: &str) -> Result<Vec<Vec<f64>>, String> {
269 let d = a.len();
270 let mut aug = a.to_vec();
271 let mut inv = vec![vec![0.0_f64; d]; d];
272 for i in 0..d {
273 inv[i][i] = 1.0;
274 }
275 for col in 0..d {
276 let piv = (col..d)
277 .max_by(|&i, &j| aug[i][col].abs().total_cmp(&aug[j][col].abs()))
278 .unwrap();
279 let p = aug[piv][col];
280 if !(p.is_finite() && p.abs() > 0.0) {
281 return Err(format!(
282 "spline scan: singular {d}x{d} in {what} (pivot={p})"
283 ));
284 }
285 aug.swap(col, piv);
286 inv.swap(col, piv);
287 let d_piv = aug[col][col];
288 for k in 0..d {
289 aug[col][k] /= d_piv;
290 inv[col][k] /= d_piv;
291 }
292 for r in 0..d {
293 if r == col {
294 continue;
295 }
296 let f = aug[r][col];
297 if f == 0.0 {
298 continue;
299 }
300 for k in 0..d {
301 aug[r][k] -= f * aug[col][k];
302 inv[r][k] -= f * inv[col][k];
303 }
304 }
305 }
306 Ok(inv)
307}
308
309#[inline]
312fn factorial(k: usize) -> f64 {
313 (1..=k).map(|v| v as f64).product::<f64>().max(1.0)
314}
315
316#[inline]
320fn transition(delta: f64, m: usize) -> Mat2 {
321 let mut f = [[0.0; MAX_ORDER]; MAX_ORDER];
322 for i in 0..m {
323 for j in i..m {
324 f[i][j] = delta.powi((j - i) as i32) / factorial(j - i);
325 }
326 }
327 f
328}
329
330#[inline]
335fn process_noise(delta: f64, q: f64, m: usize) -> Mat2 {
336 let mut out = [[0.0; MAX_ORDER]; MAX_ORDER];
337 for i in 0..m {
338 for j in 0..m {
339 let p = 2 * m - 1 - i - j;
340 out[i][j] = q * delta.powi(p as i32)
341 / (factorial(m - 1 - i) * factorial(m - 1 - j) * (p as f64));
342 }
343 }
344 out
345}
346
347#[inline]
349fn symmetrize(a: &mut Mat2, m: usize) {
350 for i in 0..m {
351 for j in (i + 1)..m {
352 let off = 0.5 * (a[i][j] + a[j][i]);
353 a[i][j] = off;
354 a[j][i] = off;
355 }
356 }
357}
358
359struct FilterStep {
361 a_filt: Vec2,
363 p_filt: Mat2,
364 a_pred: Vec2,
366 p_pred: Mat2,
367}
368
369struct FilterPass {
371 steps: Vec<FilterStep>,
372 sum_log_f: f64,
374 sum_log_f_d1: f64,
380 sum_log_f_d2: f64,
381 sum_log_f_d3: f64,
382 sum_v2_over_f: f64,
384 sum_v2_over_f_d1: f64,
386 sum_v2_over_f_d2: f64,
387 sum_v2_over_f_d3: f64,
388 n_proper: usize,
390}
391
392fn run_filter(nodes: &[PooledNode], q: f64, order: usize) -> Result<FilterPass, String> {
393 let n = nodes.len();
394 let mut steps = Vec::with_capacity(n);
395 let mut a: Vec2 = [0.0; MAX_ORDER];
400 let mut a_d1: Vec2 = [0.0; MAX_ORDER];
401 let mut a_d2: Vec2 = [0.0; MAX_ORDER];
402 let mut a_d3: Vec2 = [0.0; MAX_ORDER];
403 let mut p_star: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
404 let mut p_star_d1: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
405 let mut p_star_d2: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
406 let mut p_star_d3: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
407 let mut p_inf: Mat2 = [[0.0; MAX_ORDER]; MAX_ORDER];
408 for i in 0..order {
409 p_inf[i][i] = 1.0;
410 }
411 let mut diffuse_rank = order;
412 let mut sum_log_f = 0.0;
413 let mut sum_log_f_d1 = 0.0;
414 let mut sum_log_f_d2 = 0.0;
415 let mut sum_log_f_d3 = 0.0;
416 let mut sum_v2_over_f = 0.0;
417 let mut sum_v2_over_f_d1 = 0.0;
418 let mut sum_v2_over_f_d2 = 0.0;
419 let mut sum_v2_over_f_d3 = 0.0;
420 let mut n_proper = 0usize;
421 for t in 0..n {
422 let a_pred = a;
423 let p_pred = p_star;
424 let r = 1.0 / nodes[t].w;
425 let v = nodes[t].y - a[0];
426 let v_d1 = -a_d1[0];
427 let v_d2 = -a_d2[0];
428 let v_d3 = -a_d3[0];
429 let mut m_star: Vec2 = [0.0; MAX_ORDER];
431 let mut m_star_d1: Vec2 = [0.0; MAX_ORDER];
432 let mut m_star_d2: Vec2 = [0.0; MAX_ORDER];
433 let mut m_star_d3: Vec2 = [0.0; MAX_ORDER];
434 for i in 0..order {
435 m_star[i] = p_star[i][0];
436 m_star_d1[i] = p_star_d1[i][0];
437 m_star_d2[i] = p_star_d2[i][0];
438 m_star_d3[i] = p_star_d3[i][0];
439 }
440 let f_star = m_star[0] + r;
441 let f_star_d1 = m_star_d1[0];
442 let f_star_d2 = m_star_d2[0];
443 let f_star_d3 = m_star_d3[0];
444 let mut proper_update = diffuse_rank == 0;
445 if diffuse_rank > 0 {
446 let mut m_inf: Vec2 = [0.0; MAX_ORDER];
447 for i in 0..order {
448 m_inf[i] = p_inf[i][0];
449 }
450 let f_inf = m_inf[0];
451 if f_inf > INNOVATION_VAR_FLOOR {
452 for i in 0..order {
456 let k_inf = m_inf[i] / f_inf;
457 a[i] += k_inf * v;
458 a_d1[i] += k_inf * v_d1;
459 a_d2[i] += k_inf * v_d2;
460 a_d3[i] += k_inf * v_d3;
461 }
462 let mut p_new = p_star;
463 let mut p_new_d1 = p_star_d1;
464 let mut p_new_d2 = p_star_d2;
465 let mut p_new_d3 = p_star_d3;
466 for i in 0..order {
467 for j in 0..order {
468 p_new[i][j] += -m_inf[i] * m_star[j] / f_inf - m_star[i] * m_inf[j] / f_inf
469 + m_inf[i] * m_inf[j] * f_star / (f_inf * f_inf);
470 p_new_d1[i][j] += -m_inf[i] * m_star_d1[j] / f_inf
471 - m_star_d1[i] * m_inf[j] / f_inf
472 + m_inf[i] * m_inf[j] * f_star_d1 / (f_inf * f_inf);
473 p_new_d2[i][j] += -m_inf[i] * m_star_d2[j] / f_inf
474 - m_star_d2[i] * m_inf[j] / f_inf
475 + m_inf[i] * m_inf[j] * f_star_d2 / (f_inf * f_inf);
476 p_new_d3[i][j] += -m_inf[i] * m_star_d3[j] / f_inf
477 - m_star_d3[i] * m_inf[j] / f_inf
478 + m_inf[i] * m_inf[j] * f_star_d3 / (f_inf * f_inf);
479 }
480 }
481 p_star = p_new;
482 p_star_d1 = p_new_d1;
483 p_star_d2 = p_new_d2;
484 p_star_d3 = p_new_d3;
485 symmetrize(&mut p_star, order);
486 symmetrize(&mut p_star_d1, order);
487 symmetrize(&mut p_star_d2, order);
488 symmetrize(&mut p_star_d3, order);
489 for i in 0..order {
490 for j in 0..order {
491 p_inf[i][j] -= m_inf[i] * m_inf[j] / f_inf;
492 }
493 }
494 symmetrize(&mut p_inf, order);
495 diffuse_rank -= 1;
496 if diffuse_rank == 0 {
497 p_inf = [[0.0; MAX_ORDER]; MAX_ORDER];
498 }
499 } else {
500 proper_update = true;
503 }
504 }
505 if proper_update {
506 if f_star <= INNOVATION_VAR_FLOOR {
507 return Err("spline scan: non-positive innovation variance".to_string());
508 }
509 let inv_f = 1.0 / f_star;
510 let mut gain = [0.0; MAX_ORDER];
515 let mut gain_d1 = [0.0; MAX_ORDER];
516 let mut gain_d2 = [0.0; MAX_ORDER];
517 let mut gain_d3 = [0.0; MAX_ORDER];
518 for i in 0..order {
519 gain[i] = m_star[i] * inv_f;
520 gain_d1[i] = (m_star_d1[i] - gain[i] * f_star_d1) * inv_f;
521 gain_d2[i] =
522 (m_star_d2[i] - 2.0 * gain_d1[i] * f_star_d1 - gain[i] * f_star_d2) * inv_f;
523 gain_d3[i] = (m_star_d3[i]
524 - 3.0 * gain_d2[i] * f_star_d1
525 - 3.0 * gain_d1[i] * f_star_d2
526 - gain[i] * f_star_d3)
527 * inv_f;
528 }
529 let a_old_d1 = a_d1;
530 let a_old_d2 = a_d2;
531 let a_old_d3 = a_d3;
532 for i in 0..order {
533 a[i] += gain[i] * v;
534 a_d1[i] = a_old_d1[i] + gain_d1[i] * v + gain[i] * v_d1;
535 a_d2[i] = a_old_d2[i] + gain_d2[i] * v + 2.0 * gain_d1[i] * v_d1 + gain[i] * v_d2;
536 a_d3[i] = a_old_d3[i]
537 + gain_d3[i] * v
538 + 3.0 * gain_d2[i] * v_d1
539 + 3.0 * gain_d1[i] * v_d2
540 + gain[i] * v_d3;
541 }
542 let mut p_new = p_star;
543 let mut p_new_d1 = p_star_d1;
544 let mut p_new_d2 = p_star_d2;
545 let mut p_new_d3 = p_star_d3;
546 for i in 0..order {
547 for j in 0..order {
548 let mm = m_star[i] * m_star[j];
549 let mm_d1 = m_star_d1[i] * m_star[j] + m_star[i] * m_star_d1[j];
550 let mm_d2 = m_star_d2[i] * m_star[j]
551 + 2.0 * m_star_d1[i] * m_star_d1[j]
552 + m_star[i] * m_star_d2[j];
553 let mm_d3 = m_star_d3[i] * m_star[j]
554 + 3.0 * m_star_d2[i] * m_star_d1[j]
555 + 3.0 * m_star_d1[i] * m_star_d2[j]
556 + m_star[i] * m_star_d3[j];
557 let s0 = mm * inv_f;
558 let s1 = (mm_d1 - s0 * f_star_d1) * inv_f;
559 let s2 = (mm_d2 - 2.0 * s1 * f_star_d1 - s0 * f_star_d2) * inv_f;
560 let s3 = (mm_d3
561 - 3.0 * s2 * f_star_d1
562 - 3.0 * s1 * f_star_d2
563 - s0 * f_star_d3)
564 * inv_f;
565 p_new[i][j] -= s0;
566 p_new_d1[i][j] -= s1;
567 p_new_d2[i][j] -= s2;
568 p_new_d3[i][j] -= s3;
569 }
570 }
571 p_star = p_new;
572 p_star_d1 = p_new_d1;
573 p_star_d2 = p_new_d2;
574 p_star_d3 = p_new_d3;
575 symmetrize(&mut p_star, order);
576 symmetrize(&mut p_star_d1, order);
577 symmetrize(&mut p_star_d2, order);
578 symmetrize(&mut p_star_d3, order);
579
580 let vv = v * v;
581 let vv_d1 = 2.0 * v * v_d1;
582 let vv_d2 = 2.0 * (v_d1 * v_d1 + v * v_d2);
583 let vv_d3 = 2.0 * (v * v_d3 + 3.0 * v_d1 * v_d2);
584 let logf_d1 = f_star_d1 * inv_f;
585 let logf_d2 = f_star_d2 * inv_f - logf_d1 * logf_d1;
586 let logf_d3 = f_star_d3 * inv_f - 3.0 * (f_star_d2 * inv_f) * logf_d1
587 + 2.0 * logf_d1 * logf_d1 * logf_d1;
588 sum_log_f += f_star.ln();
589 sum_log_f_d1 += logf_d1;
590 sum_log_f_d2 += logf_d2;
591 sum_log_f_d3 += logf_d3;
592 let t0 = vv * inv_f;
593 let t1 = (vv_d1 - t0 * f_star_d1) * inv_f;
594 let t2 = (vv_d2 - 2.0 * t1 * f_star_d1 - t0 * f_star_d2) * inv_f;
595 let t3 = (vv_d3 - 3.0 * t2 * f_star_d1 - 3.0 * t1 * f_star_d2 - t0 * f_star_d3)
596 * inv_f;
597 sum_v2_over_f += t0;
598 sum_v2_over_f_d1 += t1;
599 sum_v2_over_f_d2 += t2;
600 sum_v2_over_f_d3 += t3;
601 n_proper += 1;
602 }
603 steps.push(FilterStep {
604 a_filt: a,
605 p_filt: p_star,
606 a_pred,
607 p_pred,
608 });
609 if t + 1 < n {
611 let delta = nodes[t + 1].x - nodes[t].x;
612 let f_t = transition(delta, order);
613 a = mat_vec(&f_t, &a, order);
614 a_d1 = mat_vec(&f_t, &a_d1, order);
615 a_d2 = mat_vec(&f_t, &a_d2, order);
616 a_d3 = mat_vec(&f_t, &a_d3, order);
617 let f_t_t = mat_t(&f_t, order);
618 let q_noise = process_noise(delta, q, order);
619 let mut p_next = mat_add(
620 &mat_mul(&mat_mul(&f_t, &p_star, order), &f_t_t, order),
621 &q_noise,
622 order,
623 );
624 let mut p_next_d1 = mat_sub(
625 &mat_mul(&mat_mul(&f_t, &p_star_d1, order), &f_t_t, order),
626 &q_noise,
627 order,
628 );
629 let mut p_next_d2 = mat_add(
630 &mat_mul(&mat_mul(&f_t, &p_star_d2, order), &f_t_t, order),
631 &q_noise,
632 order,
633 );
634 let mut p_next_d3 = mat_sub(
636 &mat_mul(&mat_mul(&f_t, &p_star_d3, order), &f_t_t, order),
637 &q_noise,
638 order,
639 );
640 symmetrize(&mut p_next, order);
641 symmetrize(&mut p_next_d1, order);
642 symmetrize(&mut p_next_d2, order);
643 symmetrize(&mut p_next_d3, order);
644 p_star = p_next;
645 p_star_d1 = p_next_d1;
646 p_star_d2 = p_next_d2;
647 p_star_d3 = p_next_d3;
648 if diffuse_rank > 0 {
649 let mut pi_next =
650 mat_mul(&mat_mul(&f_t, &p_inf, order), &mat_t(&f_t, order), order);
651 symmetrize(&mut pi_next, order);
652 p_inf = pi_next;
653 }
654 }
655 }
656 Ok(FilterPass {
657 steps,
658 sum_log_f,
659 sum_log_f_d1,
660 sum_log_f_d2,
661 sum_log_f_d3,
662 sum_v2_over_f,
663 sum_v2_over_f_d1,
664 sum_v2_over_f_d2,
665 sum_v2_over_f_d3,
666 n_proper,
667 })
668}
669
670#[derive(Clone, Debug)]
672pub struct SplineScanFit {
673 pub order: usize,
677 pub knots: Vec<f64>,
679 pub mean: Vec<f64>,
681 pub deriv: Option<Vec<f64>>,
686 pub var: Vec<f64>,
688 log_lambda: f64,
690 pub sigma2: f64,
692 pub restricted_loglik: f64,
696 pub n_obs: usize,
699 pub data_sse: f64,
705 smoothed_state: Vec<Vec2>,
707 smoothed_cov: Vec<Mat2>,
709 rts_gain: Vec<Mat2>,
711 q: f64,
713 node_weight: Vec<f64>,
715}
716
717fn pool_nodes(
720 x: &[f64],
721 y: &[f64],
722 w: &[f64],
723 order: usize,
724) -> Result<(Vec<PooledNode>, f64, usize), String> {
725 let n = x.len();
726 if y.len() != n || w.len() != n {
727 return Err(format!(
728 "spline scan: length mismatch x={n}, y={}, w={}",
729 y.len(),
730 w.len()
731 ));
732 }
733 for i in 0..n {
734 if !(x[i].is_finite() && y[i].is_finite() && w[i].is_finite() && w[i] > 0.0) {
735 return Err(format!(
736 "spline scan: non-finite or non-positive input at row {i} (x={}, y={}, w={})",
737 x[i], y[i], w[i]
738 ));
739 }
740 }
741 let mut perm: Vec<usize> = (0..n).collect();
742 perm.sort_by(|&i, &j| x[i].total_cmp(&x[j]));
743 let mut nodes: Vec<PooledNode> = Vec::new();
744 for &i in &perm {
745 match nodes.last_mut() {
746 Some(last) if last.x == x[i] => {
747 let w_new = last.w + w[i];
748 last.y = (last.y * last.w + y[i] * w[i]) / w_new;
749 last.w = w_new;
750 }
751 _ => nodes.push(PooledNode {
752 x: x[i],
753 y: y[i],
754 w: w[i],
755 }),
756 }
757 }
758 if nodes.len() < order + 1 {
760 return Err(format!(
761 "spline scan: order {order} needs at least {} distinct abscissae, got {}",
762 order + 1,
763 nodes.len()
764 ));
765 }
766 let mut ssr_within = 0.0;
768 let mut k = 0usize;
769 for &i in &perm {
770 while nodes[k].x != x[i] {
771 k += 1;
772 }
773 let d = y[i] - nodes[k].y;
774 ssr_within += w[i] * d * d;
775 }
776 Ok((nodes, ssr_within, n))
777}
778
779fn concentrated_criterion_jet(
786 nodes: &[PooledNode],
787 ssr_within: f64,
788 n_obs: usize,
789 log_lambda: f64,
790 order: usize,
791) -> Result<(f64, f64, f64, f64), String> {
792 let q = gam_problem::checked_exp_log_strength(-log_lambda)
793 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
794 let pass = run_filter(nodes, q, order)?;
795 let dof = (n_obs - order) as f64;
798 let rss = pass.sum_v2_over_f + ssr_within;
799 if rss <= 0.0 {
800 return Err("spline scan: degenerate zero residual sum".to_string());
801 }
802 let sigma2 = rss / dof;
803 if pass.n_proper != nodes.len() - order {
804 return Err(format!(
805 "spline scan: expected {} proper innovations, got {} (diffuse rank not consumed)",
806 nodes.len() - order,
807 pass.n_proper
808 ));
809 }
810 let rss_d1 = pass.sum_v2_over_f_d1;
811 let rss_d2 = pass.sum_v2_over_f_d2;
812 let rss_d3 = pass.sum_v2_over_f_d3;
813 let rss_log_d1 = rss_d1 / rss;
814 let rss_log_d2 = rss_d2 / rss - rss_log_d1 * rss_log_d1;
815 let rss_log_d3 = rss_d3 / rss - 3.0 * (rss_d2 / rss) * rss_log_d1
816 + 2.0 * rss_log_d1 * rss_log_d1 * rss_log_d1;
817 Ok((
818 -0.5 * (pass.sum_log_f + dof * sigma2.ln()),
819 -0.5 * (pass.sum_log_f_d1 + dof * rss_log_d1),
820 -0.5 * (pass.sum_log_f_d2 + dof * rss_log_d2),
821 -0.5 * (pass.sum_log_f_d3 + dof * rss_log_d3),
822 ))
823}
824
825fn concentrated_criterion_enclosure(
844 nodes: &[PooledNode],
845 ssr_within: f64,
846 n_obs: usize,
847 lo: f64,
848 hi: f64,
849 order: usize,
850) -> Result<DerivativeEnclosure, String> {
851 if !(lo.is_finite() && hi.is_finite() && lo <= hi) {
852 return Err(format!(
853 "spline scan: invalid score-enclosure interval [{lo}, {hi}]"
854 ));
855 }
856 let left = concentrated_criterion_jet(nodes, ssr_within, n_obs, lo, order)?;
857 let right = concentrated_criterion_jet(nodes, ssr_within, n_obs, hi, order)?;
858 let width = hi - lo;
859 let proper_modes = (nodes.len() - order) as f64;
860 let residual_dof = (n_obs - order) as f64;
861 let fourth_abs_bound = 0.5 * (0.25 * proper_modes + 26.0 * residual_dof);
862 let curvature_endpoint_abs = left.2.abs().max(right.2.abs());
877 let third_endpoint_abs = left.3.abs().max(right.3.abs());
878 let derivative_radius = curvature_endpoint_abs * width
879 + 0.5 * third_endpoint_abs * width * width
880 + fourth_abs_bound * width * width * width / 6.0;
881 let curvature_radius = third_endpoint_abs * width + 0.5 * fourth_abs_bound * width * width;
884 Ok(DerivativeEnclosure {
885 derivative: ClosedInterval::outward(
886 (left.1 - derivative_radius).min(right.1 - derivative_radius),
887 (left.1 + derivative_radius).max(right.1 + derivative_radius),
888 ),
889 curvature: ClosedInterval::outward(
890 (left.2 - curvature_radius).min(right.2 - curvature_radius),
891 (left.2 + curvature_radius).max(right.2 + curvature_radius),
892 ),
893 })
894}
895
896fn leading_block_smooth(
929 sm_state: &mut [Vec2],
930 sm_cov: &mut [Mat2],
931 gains: &mut [Mat2],
932 nodes: &[PooledNode],
933 q: f64,
934 order: usize,
935) -> Result<(), String> {
936 let nb = order - 1; let pin = order - 1; let d = nb * order; let mut lambda = vec![vec![0.0_f64; d]; d];
940 let mut b_const = vec![0.0_f64; d];
941 let mut bmat = vec![vec![0.0_f64; order]; d]; for t in 0..order - 1 {
945 let delta = nodes[t + 1].x - nodes[t].x;
946 let f = transition(delta, order);
947 let qn = process_noise(delta, q, order);
948 let a = mat_inv(&qn, order, "leading-block increment noise")?; let ft = mat_t(&f, order);
950 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 {
955 for j in 0..order {
956 lambda[t * order + i][t * order + j] += ftaf[i][j];
957 }
958 }
959 if t + 1 <= nb - 1 {
960 for i in 0..order {
963 for j in 0..order {
964 lambda[(t + 1) * order + i][(t + 1) * order + j] += a[i][j];
965 lambda[t * order + i][(t + 1) * order + j] -= fta[i][j];
966 lambda[(t + 1) * order + i][t * order + j] -= af[i][j];
967 }
968 }
969 } else {
970 for i in 0..order {
973 for j in 0..order {
974 bmat[t * order + i][j] += fta[i][j];
975 }
976 }
977 }
978 }
979 for t in 0..nb {
981 let w = nodes[t].w;
982 lambda[t * order][t * order] += w;
983 b_const[t * order] += w * nodes[t].y;
984 }
985
986 let sigma = dense_spd_inverse(&lambda, "leading-block precision")?;
988 let dvec: Vec<f64> = (0..d)
989 .map(|i| (0..d).map(|k| sigma[i][k] * b_const[k]).sum())
990 .collect();
991 let cmat: Vec<Vec<f64>> = (0..d)
992 .map(|i| {
993 (0..order)
994 .map(|j| (0..d).map(|k| sigma[i][k] * bmat[k][j]).sum())
995 .collect()
996 })
997 .collect();
998
999 let ahat_p = sm_state[pin];
1001 let vp = sm_cov[pin];
1002 let cvp: Vec<Vec<f64>> = (0..d)
1004 .map(|i| {
1005 (0..order)
1006 .map(|j| (0..order).map(|k| cmat[i][k] * vp[k][j]).sum())
1007 .collect()
1008 })
1009 .collect();
1010 let mean_u: Vec<f64> = (0..d)
1012 .map(|i| (0..order).map(|j| cmat[i][j] * ahat_p[j]).sum::<f64>() + dvec[i])
1013 .collect();
1014 let cov_u: Vec<Vec<f64>> = (0..d)
1016 .map(|i| {
1017 (0..d)
1018 .map(|k| (0..order).map(|j| cvp[i][j] * cmat[k][j]).sum::<f64>() + sigma[i][k])
1019 .collect()
1020 })
1021 .collect();
1022
1023 for j in 0..nb {
1025 for i in 0..order {
1026 sm_state[j][i] = mean_u[j * order + i];
1027 }
1028 let mut cov = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1029 for i in 0..order {
1030 for k in 0..order {
1031 cov[i][k] = cov_u[j * order + i][j * order + k];
1032 }
1033 }
1034 symmetrize(&mut cov, order);
1035 sm_cov[j] = cov;
1036 }
1037 for j in 0..nb {
1041 let mut cross = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1042 if j + 1 <= nb - 1 {
1043 for i in 0..order {
1045 for k in 0..order {
1046 cross[i][k] = cov_u[j * order + i][(j + 1) * order + k];
1047 }
1048 }
1049 } else {
1050 for i in 0..order {
1052 for k in 0..order {
1053 cross[i][k] = cvp[j * order + i][k];
1054 }
1055 }
1056 }
1057 let denom_inv = mat_inv(&sm_cov[j + 1], order, "leading-block gain denominator")?;
1058 gains[j] = mat_mul(&cross, &denom_inv, order);
1059 }
1060 Ok(())
1061}
1062
1063pub fn fit_spline_scan_at(
1066 x: &[f64],
1067 y: &[f64],
1068 w: &[f64],
1069 log_lambda: f64,
1070 sigma2: Option<f64>,
1071 order: usize,
1072) -> Result<SplineScanFit, String> {
1073 if order == 0 || order > MAX_ORDER {
1074 return Err(format!(
1075 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
1076 ));
1077 }
1078 let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
1079 let q = gam_problem::checked_exp_log_strength(-log_lambda)
1080 .map_err(|error| format!("spline scan inverse log strength: {error}"))?;
1081 let pass = run_filter(&nodes, q, order)?;
1082 let n = nodes.len();
1083 let dof = (n_obs - order) as f64;
1084 let sigma2 = match sigma2 {
1085 Some(s) => {
1086 if !(s.is_finite() && s > 0.0) {
1087 return Err(format!("spline scan: invalid sigma2 {s}"));
1088 }
1089 s
1090 }
1091 None => (pass.sum_v2_over_f + ssr_within) / dof,
1092 };
1093 let rss = pass.sum_v2_over_f + ssr_within;
1098 let restricted_loglik = -0.5 * (pass.sum_log_f + dof * sigma2.ln() + rss / sigma2);
1099
1100 let mut sm_state = vec![[0.0_f64; MAX_ORDER]; n];
1111 let mut sm_cov = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
1112 let mut gains = vec![[[0.0_f64; MAX_ORDER]; MAX_ORDER]; n];
1113 sm_state[n - 1] = pass.steps[n - 1].a_filt;
1114 sm_cov[n - 1] = pass.steps[n - 1].p_filt;
1115 for t in (order - 1..n - 1).rev() {
1116 let p_next_pred = &pass.steps[t + 1].p_pred;
1117 let delta = nodes[t + 1].x - nodes[t].x;
1118 let f_t = transition(delta, order);
1119 let p_inv = mat_inv(p_next_pred, order, "RTS predicted covariance")?;
1120 let g = mat_mul(
1121 &mat_mul(&pass.steps[t].p_filt, &mat_t(&f_t, order), order),
1122 &p_inv,
1123 order,
1124 );
1125 let mut dm: Vec2 = [0.0; MAX_ORDER];
1126 for i in 0..order {
1127 dm[i] = sm_state[t + 1][i] - pass.steps[t + 1].a_pred[i];
1128 }
1129 let corr = mat_vec(&g, &dm, order);
1130 for i in 0..order {
1131 sm_state[t][i] = pass.steps[t].a_filt[i] + corr[i];
1132 }
1133 let dp = mat_sub(&sm_cov[t + 1], p_next_pred, order);
1134 let mut cov = mat_add(
1135 &pass.steps[t].p_filt,
1136 &mat_mul(&mat_mul(&g, &dp, order), &mat_t(&g, order), order),
1137 order,
1138 );
1139 symmetrize(&mut cov, order);
1140 sm_cov[t] = cov;
1141 gains[t] = g;
1142 }
1143 if order >= 2 {
1146 leading_block_smooth(&mut sm_state, &mut sm_cov, &mut gains, &nodes, q, order)?;
1147 }
1148
1149 let knots: Vec<f64> = nodes.iter().map(|n| n.x).collect();
1150 let mean: Vec<f64> = sm_state.iter().map(|s| s[0]).collect();
1151 let deriv: Option<Vec<f64>> = (order >= 2).then(|| sm_state.iter().map(|s| s[1]).collect());
1154 let var: Vec<f64> = sm_cov.iter().map(|p| p[0][0] * sigma2).collect();
1155 let data_sse = ssr_within
1160 + nodes
1161 .iter()
1162 .zip(mean.iter())
1163 .map(|(node, &fhat)| {
1164 let r = node.y - fhat;
1165 node.w * r * r
1166 })
1167 .sum::<f64>();
1168 Ok(SplineScanFit {
1169 order,
1170 knots,
1171 mean,
1172 deriv,
1173 var,
1174 log_lambda,
1175 sigma2,
1176 restricted_loglik,
1177 n_obs,
1178 data_sse,
1179 smoothed_state: sm_state,
1180 smoothed_cov: sm_cov,
1181 rts_gain: gains,
1182 q,
1183 node_weight: nodes.iter().map(|n| n.w).collect(),
1184 })
1185}
1186
1187pub fn fit_spline_scan(
1192 x: &[f64],
1193 y: &[f64],
1194 w: &[f64],
1195 order: usize,
1196) -> Result<SplineScanFit, String> {
1197 if order == 0 || order > MAX_ORDER {
1198 return Err(format!(
1199 "spline scan: order must be in 1..={MAX_ORDER}, got {order}"
1200 ));
1201 }
1202 let (nodes, ssr_within, n_obs) = pool_nodes(x, y, w, order)?;
1203 let span = nodes.last().map(|n| n.x).unwrap_or(0.0) - nodes.first().map(|n| n.x).unwrap_or(0.0);
1218 let scale_shift = if span.is_finite() && span > 0.0 {
1219 (2 * order - 1) as f64 * span.ln()
1220 } else {
1221 0.0
1222 };
1223 let lo_anchor = LOG_LAMBDA_LO + scale_shift;
1224 let hi_anchor = LOG_LAMBDA_HI + scale_shift;
1225 let search = maximize_score_1d(
1226 lo_anchor,
1227 hi_anchor,
1228 f64::EPSILON.sqrt(),
1229 |ll| {
1230 concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order).map(
1231 |(value, derivative, curvature, _third)| ScoreJet {
1232 value,
1233 derivative,
1234 curvature,
1235 },
1236 )
1237 },
1238 |lo, hi| concentrated_criterion_enclosure(&nodes, ssr_within, n_obs, lo, hi, order),
1239 )
1240 .map_err(|error| format!("spline scan: REML stationary isolation failed: {error}"))?;
1241 fit_spline_scan_at(x, y, w, search.optimum.x, None, order)
1242}
1243
1244#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1258pub struct SplineScanState {
1259 #[serde(default = "default_spline_scan_order")]
1263 pub order: usize,
1264 pub knots: Vec<f64>,
1265 pub state: Vec<f64>,
1267 pub cov: Vec<f64>,
1270 pub gain: Vec<f64>,
1273 pub node_weight: Vec<f64>,
1275 pub log_lambda: f64,
1276 pub sigma2: f64,
1277 pub restricted_loglik: f64,
1278 pub n_obs: u64,
1280 pub data_sse: f64,
1285}
1286
1287fn default_spline_scan_order() -> usize {
1290 2
1291}
1292
1293impl SplineScanFit {
1294 pub fn to_state(&self) -> SplineScanState {
1296 let order = self.order;
1297 let tri = order * (order + 1) / 2;
1298 let nk = self.knots.len();
1299 let mut state = Vec::with_capacity(order * nk);
1300 for s in &self.smoothed_state {
1301 state.extend_from_slice(&s[..order]);
1302 }
1303 let mut cov = Vec::with_capacity(tri * nk);
1304 for c in &self.smoothed_cov {
1305 for i in 0..order {
1306 for j in i..order {
1307 cov.push(c[i][j]);
1308 }
1309 }
1310 }
1311 let mut gain = Vec::with_capacity(order * order * nk);
1312 for g in &self.rts_gain {
1313 for i in 0..order {
1314 for j in 0..order {
1315 gain.push(g[i][j]);
1316 }
1317 }
1318 }
1319 SplineScanState {
1320 order: self.order,
1321 knots: self.knots.clone(),
1322 state,
1323 cov,
1324 gain,
1325 node_weight: self.node_weight.clone(),
1326 log_lambda: self.log_lambda,
1327 sigma2: self.sigma2,
1328 restricted_loglik: self.restricted_loglik,
1329 n_obs: self.n_obs as u64,
1330 data_sse: self.data_sse,
1331 }
1332 }
1333
1334 pub fn from_state(state: &SplineScanState) -> Result<Self, String> {
1342 let order = state.order;
1343 if order == 0 || order > MAX_ORDER {
1344 return Err(format!(
1345 "spline scan state: order must be in 1..={MAX_ORDER}, got {order}"
1346 ));
1347 }
1348 let m = state.knots.len();
1349 if m < order + 1 {
1350 return Err(format!(
1351 "spline scan state: order {order} needs at least {} knots, got {m}",
1352 order + 1
1353 ));
1354 }
1355 let tri = order * (order + 1) / 2;
1356 if state.state.len() != order * m
1357 || state.cov.len() != tri * m
1358 || state.gain.len() != order * order * m
1359 || state.node_weight.len() != m
1360 {
1361 return Err(format!(
1362 "spline scan state: inconsistent lengths (order={order}, m={m}, state={}, cov={}, gain={}, weights={})",
1363 state.state.len(),
1364 state.cov.len(),
1365 state.gain.len(),
1366 state.node_weight.len()
1367 ));
1368 }
1369 let all = state
1370 .state
1371 .iter()
1372 .chain(&state.cov)
1373 .chain(&state.gain)
1374 .chain(&state.knots)
1375 .chain(&state.node_weight);
1376 for (i, v) in all.enumerate() {
1377 if !v.is_finite() {
1378 return Err(format!("spline scan state: non-finite entry at {i}"));
1379 }
1380 }
1381 gam_problem::validate_log_strength(state.log_lambda)
1382 .map_err(|error| format!("spline scan state: {error}"))?;
1383 if !(state.restricted_loglik.is_finite() && state.sigma2.is_finite() && state.sigma2 > 0.0)
1384 {
1385 return Err(format!(
1386 "spline scan state: invalid scalars (log_lambda={}, sigma2={}, restricted_loglik={})",
1387 state.log_lambda, state.sigma2, state.restricted_loglik
1388 ));
1389 }
1390 if !(state.data_sse.is_finite() && state.data_sse >= 0.0) {
1391 return Err(format!(
1392 "spline scan state: invalid data_sse {}",
1393 state.data_sse
1394 ));
1395 }
1396 if state.knots.windows(2).any(|kk| !(kk[0] < kk[1])) {
1397 return Err("spline scan state: knots must be strictly increasing".to_string());
1398 }
1399 if state.node_weight.iter().any(|&w| w <= 0.0) {
1400 return Err("spline scan state: node weights must be positive".to_string());
1401 }
1402 let smoothed_state: Vec<Vec2> = state
1403 .state
1404 .chunks_exact(order)
1405 .map(|s| {
1406 let mut v = [0.0_f64; MAX_ORDER];
1407 v[..order].copy_from_slice(s);
1408 v
1409 })
1410 .collect();
1411 let smoothed_cov: Vec<Mat2> = state
1412 .cov
1413 .chunks_exact(tri)
1414 .map(|c| {
1415 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1416 let mut idx = 0;
1417 for i in 0..order {
1418 for j in i..order {
1419 mm[i][j] = c[idx];
1420 mm[j][i] = c[idx];
1421 idx += 1;
1422 }
1423 }
1424 mm
1425 })
1426 .collect();
1427 let rts_gain: Vec<Mat2> = state
1428 .gain
1429 .chunks_exact(order * order)
1430 .map(|g| {
1431 let mut mm = [[0.0_f64; MAX_ORDER]; MAX_ORDER];
1432 for i in 0..order {
1433 for j in 0..order {
1434 mm[i][j] = g[i * order + j];
1435 }
1436 }
1437 mm
1438 })
1439 .collect();
1440 let sigma2 = state.sigma2;
1441 if state.n_obs == 0 {
1442 return Err("spline scan state: n_obs must be positive".to_string());
1443 }
1444 let n_obs = state.n_obs as usize;
1445 Ok(Self {
1446 order,
1447 knots: state.knots.clone(),
1448 mean: smoothed_state.iter().map(|s| s[0]).collect(),
1449 deriv: (order >= 2).then(|| smoothed_state.iter().map(|s| s[1]).collect()),
1450 var: smoothed_cov.iter().map(|c| c[0][0] * sigma2).collect(),
1451 log_lambda: state.log_lambda,
1452 sigma2,
1453 restricted_loglik: state.restricted_loglik,
1454 n_obs,
1455 data_sse: state.data_sse,
1456 smoothed_state,
1457 smoothed_cov,
1458 rts_gain,
1459 q: gam_problem::checked_exp_log_strength(-state.log_lambda)
1460 .map_err(|error| format!("spline scan inverse log strength: {error}"))?,
1461 node_weight: state.node_weight.clone(),
1462 })
1463 }
1464
1465 pub fn predict(&self, x_new: f64) -> Result<(f64, f64), String> {
1472 if !x_new.is_finite() {
1473 return Err("spline scan: non-finite prediction abscissa".to_string());
1474 }
1475 let n = self.knots.len();
1476 let order = self.order;
1477 let first = self.knots[0];
1478 let last = self.knots[n - 1];
1479 if x_new <= first {
1480 let delta = first - x_new;
1481 let f_t = transition(delta, order);
1483 let f_inv = mat_inv(&f_t, order, "backward extrapolation transition")?;
1484 let mean_s = mat_vec(&f_inv, &self.smoothed_state[0], order);
1485 let qm = process_noise(delta, self.q, order);
1486 let cov = mat_add(
1487 &mat_mul(
1488 &mat_mul(&f_inv, &self.smoothed_cov[0], order),
1489 &mat_t(&f_inv, order),
1490 order,
1491 ),
1492 &mat_mul(&mat_mul(&f_inv, &qm, order), &mat_t(&f_inv, order), order),
1493 order,
1494 );
1495 return Ok((mean_s[0], cov[0][0] * self.sigma2));
1496 }
1497 if x_new >= last {
1498 let delta = x_new - last;
1499 let f_t = transition(delta, order);
1500 let mean_s = mat_vec(&f_t, &self.smoothed_state[n - 1], order);
1501 let cov = mat_add(
1502 &mat_mul(
1503 &mat_mul(&f_t, &self.smoothed_cov[n - 1], order),
1504 &mat_t(&f_t, order),
1505 order,
1506 ),
1507 &process_noise(delta, self.q, order),
1508 order,
1509 );
1510 return Ok((mean_s[0], cov[0][0] * self.sigma2));
1511 }
1512 let t = match self.knots.binary_search_by(|k| k.total_cmp(&x_new)) {
1514 Ok(idx) => return Ok((self.mean[idx], self.var[idx])),
1515 Err(idx) => idx - 1,
1516 };
1517 let (xa, xb) = (self.knots[t], self.knots[t + 1]);
1518 let (d1, d2) = (x_new - xa, xb - x_new);
1519 let (f1m, f2m) = (transition(d1, order), transition(d2, order));
1520 let (q1, q2) = (
1521 process_noise(d1, self.q, order),
1522 process_noise(d2, self.q, order),
1523 );
1524 let q1_inv = mat_inv(&q1, order, "bridge left noise")?;
1525 let q2_inv = mat_inv(&q2, order, "bridge right noise")?;
1526 let lambda = mat_add(
1529 &q1_inv,
1530 &mat_mul(&mat_mul(&mat_t(&f2m, order), &q2_inv, order), &f2m, order),
1531 order,
1532 );
1533 let lam_inv = mat_inv(&lambda, order, "bridge precision")?;
1534 let ca = mat_mul(&lam_inv, &mat_mul(&q1_inv, &f1m, order), order);
1535 let cb = mat_mul(
1536 &lam_inv,
1537 &mat_mul(&mat_t(&f2m, order), &q2_inv, order),
1538 order,
1539 );
1540 let ma = mat_vec(&ca, &self.smoothed_state[t], order);
1541 let mb = mat_vec(&cb, &self.smoothed_state[t + 1], order);
1542 let mut mean_s = [0.0_f64; MAX_ORDER];
1543 for i in 0..order {
1544 mean_s[i] = ma[i] + mb[i];
1545 }
1546 let cross = mat_mul(&self.rts_gain[t], &self.smoothed_cov[t + 1], order);
1549 let mut cov = mat_add(
1550 &mat_add(
1551 &mat_mul(
1552 &mat_mul(&ca, &self.smoothed_cov[t], order),
1553 &mat_t(&ca, order),
1554 order,
1555 ),
1556 &mat_mul(
1557 &mat_mul(&cb, &self.smoothed_cov[t + 1], order),
1558 &mat_t(&cb, order),
1559 order,
1560 ),
1561 order,
1562 ),
1563 &lam_inv,
1564 order,
1565 );
1566 let cab = mat_mul(&mat_mul(&ca, &cross, order), &mat_t(&cb, order), order);
1567 cov = mat_add(&cov, &mat_add(&cab, &mat_t(&cab, order), order), order);
1568 symmetrize(&mut cov, order);
1569 Ok((mean_s[0], cov[0][0] * self.sigma2))
1570 }
1571
1572 pub fn edf(&self) -> f64 {
1586 self.node_weight
1587 .iter()
1588 .zip(self.smoothed_cov.iter())
1589 .map(|(w, c)| w * c[0][0])
1590 .sum()
1591 }
1592
1593 pub fn deriv_at_knot(&self, t: usize) -> Option<(f64, f64)> {
1600 (self.order >= 2).then(|| {
1601 (
1602 self.smoothed_state[t][1],
1603 self.smoothed_cov[t][1][1] * self.sigma2,
1604 )
1605 })
1606 }
1607
1608 pub fn lambda(&self) -> f64 {
1610 gam_problem::checked_exp_log_strength(self.log_lambda)
1611 .expect("SplineScanFit construction validates its private log strength")
1612 }
1613
1614 pub fn log_lambda(&self) -> f64 {
1615 self.log_lambda
1616 }
1617
1618 pub fn n_obs(&self) -> usize {
1620 self.n_obs
1621 }
1622
1623 pub fn deviance(&self) -> f64 {
1632 self.data_sse
1633 }
1634}
1635
1636#[cfg(test)]
1637mod tests {
1638
1639 #[test]
1647 fn weighted_scan_dgp_2300_search_terminates_in_bounded_evaluations() {
1648 let n = 180usize;
1651 let mut state: u64 = 0x2300_2300_2300_2300;
1652 let mut next_unit = move || {
1653 state ^= state << 13;
1654 state ^= state >> 7;
1655 state ^= state << 17;
1656 (state >> 11) as f64 / (1u64 << 53) as f64
1657 };
1658 let mut x = Vec::with_capacity(n);
1659 let mut y = Vec::with_capacity(n);
1660 let mut w = Vec::with_capacity(n);
1661 for i in 0..n {
1662 let xi = -2.0 + 4.0 * (i as f64) / ((n - 1) as f64);
1663 let wi: f64 = if xi < 0.0 { 1.0 } else { 9.0 };
1664 let u1 = next_unit().max(1e-12);
1665 let u2 = next_unit();
1666 let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
1667 x.push(xi);
1668 w.push(wi);
1669 y.push(0.4 + (1.3 * xi).sin() + (0.45 / wi.sqrt()) * z);
1670 }
1671 for order in 1..=MAX_ORDER {
1679 let (nodes, ssr_within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pool");
1680 let span = nodes.last().unwrap().x - nodes.first().unwrap().x;
1681 let scale_shift = (2 * order - 1) as f64 * span.ln();
1682 let lo = LOG_LAMBDA_LO + scale_shift;
1683 let hi = LOG_LAMBDA_HI + scale_shift;
1684
1685 let evals = std::cell::Cell::new(0u64);
1686 let last_x = std::cell::Cell::new(f64::NAN);
1687 let budget = 2_000_000u64;
1688 let result = gam_math::score_opt::maximize_score_1d(
1689 lo,
1690 hi,
1691 f64::EPSILON.sqrt(),
1692 |ll| {
1693 let count = evals.get() + 1;
1694 evals.set(count);
1695 last_x.set(ll);
1696 assert!(
1697 count <= budget,
1698 "order-{order} certified scan search exceeded {budget} criterion \
1699 evaluations (last log-lambda sample {ll:.9}; bracket \
1700 [{lo:.3}, {hi:.3}]) — non-terminating subdivision reproduced"
1701 );
1702 concentrated_criterion_jet(&nodes, ssr_within, n_obs, ll, order).map(
1703 |(value, derivative, curvature, _third)| gam_math::score_opt::ScoreJet {
1704 value,
1705 derivative,
1706 curvature,
1707 },
1708 )
1709 },
1710 |a, b| concentrated_criterion_enclosure(&nodes, ssr_within, n_obs, a, b, order),
1711 );
1712 match result {
1713 Ok(search) => {
1714 assert!(
1715 search.optimum.x.is_finite(),
1716 "order-{order} search must return a finite optimum"
1717 );
1718 }
1719 Err(error) => panic!(
1720 "order-{order} weighted scan search failed after {} evaluations \
1721 (last x {:.9}): {error:?}",
1722 evals.get(),
1723 last_x.get()
1724 ),
1725 }
1726 }
1727 }
1728 fn concentrated_criterion(
1730 nodes: &[PooledNode],
1731 ssr_within: f64,
1732 n_obs: usize,
1733 log_lambda: f64,
1734 order: usize,
1735 ) -> Result<f64, String> {
1736 Ok(concentrated_criterion_jet(nodes, ssr_within, n_obs, log_lambda, order)?.0)
1737 }
1738 use super::*;
1739
1740 #[test]
1741 fn concentrated_score_jet_matches_test_only_differences() {
1742 let x = [0.0, 0.07, 0.19, 0.41, 0.41, 0.68, 1.0, 1.37];
1743 let y = [0.2, -0.4, 0.8, 0.1, 0.35, -0.2, 0.7, 0.15];
1744 let w = [1.0, 2.0, 0.7, 1.4, 0.9, 3.0, 1.2, 0.8];
1745 for order in 1..=MAX_ORDER {
1746 let (nodes, within, n_obs) = pool_nodes(&x, &y, &w, order).expect("pooled data");
1747 for &rho in &[-4.0, -0.3, 2.5] {
1748 let (value, d1, d2, d3) =
1749 concentrated_criterion_jet(&nodes, within, n_obs, rho, order)
1750 .expect("analytic score jet");
1751 let h = 2.0e-4;
1754 let fm = concentrated_criterion(&nodes, within, n_obs, rho - h, order)
1755 .expect("left score");
1756 let fp = concentrated_criterion(&nodes, within, n_obs, rho + h, order)
1757 .expect("right score");
1758 let fm2 = concentrated_criterion(&nodes, within, n_obs, rho - 2.0 * h, order)
1759 .expect("far left score");
1760 let fp2 = concentrated_criterion(&nodes, within, n_obs, rho + 2.0 * h, order)
1761 .expect("far right score");
1762 let d1_fd = (fp - fm) / (2.0 * h);
1763 let d2_fd = (fp - 2.0 * value + fm) / (h * h);
1764 let d3_fd = (fp2 - 2.0 * fp + 2.0 * fm - fm2) / (2.0 * h * h * h);
1765 let d1_scale = 1.0 + d1.abs().max(d1_fd.abs());
1766 let d2_scale = 1.0 + d2.abs().max(d2_fd.abs());
1767 let d3_scale = 1.0 + d3.abs().max(d3_fd.abs());
1768 assert!(
1769 (d1 - d1_fd).abs() <= 2.0e-6 * d1_scale,
1770 "order={order} rho={rho}: analytic d1={d1}, FD={d1_fd}"
1771 );
1772 assert!(
1773 (d2 - d2_fd).abs() <= 2.0e-4 * d2_scale,
1774 "order={order} rho={rho}: analytic d2={d2}, FD={d2_fd}"
1775 );
1776 assert!(
1777 (d3 - d3_fd).abs() <= 5.0e-3 * d3_scale,
1778 "order={order} rho={rho}: analytic d3={d3}, FD={d3_fd}"
1779 );
1780 }
1781 }
1782 }
1783
1784 fn round_trip_predict_bit_for_bit(order: usize) {
1793 let n = 60usize;
1794 let x: Vec<f64> = (0..n).map(|i| (i as f64) / (n as f64 - 1.0)).collect();
1795 let mut x = x;
1797 x[7] = x[6];
1798 let y: Vec<f64> = x
1799 .iter()
1800 .enumerate()
1801 .map(|(i, &xi)| {
1802 (6.0 * xi).sin() + 0.3 * (17.0 * xi).cos() + 0.05 * ((i * 37 % 11) as f64 - 5.0)
1803 })
1804 .collect();
1805 let w: Vec<f64> = (0..n).map(|i| 1.0 + 0.5 * ((i % 3) as f64)).collect();
1806 let fit = fit_spline_scan(&x, &y, &w, order).expect("scan fit");
1807 assert_eq!(fit.order, order);
1808 assert_eq!(fit.n_obs, n);
1811
1812 let json = serde_json::to_string(&fit.to_state()).expect("serialize state");
1813 let state: SplineScanState = serde_json::from_str(&json).expect("deserialize state");
1814 let restored = SplineScanFit::from_state(&state).expect("restore fit");
1815
1816 assert_eq!(fit.n_obs, restored.n_obs);
1817 assert_eq!(fit.deviance().to_bits(), restored.deviance().to_bits());
1818 assert_eq!(fit.knots, restored.knots);
1819 assert_eq!(fit.mean, restored.mean);
1820 assert_eq!(fit.var, restored.var);
1821 assert_eq!(fit.deriv, restored.deriv);
1822 assert_eq!(fit.log_lambda.to_bits(), restored.log_lambda.to_bits());
1823 assert_eq!(fit.sigma2.to_bits(), restored.sigma2.to_bits());
1824 assert_eq!(fit.edf().to_bits(), restored.edf().to_bits());
1825 for t in 0..fit.knots.len() {
1826 match (fit.deriv_at_knot(t), restored.deriv_at_knot(t)) {
1827 (Some((d0, v0)), Some((d1, v1))) => {
1828 assert!(order >= 2);
1829 assert_eq!(d0.to_bits(), d1.to_bits());
1830 assert_eq!(v0.to_bits(), v1.to_bits());
1831 }
1832 (None, None) => assert_eq!(order, 1),
1833 _ => panic!("derivative availability drifted across the persistence seam"),
1834 }
1835 }
1836 for &xq in &[-0.2, 0.0, 0.013, 0.5, x[6], 0.987, 1.0, 1.3] {
1838 let (m0, v0) = fit.predict(xq).expect("predict original");
1839 let (m1, v1) = restored.predict(xq).expect("predict restored");
1840 assert_eq!(
1841 m0.to_bits(),
1842 m1.to_bits(),
1843 "mean drift at x={xq} (m={order})"
1844 );
1845 assert_eq!(
1846 v0.to_bits(),
1847 v1.to_bits(),
1848 "variance drift at x={xq} (m={order})"
1849 );
1850 }
1851
1852 let mut bad = fit.to_state();
1854 bad.cov.truncate(bad.cov.len() - 1);
1855 SplineScanFit::from_state(&bad).expect_err("length mismatch must error");
1856 let mut bad = fit.to_state();
1857 bad.sigma2 = -1.0;
1858 SplineScanFit::from_state(&bad).expect_err("non-positive sigma2 must error");
1859 let mut bad = fit.to_state();
1860 bad.knots[2] = bad.knots[1];
1861 SplineScanFit::from_state(&bad).expect_err("non-increasing knots must error");
1862 }
1863
1864 #[test]
1865 fn state_snapshot_round_trips_predict_bit_for_bit() {
1866 round_trip_predict_bit_for_bit(2);
1867 }
1868
1869 #[test]
1871 fn state_snapshot_round_trips_predict_bit_for_bit_order1() {
1872 round_trip_predict_bit_for_bit(1);
1873 }
1874
1875 #[test]
1876 fn state_snapshot_round_trips_predict_bit_for_bit_order3() {
1877 round_trip_predict_bit_for_bit(3);
1878 }
1879
1880 fn dense_rw_truth(x: &[f64], y: &[f64], w: &[f64], log_lambda: f64) -> (Vec<f64>, Vec<f64>) {
1886 let n = x.len();
1887 let q = (-log_lambda).exp();
1888 let mut prec = vec![vec![0.0_f64; n]; n];
1889 let mut rhs = vec![0.0_f64; n];
1890 for t in 0..n {
1891 prec[t][t] += w[t];
1892 rhs[t] += w[t] * y[t];
1893 }
1894 for t in 0..n - 1 {
1895 let p = 1.0 / (q * (x[t + 1] - x[t]));
1896 prec[t][t] += p;
1897 prec[t + 1][t + 1] += p;
1898 prec[t][t + 1] -= p;
1899 prec[t + 1][t] -= p;
1900 }
1901 let mut aug = prec.clone();
1903 let mut inv = vec![vec![0.0_f64; n]; n];
1904 for i in 0..n {
1905 inv[i][i] = 1.0;
1906 }
1907 for col in 0..n {
1908 let piv = (col..n)
1909 .max_by(|&a, &b| aug[a][col].abs().total_cmp(&aug[b][col].abs()))
1910 .unwrap();
1911 aug.swap(col, piv);
1912 inv.swap(col, piv);
1913 let d = aug[col][col];
1914 for k in 0..n {
1915 aug[col][k] /= d;
1916 inv[col][k] /= d;
1917 }
1918 for r in 0..n {
1919 if r == col {
1920 continue;
1921 }
1922 let f = aug[r][col];
1923 if f == 0.0 {
1924 continue;
1925 }
1926 for k in 0..n {
1927 aug[r][k] -= f * aug[col][k];
1928 inv[r][k] -= f * inv[col][k];
1929 }
1930 }
1931 }
1932 let mean: Vec<f64> = (0..n)
1933 .map(|i| (0..n).map(|j| inv[i][j] * rhs[j]).sum())
1934 .collect();
1935 let var: Vec<f64> = (0..n).map(|i| inv[i][i]).collect();
1936 (mean, var)
1937 }
1938
1939 #[test]
1943 fn order_one_scan_matches_dense_random_walk_posterior() {
1944 let n = 30usize;
1945 let x: Vec<f64> = (0..n).map(|i| i as f64 / (n as f64 - 1.0)).collect();
1946 let y: Vec<f64> = x
1947 .iter()
1948 .enumerate()
1949 .map(|(i, &xi)| 2.0 * xi + 0.4 * (5.0 * xi).sin() + 0.05 * ((i * 13 % 7) as f64 - 3.0))
1950 .collect();
1951 let w = vec![1.0_f64; n];
1952 let fit = fit_spline_scan(&x, &y, &w, 1).expect("order-1 scan fit");
1953 assert_eq!(fit.order, 1);
1954
1955 let (mean, var) = dense_rw_truth(&x, &y, &w, fit.log_lambda);
1956 for t in 0..n {
1957 assert!(
1958 (fit.mean[t] - mean[t]).abs() <= 1e-7 * mean[t].abs().max(1e-3),
1959 "order-1 mean mismatch at {t}: scan={} dense={}",
1960 fit.mean[t],
1961 mean[t]
1962 );
1963 let se_scan = fit.var[t].sqrt();
1964 let se_dense = (var[t] * fit.sigma2).sqrt();
1965 assert!(
1966 (se_scan - se_dense).abs() <= 1e-7 * se_dense.max(1e-12),
1967 "order-1 SE mismatch at {t}: scan={se_scan} dense={se_dense}"
1968 );
1969 }
1970 let dense_edf: f64 = w.iter().zip(var.iter()).map(|(wt, vt)| wt * vt).sum();
1972 assert!(
1973 (fit.edf() - dense_edf).abs() <= 1e-7 * dense_edf.max(1e-12),
1974 "order-1 EDF mismatch: scan={} dense={dense_edf}",
1975 fit.edf()
1976 );
1977 assert!(fit.deriv.is_none());
1981 assert!(fit.deriv_at_knot(0).is_none());
1982 }
1983
1984 #[test]
1991 fn deviance_is_data_sse_not_penalized_quadratic() {
1992 let x = [0.0, 1.0];
1993 let y = [0.0, 1.0];
1994 let w = [1.0, 1.0];
1995 let fit = fit_spline_scan_at(&x, &y, &w, 0.0, None, 1).expect("order-1 fit");
1996 let manual: f64 = x
1998 .iter()
1999 .zip(&y)
2000 .zip(&w)
2001 .map(|((&xi, &yi), &wi)| {
2002 let (m, _) = fit.predict(xi).expect("predict at knot");
2003 wi * (yi - m) * (yi - m)
2004 })
2005 .sum();
2006 assert!(
2007 (fit.deviance() - manual).abs() <= 1e-12 * manual.max(1e-300),
2008 "deviance {} != recomputed data SSE {manual}",
2009 fit.deviance()
2010 );
2011 assert!(
2012 (fit.deviance() - 2.0 / 9.0).abs() < 1e-10,
2013 "deviance {} != 2/9",
2014 fit.deviance()
2015 );
2016 let reml_quadratic = fit.sigma2 * (fit.n_obs as f64 - fit.order as f64);
2018 assert!(fit.deviance() < reml_quadratic);
2019 }
2020}