1use crate::cubic_cell_kernel as exact_kernel;
2use crate::util::span::span_index_for_breakpoints;
3use gam_linalg::faer_ndarray::{FaerEigh, fast_ab};
4use gam_solve::pirls::LinearInequalityConstraints;
5use gam_terms::basis::create_ispline_derivative_dense;
6use ndarray::{Array1, Array2, ArrayView2};
7
8fn validate_breakpoints(breakpoints: &[f64], label: &str) -> Result<(), String> {
11 if breakpoints.len() < 2 {
12 return Err(format!("{label} requires at least two breakpoints"));
13 }
14 if let Some((idx, window)) = breakpoints.windows(2).enumerate().find(|(_, window)| {
15 !window[0].is_finite() || !window[1].is_finite() || window[0] >= window[1]
16 }) {
17 return Err(format!(
18 "{label} requires strictly increasing finite breakpoints; breakpoints[{idx}]={:.6}, breakpoints[{}]={:.6}",
19 window[0],
20 idx + 1,
21 window[1]
22 ));
23 }
24 Ok::<(), _>(())
25}
26
27fn breakpoints_from_knots(knots: &[f64], label: &str) -> Result<Vec<f64>, String> {
30 let mut breakpoints = Vec::new();
31 for &knot in knots {
32 if breakpoints
33 .last()
34 .is_none_or(|prev: &f64| (knot - *prev).abs() > 1e-12)
35 {
36 breakpoints.push(knot);
37 }
38 }
39 validate_breakpoints(&breakpoints, label)?;
40 Ok(breakpoints)
41}
42
43pub(crate) const MONOTONICITY_SLACK_ROUNDOFF_TOL: f64 = -1e-10;
50
51#[derive(Debug, Clone)]
59pub enum DeviationRuntimeError {
60 InvalidInput { reason: String },
64 DimensionMismatch { reason: String },
67 NumericalFailure { reason: String },
70}
71
72impl_reason_error_boilerplate! {
73 DeviationRuntimeError {
74 InvalidInput,
75 DimensionMismatch,
76 NumericalFailure,
77 }
78}
79
80#[derive(Clone, Debug)]
93pub struct InstalledFlexBlock {
94 pub anchor_correction: Array2<f64>,
98 pub anchor_components: Vec<AnchorComponentTag>,
102}
103
104#[derive(Clone, Debug)]
105pub enum AnchorComponentTag {
106 Parametric {
112 block: ParametricAnchorBlock,
113 ncols: usize,
114 },
115 FlexEvaluation { ncols: usize },
120}
121
122#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
123pub enum ParametricAnchorBlock {
124 Marginal,
125 Logslope,
126}
127
128pub(crate) fn integrate_polynomial_product(left: &[f64], right: &[f64], width: f64) -> f64 {
129 let mut total = 0.0;
130 for (left_power, &left_coeff) in left.iter().enumerate() {
131 for (right_power, &right_coeff) in right.iter().enumerate() {
132 let power = left_power + right_power + 1;
133 total += left_coeff * right_coeff * width.powi(power as i32) / power as f64;
134 }
135 }
136 total
137}
138
139#[derive(Clone, Debug)]
152pub struct DeviationRuntime {
153 pub(crate) degree: usize,
154 pub(crate) value_span_degree: usize,
155 pub(crate) basis_dim: usize,
156 pub(crate) monotonicity_eps: f64,
157 pub(crate) endpoint_points: Array1<f64>,
158 pub(crate) span_c0: Array2<f64>,
159 pub(crate) span_c1: Array2<f64>,
160 pub(crate) span_c2: Array2<f64>,
161 pub(crate) span_c3: Array2<f64>,
162 pub(crate) monotonicity_constraint_rows: Array2<f64>,
163 pub(crate) right_boundary_value_row: Array1<f64>,
167 pub(crate) installed_flex_block: Option<InstalledFlexBlock>,
170 pub(crate) anchor_rows_at_training: Option<Array2<f64>>,
176}
177
178pub(crate) fn raw_integrated_derivative_penalty(
195 endpoint_points: &Array1<f64>,
196 raw_span_c0: &Array2<f64>,
197 raw_span_c1: &Array2<f64>,
198 raw_span_c2: &Array2<f64>,
199 raw_span_c3: &Array2<f64>,
200 derivative_order: usize,
201) -> Result<Array2<f64>, String> {
202 let raw_dim = raw_span_c0.ncols();
203 let n_spans = endpoint_points.len().saturating_sub(1);
204 if raw_span_c1.ncols() != raw_dim
205 || raw_span_c2.ncols() != raw_dim
206 || raw_span_c3.ncols() != raw_dim
207 {
208 return Err("raw smoothness penalty: span coefficient column dimensions disagree".into());
209 }
210 let mut penalty = Array2::<f64>::zeros((raw_dim, raw_dim));
211 for span_idx in 0..n_spans {
212 let left = endpoint_points[span_idx];
213 let right = endpoint_points[span_idx + 1];
214 let width = right - left;
215 if !width.is_finite() || width <= 0.0 {
216 return Err(format!(
217 "raw smoothness penalty span {span_idx} has invalid width {width}"
218 ));
219 }
220 for i in 0..raw_dim {
221 let ci = raw_span_derivative_polynomial_coefficients(
222 span_idx,
223 i,
224 derivative_order,
225 raw_span_c0,
226 raw_span_c1,
227 raw_span_c2,
228 raw_span_c3,
229 );
230 for j in i..raw_dim {
231 let cj = raw_span_derivative_polynomial_coefficients(
232 span_idx,
233 j,
234 derivative_order,
235 raw_span_c0,
236 raw_span_c1,
237 raw_span_c2,
238 raw_span_c3,
239 );
240 let contribution = integrate_polynomial_product(&ci, &cj, width);
241 penalty[[i, j]] += contribution;
242 if i != j {
243 penalty[[j, i]] += contribution;
244 }
245 }
246 }
247 }
248 Ok(penalty)
249}
250
251pub(crate) fn raw_span_derivative_polynomial_coefficients(
256 span_idx: usize,
257 basis_idx: usize,
258 derivative_order: usize,
259 raw_span_c0: &Array2<f64>,
260 raw_span_c1: &Array2<f64>,
261 raw_span_c2: &Array2<f64>,
262 raw_span_c3: &Array2<f64>,
263) -> Vec<f64> {
264 let c0 = raw_span_c0[[span_idx, basis_idx]];
265 let c1 = raw_span_c1[[span_idx, basis_idx]];
266 let c2 = raw_span_c2[[span_idx, basis_idx]];
267 let c3 = raw_span_c3[[span_idx, basis_idx]];
268 match derivative_order {
269 0 => vec![c0, c1, c2, c3],
270 1 => vec![c1, 2.0 * c2, 3.0 * c3],
271 2 => vec![2.0 * c2, 6.0 * c3],
272 3 => vec![6.0 * c3],
273 _ => Vec::new(),
274 }
275}
276
277pub(crate) fn smoothness_nullspace_orthogonal_complement(
290 raw_penalty: &Array2<f64>,
291) -> Result<Array2<f64>, String> {
292 let n = raw_penalty.nrows();
293 if raw_penalty.ncols() != n {
294 return Err("smoothness penalty matrix must be square for null-space drop".to_string());
295 }
296 let (eigenvalues, eigenvectors) = raw_penalty
297 .eigh(faer::Side::Lower)
298 .map_err(|e| format!("raw smoothness penalty eigendecomposition failed: {e}"))?;
299 let evals = eigenvalues
300 .as_slice()
301 .ok_or_else(|| "raw smoothness penalty eigenvalues are not contiguous".to_string())?;
302 let threshold =
303 gam_solve::estimate::reml::reml_outer_engine::positive_eigenvalue_threshold(evals);
304 let kept: Vec<usize> = evals
305 .iter()
306 .enumerate()
307 .filter_map(|(i, &v)| (v > threshold).then_some(i))
308 .collect();
309 if kept.is_empty() {
310 return Err(
311 "smoothness penalty has no positive eigenvalues; basis is entirely in the penalty's \
312 null space and cannot be identified after the smoothness null-space drop"
313 .to_string(),
314 );
315 }
316 if kept.len() == n {
317 return Err(
318 "smoothness penalty has no null directions; nothing to drop. The link-deviation \
319 basis was expected to carry a non-trivial null space (constants/linears) for \
320 absorption by the location block — check the configured penalty derivative order"
321 .to_string(),
322 );
323 }
324 let mut z = Array2::<f64>::zeros((n, kept.len()));
325 for (col_out, &col_in) in kept.iter().enumerate() {
326 z.column_mut(col_out).assign(&eigenvectors.column(col_in));
327 }
328 Ok(z)
329}
330
331pub(crate) fn build_quadratic_derivative_bernstein_constraints(
332 endpoint_points: &Array1<f64>,
333 span_c1: &Array2<f64>,
334 span_c2: &Array2<f64>,
335 span_c3: &Array2<f64>,
336) -> Result<Array2<f64>, String> {
337 let n_spans = endpoint_points.len().saturating_sub(1);
338 let basis_dim = span_c1.ncols();
339 let mut rows = Array2::<f64>::zeros((3 * n_spans, basis_dim));
340 for span_idx in 0..n_spans {
341 let width = endpoint_points[span_idx + 1] - endpoint_points[span_idx];
342 if !width.is_finite() || width <= 0.0 {
343 return Err(DeviationRuntimeError::InvalidInput {
344 reason: format!(
345 "DeviationRuntime monotonicity span {span_idx} has invalid width {width}"
346 ),
347 }
348 .into());
349 }
350 let left_row = 3 * span_idx;
351 let mid_row = left_row + 1;
352 let right_row = left_row + 2;
353 for basis_idx in 0..basis_dim {
354 let c1 = span_c1[[span_idx, basis_idx]];
355 let c2 = span_c2[[span_idx, basis_idx]];
356 let c3 = span_c3[[span_idx, basis_idx]];
357 rows[[left_row, basis_idx]] = c1;
368 rows[[mid_row, basis_idx]] = c1 + c2 * width;
369 rows[[right_row, basis_idx]] = c1 + 2.0 * c2 * width + 3.0 * c3 * width * width;
370 }
371 }
372 Ok(rows)
373}
374
375impl DeviationRuntime {
376 pub(crate) fn try_new(
391 knots: Array1<f64>,
392 monotonicity_eps: f64,
393 max_penalty_derivative_order: usize,
394 ) -> Result<Self, String> {
395 Self::try_new_with_smoothness_drop(knots, monotonicity_eps, max_penalty_derivative_order)
396 }
397
398 pub(super) fn try_new_with_smoothness_drop(
399 knots: Array1<f64>,
400 monotonicity_eps: f64,
401 max_penalty_derivative_order: usize,
402 ) -> Result<Self, String> {
403 if !monotonicity_eps.is_finite() || monotonicity_eps < 0.0 {
404 return Err(DeviationRuntimeError::InvalidInput {
405 reason: format!(
406 "DeviationRuntime monotonicity_eps must be finite and non-negative, got {monotonicity_eps}"
407 ),
408 }
409 .into());
410 }
411
412 let bkpts = breakpoints_from_knots(
413 knots.as_slice().ok_or_else(|| {
414 String::from(DeviationRuntimeError::InvalidInput {
415 reason: "DeviationRuntime knots are not contiguous".to_string(),
416 })
417 })?,
418 "DeviationRuntime breakpoints",
419 )?;
420 let endpoint_points = Array1::from_vec(bkpts);
421 if endpoint_points.len() < 3 {
422 return Err(DeviationRuntimeError::InvalidInput {
423 reason:
424 "DeviationRuntime requires at least two active knot spans and one interior node"
425 .to_string(),
426 }
427 .into());
428 }
429 let n_spans = endpoint_points.len() - 1;
430 for span_idx in 0..n_spans {
431 let left = endpoint_points[span_idx];
432 let right = endpoint_points[span_idx + 1];
433 let width = right - left;
434 if !width.is_finite() || width <= 0.0 {
435 return Err(DeviationRuntimeError::InvalidInput {
436 reason: format!(
437 "DeviationRuntime requires strictly increasing span endpoints at span {span_idx}: left={left}, right={right}"
438 ),
439 }
440 .into());
441 }
442 }
443 let span_lefts = Array1::from_iter((0..n_spans).map(|idx| endpoint_points[idx]));
444 let span_midpoints = Array1::from_iter(
445 (0..n_spans).map(|idx| 0.5 * (endpoint_points[idx] + endpoint_points[idx + 1])),
446 );
447 let right_endpoint = Array1::from_vec(vec![endpoint_points[n_spans]]);
448 let internal_degree = 2usize;
449 let raw_span_c0 =
450 create_ispline_derivative_dense(span_lefts.view(), &knots, internal_degree, 0)
451 .map_err(|e| {
452 String::from(DeviationRuntimeError::NumericalFailure {
453 reason: format!("DeviationRuntime cubic I-spline values failed: {e}"),
454 })
455 })?;
456 let raw_span_c1 =
457 create_ispline_derivative_dense(span_lefts.view(), &knots, internal_degree, 1)
458 .map_err(|e| {
459 String::from(DeviationRuntimeError::NumericalFailure {
460 reason: format!(
461 "DeviationRuntime cubic I-spline first derivatives failed: {e}"
462 ),
463 })
464 })?;
465 let raw_span_c2 =
466 create_ispline_derivative_dense(span_lefts.view(), &knots, internal_degree, 2)
467 .map_err(|e| {
468 String::from(DeviationRuntimeError::NumericalFailure {
469 reason: format!(
470 "DeviationRuntime cubic I-spline second derivatives failed: {e}"
471 ),
472 })
473 })?
474 .mapv(|value| 0.5 * value);
475 let raw_span_c3 =
476 create_ispline_derivative_dense(span_midpoints.view(), &knots, internal_degree, 3)
477 .map_err(|e| {
478 String::from(DeviationRuntimeError::NumericalFailure {
479 reason: format!(
480 "DeviationRuntime cubic I-spline third derivatives failed: {e}"
481 ),
482 })
483 })?
484 .mapv(|value| value / 6.0);
485 let raw_right_boundary_values =
486 create_ispline_derivative_dense(right_endpoint.view(), &knots, internal_degree, 0)
487 .map_err(|e| {
488 String::from(DeviationRuntimeError::NumericalFailure {
489 reason: format!(
490 "DeviationRuntime cubic I-spline right boundary failed: {e}"
491 ),
492 })
493 })?;
494 let raw_right_boundary_value_row = raw_right_boundary_values.row(0).to_owned();
495
496 if max_penalty_derivative_order == 0 {
497 return Err(
498 "DeviationRuntime requires max_penalty_derivative_order >= 1 so the basis can \
499 drop the corresponding smoothness null space; an order-0 (mass) penalty alone \
500 has no null space and would not require any drop"
501 .to_string(),
502 );
503 }
504 if max_penalty_derivative_order > 3 {
505 return Err(format!(
506 "DeviationRuntime cubic basis supports derivative orders up to 3; got max \
507 penalty derivative order {max_penalty_derivative_order}"
508 ));
509 }
510 let raw_smoothness_penalty = raw_integrated_derivative_penalty(
511 &endpoint_points,
512 &raw_span_c0,
513 &raw_span_c1,
514 &raw_span_c2,
515 &raw_span_c3,
516 max_penalty_derivative_order,
517 )?;
518 let coefficient_transform =
519 smoothness_nullspace_orthogonal_complement(&raw_smoothness_penalty)?;
520 let basis_dim = coefficient_transform.ncols();
521 let span_c0 = fast_ab(&raw_span_c0, &coefficient_transform);
522 let span_c1 = fast_ab(&raw_span_c1, &coefficient_transform);
523 let span_c2 = fast_ab(&raw_span_c2, &coefficient_transform);
524 let span_c3 = fast_ab(&raw_span_c3, &coefficient_transform);
525 let right_boundary_value_row = raw_right_boundary_value_row.dot(&coefficient_transform);
526 let monotonicity_constraint_rows = build_quadratic_derivative_bernstein_constraints(
527 &endpoint_points,
528 &span_c1,
529 &span_c2,
530 &span_c3,
531 )?;
532
533 Ok(Self {
534 degree: 3,
535 value_span_degree: 3,
536 basis_dim,
537 monotonicity_eps,
538 endpoint_points,
539 span_c0,
540 span_c1,
541 span_c2,
542 span_c3,
543 monotonicity_constraint_rows,
544 right_boundary_value_row,
545 installed_flex_block: None,
546 anchor_rows_at_training: None,
547 })
548 }
549
550 pub(crate) fn compose_anchor_orthogonalisation(
607 &mut self,
608 right_selector: &Array2<f64>,
609 installed_flex_block: Option<InstalledFlexBlock>,
610 ) -> Result<(), String> {
611 let old_dim = self.basis_dim;
612 if right_selector.nrows() != old_dim {
613 return Err(DeviationRuntimeError::DimensionMismatch {
614 reason: format!(
615 "DeviationRuntime cross-block transform shape mismatch: \
616 transform rows={}, expected basis_dim={}",
617 right_selector.nrows(),
618 old_dim,
619 ),
620 }
621 .into());
622 }
623 let new_dim = right_selector.ncols();
624 if new_dim == 0 {
625 return Err(DeviationRuntimeError::DimensionMismatch {
626 reason: "DeviationRuntime cross-block transform reduces basis dim to 0; \
627 the candidate's column span is fully aliased by the anchor block"
628 .to_string(),
629 }
630 .into());
631 }
632 if new_dim > old_dim {
633 return Err(DeviationRuntimeError::DimensionMismatch {
634 reason: format!(
635 "DeviationRuntime cross-block transform must not increase basis dim; \
636 got new_dim={} from old_dim={}",
637 new_dim, old_dim,
638 ),
639 }
640 .into());
641 }
642 if let Some(ref installed) = installed_flex_block {
643 let d_expected: usize = installed
644 .anchor_components
645 .iter()
646 .map(|c| match c {
647 AnchorComponentTag::Parametric { ncols, .. } => *ncols,
648 AnchorComponentTag::FlexEvaluation { ncols } => *ncols,
649 })
650 .sum();
651 if installed.anchor_correction.nrows() != d_expected {
652 return Err(DeviationRuntimeError::DimensionMismatch {
653 reason: format!(
654 "DeviationRuntime installed flex block: anchor_correction rows={}, expected sum-of-component-ncols={}",
655 installed.anchor_correction.nrows(),
656 d_expected,
657 ),
658 }
659 .into());
660 }
661 if installed.anchor_correction.ncols() != new_dim {
662 return Err(DeviationRuntimeError::DimensionMismatch {
663 reason: format!(
664 "DeviationRuntime installed flex block: anchor_correction cols={}, expected new basis dim {}",
665 installed.anchor_correction.ncols(),
666 new_dim,
667 ),
668 }
669 .into());
670 }
671 }
672 self.span_c0 = fast_ab(&self.span_c0, right_selector);
673 self.span_c1 = fast_ab(&self.span_c1, right_selector);
674 self.span_c2 = fast_ab(&self.span_c2, right_selector);
675 self.span_c3 = fast_ab(&self.span_c3, right_selector);
676 self.right_boundary_value_row = self.right_boundary_value_row.dot(right_selector);
679 self.monotonicity_constraint_rows =
684 fast_ab(&self.monotonicity_constraint_rows, right_selector);
685 self.basis_dim = new_dim;
686 self.installed_flex_block = installed_flex_block;
687 Ok(())
688 }
689
690 pub fn installed_flex_block(&self) -> Option<&InstalledFlexBlock> {
695 self.installed_flex_block.as_ref()
696 }
697
698 pub(crate) fn install_compiled_flex_block(
711 &mut self,
712 compiled: &gam_identifiability::families::compiler::CompiledBlock,
713 anchor_components: Vec<AnchorComponentTag>,
714 n_train_at_training: Array2<f64>,
715 ) -> Result<(), String> {
716 let m = compiled.anchor_correction.as_ref().ok_or_else(|| {
717 "DeviationRuntime::install_compiled_flex_block: compiled block has no \
718 anchor_correction — install requires a non-empty anchor union"
719 .to_string()
720 })?;
721 let installed = InstalledFlexBlock {
722 anchor_correction: m.clone(),
723 anchor_components,
724 };
725 self.anchor_rows_at_training = Some(n_train_at_training);
726 self.compose_anchor_orthogonalisation(&compiled.t_lw, Some(installed))
727 }
728
729 pub fn anchor_rows_at_training(&self) -> Option<&Array2<f64>> {
736 self.anchor_rows_at_training.as_ref()
737 }
738
739 pub fn design_with_anchor_rows(
745 &self,
746 values: &Array1<f64>,
747 anchor_rows: ArrayView2<f64>,
748 ) -> Result<Array2<f64>, String> {
749 let mut out = self.evaluate_span_polynomial_design_raw(values, 0)?;
750 if let Some(installed) = &self.installed_flex_block {
751 if anchor_rows.nrows() != values.len() {
752 return Err(DeviationRuntimeError::DimensionMismatch {
753 reason: format!(
754 "design_with_anchor_rows: anchor_rows has {} rows, expected {} (matching values)",
755 anchor_rows.nrows(),
756 values.len(),
757 ),
758 }
759 .into());
760 }
761 if anchor_rows.ncols() != installed.anchor_correction.nrows() {
762 return Err(DeviationRuntimeError::DimensionMismatch {
763 reason: format!(
764 "design_with_anchor_rows: anchor_rows has {} cols, expected {} (sum of component ncols)",
765 anchor_rows.ncols(),
766 installed.anchor_correction.nrows(),
767 ),
768 }
769 .into());
770 }
771 let subtract = anchor_rows.dot(&installed.anchor_correction);
772 out = out - subtract;
773 } else if anchor_rows.ncols() != 0 {
774 return Err(DeviationRuntimeError::DimensionMismatch {
777 reason: format!(
778 "design_with_anchor_rows: runtime has no installed flex block but anchor_rows has {} cols",
779 anchor_rows.ncols(),
780 ),
781 }
782 .into());
783 }
784 Ok(out)
785 }
786
787 pub(crate) fn design_at_training_with_residual(
790 &self,
791 values: &Array1<f64>,
792 ) -> Result<Array2<f64>, String> {
793 if let Some(rows) = self.anchor_rows_at_training.as_ref() {
794 self.design_with_anchor_rows(values, rows.view())
795 } else if self.installed_flex_block.is_some() {
796 Err(
797 "design_at_training_with_residual: runtime has installed_flex_block but no cached training anchor rows"
798 .to_string(),
799 )
800 } else {
801 self.design(values)
802 }
803 }
804
805 pub fn degree(&self) -> usize {
808 self.degree
809 }
810
811 pub fn value_span_degree(&self) -> usize {
812 self.value_span_degree
813 }
814
815 pub fn basis_dim(&self) -> usize {
816 self.basis_dim
817 }
818
819 pub fn monotonicity_eps(&self) -> f64 {
820 self.monotonicity_eps
821 }
822
823 pub fn span_c0(&self) -> &Array2<f64> {
824 &self.span_c0
825 }
826
827 pub fn span_c1(&self) -> &Array2<f64> {
828 &self.span_c1
829 }
830
831 pub fn span_c2(&self) -> &Array2<f64> {
832 &self.span_c2
833 }
834
835 pub fn span_c3(&self) -> &Array2<f64> {
836 &self.span_c3
837 }
838
839 pub(super) fn validate_beta_shape(
842 &self,
843 beta: &Array1<f64>,
844 label: &str,
845 ) -> Result<(), String> {
846 if beta.len() != self.basis_dim {
847 return Err(DeviationRuntimeError::DimensionMismatch {
848 reason: format!(
849 "{label} length mismatch: got {}, expected {}",
850 beta.len(),
851 self.basis_dim
852 ),
853 }
854 .into());
855 }
856 Ok::<(), _>(())
857 }
858
859 pub(super) fn evaluate_span_polynomial_design_raw(
864 &self,
865 values: &Array1<f64>,
866 derivative_order: usize,
867 ) -> Result<Array2<f64>, String> {
868 let (left_ep, right_ep) = self.support_interval()?;
869 let mut out = Array2::<f64>::zeros((values.len(), self.basis_dim));
870 for (row_idx, &value) in values.iter().enumerate() {
871 if !value.is_finite() {
872 return Err(DeviationRuntimeError::InvalidInput {
873 reason: format!(
874 "deviation runtime design value at row {row_idx} is non-finite ({value})"
875 ),
876 }
877 .into());
878 }
879 if value < left_ep {
880 if derivative_order == 0 {
881 out.row_mut(row_idx).assign(&self.span_c0.row(0));
882 }
883 continue;
884 }
885 if value > right_ep {
886 if derivative_order == 0 {
887 out.row_mut(row_idx)
888 .assign(&self.right_boundary_value_row.view());
889 }
890 continue;
891 }
892 let span_idx = self.left_biased_span_index_for(value)?;
893 let left = self.endpoint_points[span_idx];
894 let t = value - left;
895 for basis_idx in 0..self.basis_dim {
896 let c0 = self.span_c0[[span_idx, basis_idx]];
897 let c1 = self.span_c1[[span_idx, basis_idx]];
898 let c2 = self.span_c2[[span_idx, basis_idx]];
899 let c3 = self.span_c3[[span_idx, basis_idx]];
900 out[[row_idx, basis_idx]] = match derivative_order {
901 0 => c0 + c1 * t + c2 * t * t + c3 * t * t * t,
902 1 => c1 + 2.0 * c2 * t + 3.0 * c3 * t * t,
903 2 => 2.0 * c2 + 6.0 * c3 * t,
904 3 => 6.0 * c3,
905 4 => 0.0,
906 other => {
907 return Err(DeviationRuntimeError::InvalidInput {
908 reason: format!(
909 "deviation runtime only supports derivative orders up to 4, got {other}"
910 ),
911 }
912 .into());
913 }
914 };
915 }
916 }
917 Ok(out)
918 }
919
920 pub fn design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
926 assert!(
927 self.installed_flex_block.is_none(),
928 "DeviationRuntime::design called on a runtime with an installed flex block; \
929 use design_with_anchor_rows or design_at_training_with_residual instead"
930 );
931 self.evaluate_span_polynomial_design_raw(values, 0)
932 }
933
934 pub fn first_derivative_design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
935 self.evaluate_span_polynomial_design_raw(values, 1)
936 }
937
938 pub fn second_derivative_design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
939 self.evaluate_span_polynomial_design_raw(values, 2)
940 }
941
942 pub fn third_derivative_design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
943 self.evaluate_span_polynomial_design_raw(values, 3)
944 }
945
946 pub(crate) fn integrated_derivative_penalty_with_nullity(
947 &self,
948 derivative_order: usize,
949 ) -> Result<(Array2<f64>, usize), String> {
950 if derivative_order > self.value_span_degree {
951 return Err(DeviationRuntimeError::InvalidInput {
952 reason: format!(
953 "deviation penalty derivative order {derivative_order} exceeds value-basis degree {}",
954 self.value_span_degree
955 ),
956 }
957 .into());
958 }
959 let mut penalty = Array2::<f64>::zeros((self.basis_dim, self.basis_dim));
960 for span_idx in 0..self.span_count() {
961 let (left, right) = self.span_interval(span_idx)?;
962 let width = right - left;
963 if !width.is_finite() || width <= 0.0 {
964 return Err(DeviationRuntimeError::InvalidInput {
965 reason: format!("deviation penalty span {span_idx} has invalid width {width}"),
966 }
967 .into());
968 }
969 for i in 0..self.basis_dim {
970 let ci =
971 self.span_derivative_polynomial_coefficients(span_idx, i, derivative_order)?;
972 for j in i..self.basis_dim {
973 let cj = self.span_derivative_polynomial_coefficients(
974 span_idx,
975 j,
976 derivative_order,
977 )?;
978 let contribution = integrate_polynomial_product(&ci, &cj, width);
979 penalty[[i, j]] += contribution;
980 if i != j {
981 penalty[[j, i]] += contribution;
982 }
983 }
984 }
985 }
986 let (evals, _) = penalty.eigh(faer::Side::Lower).map_err(|e| {
987 String::from(DeviationRuntimeError::NumericalFailure {
988 reason: format!("deviation integrated penalty eigendecomposition failed: {e}"),
989 })
990 })?;
991 let threshold = gam_solve::estimate::reml::reml_outer_engine::positive_eigenvalue_threshold(
992 evals.as_slice().ok_or_else(|| {
993 String::from(DeviationRuntimeError::NumericalFailure {
994 reason: "deviation penalty eigenvalues are not contiguous".to_string(),
995 })
996 })?,
997 );
998 let rank = evals.iter().filter(|&&value| value > threshold).count();
999 let nullity = self.basis_dim.saturating_sub(rank);
1000 Ok((penalty, nullity))
1001 }
1002
1003 pub(crate) fn structural_monotonicity_constraints(&self) -> LinearInequalityConstraints {
1004 LinearInequalityConstraints {
1005 a: self.monotonicity_constraint_rows.clone(),
1006 b: Array1::from_elem(
1007 self.monotonicity_constraint_rows.nrows(),
1008 self.monotonicity_eps - 1.0,
1009 ),
1010 }
1011 }
1012
1013 pub(super) fn span_count(&self) -> usize {
1016 self.endpoint_points.len().saturating_sub(1)
1017 }
1018
1019 pub fn breakpoints(&self) -> &Array1<f64> {
1020 &self.endpoint_points
1021 }
1022
1023 pub(super) fn span_interval(&self, span_idx: usize) -> Result<(f64, f64), String> {
1024 if span_idx >= self.span_count() {
1025 return Err(DeviationRuntimeError::InvalidInput {
1026 reason: format!(
1027 "deviation span index {} out of range for {} spans",
1028 span_idx,
1029 self.span_count()
1030 ),
1031 }
1032 .into());
1033 }
1034 Ok((
1035 self.endpoint_points[span_idx],
1036 self.endpoint_points[span_idx + 1],
1037 ))
1038 }
1039
1040 pub(super) fn span_index_for(&self, value: f64) -> Result<usize, String> {
1041 span_index_for_breakpoints(
1042 self.endpoint_points.as_slice().ok_or_else(|| {
1043 String::from(DeviationRuntimeError::InvalidInput {
1044 reason: "deviation runtime breakpoints are not contiguous".to_string(),
1045 })
1046 })?,
1047 value,
1048 "deviation span lookup",
1049 )
1050 }
1051
1052 pub(super) fn left_biased_span_index_for(&self, value: f64) -> Result<usize, String> {
1053 let mut span_idx = self.span_index_for(value)?;
1054 if span_idx > 0 && value == self.endpoint_points[span_idx] {
1058 span_idx -= 1;
1059 }
1060 Ok(span_idx)
1061 }
1062
1063 pub(super) fn span_derivative_polynomial_coefficients(
1064 &self,
1065 span_idx: usize,
1066 basis_idx: usize,
1067 derivative_order: usize,
1068 ) -> Result<Vec<f64>, String> {
1069 if span_idx >= self.span_count() {
1070 return Err(DeviationRuntimeError::InvalidInput {
1071 reason: format!(
1072 "deviation span index {} out of range for {} spans",
1073 span_idx,
1074 self.span_count()
1075 ),
1076 }
1077 .into());
1078 }
1079 if basis_idx >= self.basis_dim {
1080 return Err(DeviationRuntimeError::InvalidInput {
1081 reason: format!(
1082 "deviation basis index {} out of range for {} coefficients",
1083 basis_idx, self.basis_dim
1084 ),
1085 }
1086 .into());
1087 }
1088 let c0 = self.span_c0[[span_idx, basis_idx]];
1089 let c1 = self.span_c1[[span_idx, basis_idx]];
1090 let c2 = self.span_c2[[span_idx, basis_idx]];
1091 let c3 = self.span_c3[[span_idx, basis_idx]];
1092 match derivative_order {
1093 0 => Ok(vec![c0, c1, c2, c3]),
1094 1 => Ok(vec![c1, 2.0 * c2, 3.0 * c3]),
1095 2 => Ok(vec![2.0 * c2, 6.0 * c3]),
1096 3 => Ok(vec![6.0 * c3]),
1097 other => Err(DeviationRuntimeError::InvalidInput {
1098 reason: format!(
1099 "deviation polynomial coefficients only support derivative orders up to 3, got {other}"
1100 ),
1101 }
1102 .into()),
1103 }
1104 }
1105
1106 pub(crate) fn local_cubic_on_span(
1109 &self,
1110 beta: &Array1<f64>,
1111 span_idx: usize,
1112 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1113 self.validate_beta_shape(beta, "deviation local cubic coefficients")?;
1114 let (left, right) = self.span_interval(span_idx)?;
1115 Ok(exact_kernel::LocalSpanCubic {
1116 left,
1117 right,
1118 c0: self.span_c0.row(span_idx).dot(beta),
1119 c1: self.span_c1.row(span_idx).dot(beta),
1120 c2: self.span_c2.row(span_idx).dot(beta),
1121 c3: self.span_c3.row(span_idx).dot(beta),
1122 })
1123 }
1124
1125 pub fn basis_span_cubic(
1126 &self,
1127 span_idx: usize,
1128 basis_idx: usize,
1129 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1130 if basis_idx >= self.basis_dim {
1131 return Err(DeviationRuntimeError::InvalidInput {
1132 reason: format!(
1133 "deviation basis index {} out of range for {} coefficients",
1134 basis_idx, self.basis_dim
1135 ),
1136 }
1137 .into());
1138 }
1139 let (left, right) = self.span_interval(span_idx)?;
1140 Ok(exact_kernel::LocalSpanCubic {
1141 left,
1142 right,
1143 c0: self.span_c0[[span_idx, basis_idx]],
1144 c1: self.span_c1[[span_idx, basis_idx]],
1145 c2: self.span_c2[[span_idx, basis_idx]],
1146 c3: self.span_c3[[span_idx, basis_idx]],
1147 })
1148 }
1149
1150 pub fn basis_cubic_at(
1155 &self,
1156 basis_idx: usize,
1157 value: f64,
1158 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1159 if basis_idx >= self.basis_dim {
1160 return Err(DeviationRuntimeError::InvalidInput {
1161 reason: format!(
1162 "deviation basis index {} out of range for {} coefficients",
1163 basis_idx, self.basis_dim
1164 ),
1165 }
1166 .into());
1167 }
1168 let (left_ep, right_ep) = self.support_interval()?;
1169 if value < left_ep {
1170 return Ok(exact_kernel::LocalSpanCubic {
1171 left: left_ep,
1172 right: left_ep + 1.0,
1173 c0: self.span_c0[[0, basis_idx]],
1174 c1: 0.0,
1175 c2: 0.0,
1176 c3: 0.0,
1177 });
1178 }
1179 if value > right_ep {
1180 return Ok(exact_kernel::LocalSpanCubic {
1181 left: right_ep,
1182 right: right_ep + 1.0,
1183 c0: self.right_boundary_value_row[basis_idx],
1184 c1: 0.0,
1185 c2: 0.0,
1186 c3: 0.0,
1187 });
1188 }
1189 let span_idx = self.left_biased_span_index_for(value)?;
1190 self.basis_span_cubic(span_idx, basis_idx)
1191 }
1192
1193 pub fn for_each_basis_cubic_at<F>(&self, value: f64, mut visit: F) -> Result<(), String>
1194 where
1195 F: FnMut(usize, exact_kernel::LocalSpanCubic) -> Result<(), String>,
1196 {
1197 let (left_ep, right_ep) = self.support_interval()?;
1198 if value < left_ep {
1199 for basis_idx in 0..self.basis_dim {
1200 visit(
1201 basis_idx,
1202 exact_kernel::LocalSpanCubic {
1203 left: left_ep,
1204 right: left_ep + 1.0,
1205 c0: self.span_c0[[0, basis_idx]],
1206 c1: 0.0,
1207 c2: 0.0,
1208 c3: 0.0,
1209 },
1210 )?;
1211 }
1212 return Ok(());
1213 }
1214 if value > right_ep {
1215 for basis_idx in 0..self.basis_dim {
1216 visit(
1217 basis_idx,
1218 exact_kernel::LocalSpanCubic {
1219 left: right_ep,
1220 right: right_ep + 1.0,
1221 c0: self.right_boundary_value_row[basis_idx],
1222 c1: 0.0,
1223 c2: 0.0,
1224 c3: 0.0,
1225 },
1226 )?;
1227 }
1228 return Ok(());
1229 }
1230
1231 let span_idx = self.left_biased_span_index_for(value)?;
1232 let (left, right) = self.span_interval(span_idx)?;
1233 for basis_idx in 0..self.basis_dim {
1234 visit(
1235 basis_idx,
1236 exact_kernel::LocalSpanCubic {
1237 left,
1238 right,
1239 c0: self.span_c0[[span_idx, basis_idx]],
1240 c1: self.span_c1[[span_idx, basis_idx]],
1241 c2: self.span_c2[[span_idx, basis_idx]],
1242 c3: self.span_c3[[span_idx, basis_idx]],
1243 },
1244 )?;
1245 }
1246 Ok(())
1247 }
1248
1249 pub(crate) fn local_cubic_at(
1254 &self,
1255 beta: &Array1<f64>,
1256 value: f64,
1257 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1258 self.validate_beta_shape(beta, "deviation local cubic")?;
1259 let (left_ep, right_ep) = self.support_interval()?;
1260 if value < left_ep {
1261 return Ok(exact_kernel::LocalSpanCubic {
1262 left: left_ep,
1263 right: left_ep + 1.0,
1264 c0: self.left_tail_value(beta),
1265 c1: 0.0,
1266 c2: 0.0,
1267 c3: 0.0,
1268 });
1269 }
1270 if value > right_ep {
1271 return Ok(exact_kernel::LocalSpanCubic {
1272 left: right_ep,
1273 right: right_ep + 1.0,
1274 c0: self.right_tail_value(beta),
1275 c1: 0.0,
1276 c2: 0.0,
1277 c3: 0.0,
1278 });
1279 }
1280 let span_idx = self.left_biased_span_index_for(value)?;
1281 self.local_cubic_on_span(beta, span_idx)
1282 }
1283
1284 pub(super) fn left_tail_value(&self, beta: &Array1<f64>) -> f64 {
1289 self.span_c0.row(0).dot(beta)
1290 }
1291
1292 pub(super) fn right_tail_value(&self, beta: &Array1<f64>) -> f64 {
1295 self.right_boundary_value_row.dot(beta)
1296 }
1297
1298 pub(crate) fn value_basis_l1_sup_norm(&self) -> f64 {
1307 let mut total = 0.0;
1308 for basis_idx in 0..self.basis_dim {
1309 let mut col_sup = self.span_c0[[0, basis_idx]]
1310 .abs()
1311 .max(self.right_boundary_value_row[basis_idx].abs());
1312 for span_idx in 0..self.span_count() {
1313 let left = self.endpoint_points[span_idx];
1314 let right = self.endpoint_points[span_idx + 1];
1315 let width = right - left;
1316 if !width.is_finite() || width <= 0.0 {
1317 continue;
1318 }
1319 let c0 = self.span_c0[[span_idx, basis_idx]];
1320 let c1 = self.span_c1[[span_idx, basis_idx]];
1321 let c2 = self.span_c2[[span_idx, basis_idx]];
1322 let c3 = self.span_c3[[span_idx, basis_idx]];
1323 let eval_abs = |t: f64| (c0 + c1 * t + c2 * t * t + c3 * t * t * t).abs();
1324 col_sup = col_sup.max(eval_abs(0.0)).max(eval_abs(width));
1325 let a = 3.0 * c3;
1326 let b = 2.0 * c2;
1327 let c = c1;
1328 if a.abs() <= f64::EPSILON {
1329 if b.abs() > f64::EPSILON {
1330 let t = -c / b;
1331 if t > 0.0 && t < width {
1332 col_sup = col_sup.max(eval_abs(t));
1333 }
1334 }
1335 } else {
1336 let disc = b * b - 4.0 * a * c;
1337 if disc >= 0.0 {
1338 let sqrt_disc = disc.sqrt();
1339 for t in [(-b - sqrt_disc) / (2.0 * a), (-b + sqrt_disc) / (2.0 * a)] {
1340 if t > 0.0 && t < width {
1341 col_sup = col_sup.max(eval_abs(t));
1342 }
1343 }
1344 }
1345 }
1346 }
1347 total += col_sup;
1348 }
1349 total
1350 }
1351
1352 pub(super) fn support_interval(&self) -> Result<(f64, f64), String> {
1355 match (self.endpoint_points.first(), self.endpoint_points.last()) {
1356 (Some(&left), Some(&right)) => Ok((left, right)),
1357 _ => Err(DeviationRuntimeError::InvalidInput {
1358 reason: "deviation runtime is missing monotonicity support points".to_string(),
1359 }
1360 .into()),
1361 }
1362 }
1363
1364 pub(crate) fn exact_monotonicity_min_slack(&self, beta: &Array1<f64>) -> Result<f64, String> {
1365 if beta.len() != self.basis_dim {
1366 return Err(DeviationRuntimeError::DimensionMismatch {
1367 reason: format!(
1368 "deviation monotonicity length mismatch: got {}, expected {}",
1369 beta.len(),
1370 self.basis_dim
1371 ),
1372 }
1373 .into());
1374 }
1375 if beta.iter().any(|value| !value.is_finite()) {
1376 let bad = beta
1377 .iter()
1378 .enumerate()
1379 .find(|(_, value)| !value.is_finite())
1380 .map(|(idx, value)| format!("deviation coefficient {idx} is non-finite ({value})"))
1381 .unwrap_or_else(|| "deviation coefficient is non-finite".to_string());
1382 return Err(DeviationRuntimeError::InvalidInput { reason: bad }.into());
1383 }
1384
1385 let mut min_slack = f64::INFINITY;
1386 for span_idx in 0..self.span_count() {
1387 let left = self.endpoint_points[span_idx];
1388 let right = self.endpoint_points[span_idx + 1];
1389 let width = right - left;
1390 if !width.is_finite() || width <= 0.0 {
1391 continue;
1392 }
1393 let c1 = self.span_c1.row(span_idx).dot(beta);
1394 let c2 = self.span_c2.row(span_idx).dot(beta);
1395 let c3 = self.span_c3.row(span_idx).dot(beta);
1396 let d1_left = c1;
1397 let d1_right = c1 + 2.0 * c2 * width + 3.0 * c3 * width * width;
1398 let d2_left = 2.0 * c2;
1399 let d3 = 6.0 * c3;
1400 let left_slack = 1.0 + d1_left - self.monotonicity_eps;
1401 let right_slack = 1.0 + d1_right - self.monotonicity_eps;
1402 min_slack = min_slack.min(left_slack.min(right_slack));
1403
1404 if d3 > 0.0 {
1405 let t_star = -d2_left / d3;
1406 if t_star > 0.0 && t_star < width {
1407 let interior = 1.0 + d1_left + d2_left * t_star + 0.5 * d3 * t_star * t_star
1408 - self.monotonicity_eps;
1409 min_slack = min_slack.min(interior);
1410 }
1411 }
1412 }
1413 if min_slack.is_finite() {
1414 Ok(min_slack)
1415 } else {
1416 Err(DeviationRuntimeError::NumericalFailure {
1417 reason: "deviation monotonicity slack computation produced no active spans"
1418 .to_string(),
1419 }
1420 .into())
1421 }
1422 }
1423
1424 pub(crate) fn monotonicity_feasible(
1425 &self,
1426 beta: &Array1<f64>,
1427 context: &str,
1428 ) -> Result<(), String> {
1429 let slack = self.exact_monotonicity_min_slack(beta)?;
1430 if slack >= MONOTONICITY_SLACK_ROUNDOFF_TOL {
1431 Ok(())
1432 } else {
1433 let (left, right) = self.support_interval()?;
1434 Err(DeviationRuntimeError::NumericalFailure {
1435 reason: format!(
1436 "{context} violates exact monotonicity on [{left:.6}, {right:.6}] (minimum derivative slack {slack:.3e}, eps={:.3e})",
1437 self.monotonicity_eps
1438 ),
1439 }
1440 .into())
1441 }
1442 }
1443}