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, ArrayView1, 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 from_exact_cubic_tables(
387 breakpoints: Array1<f64>,
388 span_c0: Array2<f64>,
389 span_c1: Array2<f64>,
390 span_c2: Array2<f64>,
391 span_c3: Array2<f64>,
392 installed_flex_block: Option<InstalledFlexBlock>,
393 anchor_rows_at_training: Option<Array2<f64>>,
394 ) -> Result<Self, String> {
395 validate_breakpoints(
396 breakpoints.as_slice().ok_or_else(|| {
397 String::from(DeviationRuntimeError::InvalidInput {
398 reason: "saved deviation breakpoints are not contiguous".to_string(),
399 })
400 })?,
401 "saved deviation replay breakpoints",
402 )?;
403 let n_spans = breakpoints.len() - 1;
404 let basis_dim = span_c0.ncols();
405 if basis_dim == 0 {
406 return Err(DeviationRuntimeError::DimensionMismatch {
407 reason: "saved deviation replay requires at least one basis column".to_string(),
408 }
409 .into());
410 }
411 let expected = (n_spans, basis_dim);
412 for (label, coefficients) in [
413 ("c0", &span_c0),
414 ("c1", &span_c1),
415 ("c2", &span_c2),
416 ("c3", &span_c3),
417 ] {
418 if coefficients.dim() != expected {
419 return Err(DeviationRuntimeError::DimensionMismatch {
420 reason: format!(
421 "saved deviation replay {label} table is {}x{}; expected {}x{}",
422 coefficients.nrows(),
423 coefficients.ncols(),
424 expected.0,
425 expected.1,
426 ),
427 }
428 .into());
429 }
430 if let Some(((row, column), value)) = coefficients
431 .indexed_iter()
432 .find(|(_, value)| !value.is_finite())
433 {
434 return Err(DeviationRuntimeError::InvalidInput {
435 reason: format!(
436 "saved deviation replay {label}[{row},{column}] is non-finite ({value})"
437 ),
438 }
439 .into());
440 }
441 }
442
443 let final_span = n_spans - 1;
444 let width = breakpoints[n_spans] - breakpoints[final_span];
445 let mut right_boundary_value_row = Array1::<f64>::zeros(basis_dim);
446 for basis in 0..basis_dim {
447 right_boundary_value_row[basis] = span_c0[[final_span, basis]]
448 + width
449 * (span_c1[[final_span, basis]]
450 + width
451 * (span_c2[[final_span, basis]]
452 + width * span_c3[[final_span, basis]]));
453 }
454 if let Some((basis, value)) = right_boundary_value_row
455 .iter()
456 .copied()
457 .enumerate()
458 .find(|(_, value)| !value.is_finite())
459 {
460 return Err(DeviationRuntimeError::InvalidInput {
461 reason: format!(
462 "saved deviation replay right-boundary value[{basis}] is non-finite ({value})"
463 ),
464 }
465 .into());
466 }
467 let monotonicity_constraint_rows = build_quadratic_derivative_bernstein_constraints(
468 &breakpoints,
469 &span_c1,
470 &span_c2,
471 &span_c3,
472 )?;
473
474 match (&installed_flex_block, &anchor_rows_at_training) {
475 (Some(installed), Some(rows)) => {
476 if rows.ncols() != installed.anchor_correction.nrows() {
477 return Err(DeviationRuntimeError::DimensionMismatch {
478 reason: format!(
479 "saved deviation replay anchor rows have {} columns; anchor correction requires {}",
480 rows.ncols(),
481 installed.anchor_correction.nrows(),
482 ),
483 }
484 .into());
485 }
486 if installed.anchor_correction.ncols() != basis_dim {
487 return Err(DeviationRuntimeError::DimensionMismatch {
488 reason: format!(
489 "saved deviation replay anchor correction has {} columns; basis has {basis_dim}",
490 installed.anchor_correction.ncols(),
491 ),
492 }
493 .into());
494 }
495 }
496 (Some(_), None) => {
497 return Err(DeviationRuntimeError::DimensionMismatch {
498 reason: "saved deviation replay has an anchor correction but no row-aligned anchor design"
499 .to_string(),
500 }
501 .into());
502 }
503 (None, Some(rows)) if rows.ncols() != 0 => {
504 return Err(DeviationRuntimeError::DimensionMismatch {
505 reason: format!(
506 "saved deviation replay has {} anchor columns but no anchor correction",
507 rows.ncols()
508 ),
509 }
510 .into());
511 }
512 (None, None) | (None, Some(_)) => {}
516 }
517
518 Ok(Self {
519 degree: 2,
520 value_span_degree: 3,
521 basis_dim,
522 monotonicity_eps: 0.0,
526 endpoint_points: breakpoints,
527 span_c0,
528 span_c1,
529 span_c2,
530 span_c3,
531 monotonicity_constraint_rows,
532 right_boundary_value_row,
533 installed_flex_block,
534 anchor_rows_at_training,
535 })
536 }
537
538 pub(crate) fn try_new(
553 knots: Array1<f64>,
554 monotonicity_eps: f64,
555 max_penalty_derivative_order: usize,
556 ) -> Result<Self, String> {
557 Self::try_new_with_smoothness_drop(knots, monotonicity_eps, max_penalty_derivative_order)
558 }
559
560 pub(super) fn try_new_with_smoothness_drop(
561 knots: Array1<f64>,
562 monotonicity_eps: f64,
563 max_penalty_derivative_order: usize,
564 ) -> Result<Self, String> {
565 if !monotonicity_eps.is_finite() || monotonicity_eps < 0.0 {
566 return Err(DeviationRuntimeError::InvalidInput {
567 reason: format!(
568 "DeviationRuntime monotonicity_eps must be finite and non-negative, got {monotonicity_eps}"
569 ),
570 }
571 .into());
572 }
573
574 let bkpts = breakpoints_from_knots(
575 knots.as_slice().ok_or_else(|| {
576 String::from(DeviationRuntimeError::InvalidInput {
577 reason: "DeviationRuntime knots are not contiguous".to_string(),
578 })
579 })?,
580 "DeviationRuntime breakpoints",
581 )?;
582 let endpoint_points = Array1::from_vec(bkpts);
583 if endpoint_points.len() < 3 {
584 return Err(DeviationRuntimeError::InvalidInput {
585 reason:
586 "DeviationRuntime requires at least two active knot spans and one interior node"
587 .to_string(),
588 }
589 .into());
590 }
591 let n_spans = endpoint_points.len() - 1;
592 for span_idx in 0..n_spans {
593 let left = endpoint_points[span_idx];
594 let right = endpoint_points[span_idx + 1];
595 let width = right - left;
596 if !width.is_finite() || width <= 0.0 {
597 return Err(DeviationRuntimeError::InvalidInput {
598 reason: format!(
599 "DeviationRuntime requires strictly increasing span endpoints at span {span_idx}: left={left}, right={right}"
600 ),
601 }
602 .into());
603 }
604 }
605 let span_lefts = Array1::from_iter((0..n_spans).map(|idx| endpoint_points[idx]));
606 let span_midpoints = Array1::from_iter(
607 (0..n_spans).map(|idx| 0.5 * (endpoint_points[idx] + endpoint_points[idx + 1])),
608 );
609 let right_endpoint = Array1::from_vec(vec![endpoint_points[n_spans]]);
610 let internal_degree = 2usize;
611 let raw_span_c0 =
612 create_ispline_derivative_dense(span_lefts.view(), &knots, internal_degree, 0)
613 .map_err(|e| {
614 String::from(DeviationRuntimeError::NumericalFailure {
615 reason: format!("DeviationRuntime cubic I-spline values failed: {e}"),
616 })
617 })?;
618 let raw_span_c1 =
619 create_ispline_derivative_dense(span_lefts.view(), &knots, internal_degree, 1)
620 .map_err(|e| {
621 String::from(DeviationRuntimeError::NumericalFailure {
622 reason: format!(
623 "DeviationRuntime cubic I-spline first derivatives failed: {e}"
624 ),
625 })
626 })?;
627 let raw_span_c2 =
628 create_ispline_derivative_dense(span_lefts.view(), &knots, internal_degree, 2)
629 .map_err(|e| {
630 String::from(DeviationRuntimeError::NumericalFailure {
631 reason: format!(
632 "DeviationRuntime cubic I-spline second derivatives failed: {e}"
633 ),
634 })
635 })?
636 .mapv(|value| 0.5 * value);
637 let raw_span_c3 =
638 create_ispline_derivative_dense(span_midpoints.view(), &knots, internal_degree, 3)
639 .map_err(|e| {
640 String::from(DeviationRuntimeError::NumericalFailure {
641 reason: format!(
642 "DeviationRuntime cubic I-spline third derivatives failed: {e}"
643 ),
644 })
645 })?
646 .mapv(|value| value / 6.0);
647 let raw_right_boundary_values =
648 create_ispline_derivative_dense(right_endpoint.view(), &knots, internal_degree, 0)
649 .map_err(|e| {
650 String::from(DeviationRuntimeError::NumericalFailure {
651 reason: format!(
652 "DeviationRuntime cubic I-spline right boundary failed: {e}"
653 ),
654 })
655 })?;
656 let raw_right_boundary_value_row = raw_right_boundary_values.row(0).to_owned();
657
658 if max_penalty_derivative_order == 0 {
659 return Err(
660 "DeviationRuntime requires max_penalty_derivative_order >= 1 so the basis can \
661 drop the corresponding smoothness null space; an order-0 (mass) penalty alone \
662 has no null space and would not require any drop"
663 .to_string(),
664 );
665 }
666 if max_penalty_derivative_order > 3 {
667 return Err(format!(
668 "DeviationRuntime cubic basis supports derivative orders up to 3; got max \
669 penalty derivative order {max_penalty_derivative_order}"
670 ));
671 }
672 let raw_smoothness_penalty = raw_integrated_derivative_penalty(
673 &endpoint_points,
674 &raw_span_c0,
675 &raw_span_c1,
676 &raw_span_c2,
677 &raw_span_c3,
678 max_penalty_derivative_order,
679 )?;
680 let coefficient_transform =
681 smoothness_nullspace_orthogonal_complement(&raw_smoothness_penalty)?;
682 let basis_dim = coefficient_transform.ncols();
683 let span_c0 = fast_ab(&raw_span_c0, &coefficient_transform);
684 let span_c1 = fast_ab(&raw_span_c1, &coefficient_transform);
685 let span_c2 = fast_ab(&raw_span_c2, &coefficient_transform);
686 let span_c3 = fast_ab(&raw_span_c3, &coefficient_transform);
687 let right_boundary_value_row = raw_right_boundary_value_row.dot(&coefficient_transform);
688 let monotonicity_constraint_rows = build_quadratic_derivative_bernstein_constraints(
689 &endpoint_points,
690 &span_c1,
691 &span_c2,
692 &span_c3,
693 )?;
694
695 Ok(Self {
696 degree: 3,
697 value_span_degree: 3,
698 basis_dim,
699 monotonicity_eps,
700 endpoint_points,
701 span_c0,
702 span_c1,
703 span_c2,
704 span_c3,
705 monotonicity_constraint_rows,
706 right_boundary_value_row,
707 installed_flex_block: None,
708 anchor_rows_at_training: None,
709 })
710 }
711
712 pub(crate) fn compose_anchor_orthogonalisation(
769 &mut self,
770 right_selector: &Array2<f64>,
771 installed_flex_block: Option<InstalledFlexBlock>,
772 ) -> Result<(), String> {
773 let old_dim = self.basis_dim;
774 if right_selector.nrows() != old_dim {
775 return Err(DeviationRuntimeError::DimensionMismatch {
776 reason: format!(
777 "DeviationRuntime cross-block transform shape mismatch: \
778 transform rows={}, expected basis_dim={}",
779 right_selector.nrows(),
780 old_dim,
781 ),
782 }
783 .into());
784 }
785 let new_dim = right_selector.ncols();
786 if new_dim == 0 {
787 return Err(DeviationRuntimeError::DimensionMismatch {
788 reason: "DeviationRuntime cross-block transform reduces basis dim to 0; \
789 the candidate's column span is fully aliased by the anchor block"
790 .to_string(),
791 }
792 .into());
793 }
794 if new_dim > old_dim {
795 return Err(DeviationRuntimeError::DimensionMismatch {
796 reason: format!(
797 "DeviationRuntime cross-block transform must not increase basis dim; \
798 got new_dim={} from old_dim={}",
799 new_dim, old_dim,
800 ),
801 }
802 .into());
803 }
804 if let Some(ref installed) = installed_flex_block {
805 let d_expected: usize = installed
806 .anchor_components
807 .iter()
808 .map(|c| match c {
809 AnchorComponentTag::Parametric { ncols, .. } => *ncols,
810 AnchorComponentTag::FlexEvaluation { ncols } => *ncols,
811 })
812 .sum();
813 if installed.anchor_correction.nrows() != d_expected {
814 return Err(DeviationRuntimeError::DimensionMismatch {
815 reason: format!(
816 "DeviationRuntime installed flex block: anchor_correction rows={}, expected sum-of-component-ncols={}",
817 installed.anchor_correction.nrows(),
818 d_expected,
819 ),
820 }
821 .into());
822 }
823 if installed.anchor_correction.ncols() != new_dim {
824 return Err(DeviationRuntimeError::DimensionMismatch {
825 reason: format!(
826 "DeviationRuntime installed flex block: anchor_correction cols={}, expected new basis dim {}",
827 installed.anchor_correction.ncols(),
828 new_dim,
829 ),
830 }
831 .into());
832 }
833 }
834 self.span_c0 = fast_ab(&self.span_c0, right_selector);
835 self.span_c1 = fast_ab(&self.span_c1, right_selector);
836 self.span_c2 = fast_ab(&self.span_c2, right_selector);
837 self.span_c3 = fast_ab(&self.span_c3, right_selector);
838 self.right_boundary_value_row = self.right_boundary_value_row.dot(right_selector);
841 self.monotonicity_constraint_rows =
846 fast_ab(&self.monotonicity_constraint_rows, right_selector);
847 self.basis_dim = new_dim;
848 self.installed_flex_block = installed_flex_block;
849 Ok(())
850 }
851
852 pub fn installed_flex_block(&self) -> Option<&InstalledFlexBlock> {
857 self.installed_flex_block.as_ref()
858 }
859
860 pub(crate) fn install_compiled_flex_block(
873 &mut self,
874 compiled: &gam_identifiability::families::compiler::CompiledBlock,
875 anchor_components: Vec<AnchorComponentTag>,
876 n_train_at_training: Array2<f64>,
877 ) -> Result<(), String> {
878 let m = compiled.anchor_correction.as_ref().ok_or_else(|| {
879 "DeviationRuntime::install_compiled_flex_block: compiled block has no \
880 anchor_correction — install requires a non-empty anchor union"
881 .to_string()
882 })?;
883 let installed = InstalledFlexBlock {
884 anchor_correction: m.clone(),
885 anchor_components,
886 };
887 self.anchor_rows_at_training = Some(n_train_at_training);
888 self.compose_anchor_orthogonalisation(&compiled.t_lw, Some(installed))
889 }
890
891 pub fn anchor_rows_at_training(&self) -> Option<&Array2<f64>> {
898 self.anchor_rows_at_training.as_ref()
899 }
900
901 pub fn design_with_anchor_rows(
907 &self,
908 values: &Array1<f64>,
909 anchor_rows: ArrayView2<f64>,
910 ) -> Result<Array2<f64>, String> {
911 let mut out = self.evaluate_span_polynomial_design_raw(values, 0)?;
912 if let Some(installed) = &self.installed_flex_block {
913 if anchor_rows.nrows() != values.len() {
914 return Err(DeviationRuntimeError::DimensionMismatch {
915 reason: format!(
916 "design_with_anchor_rows: anchor_rows has {} rows, expected {} (matching values)",
917 anchor_rows.nrows(),
918 values.len(),
919 ),
920 }
921 .into());
922 }
923 if anchor_rows.ncols() != installed.anchor_correction.nrows() {
924 return Err(DeviationRuntimeError::DimensionMismatch {
925 reason: format!(
926 "design_with_anchor_rows: anchor_rows has {} cols, expected {} (sum of component ncols)",
927 anchor_rows.ncols(),
928 installed.anchor_correction.nrows(),
929 ),
930 }
931 .into());
932 }
933 let subtract = anchor_rows.dot(&installed.anchor_correction);
934 out = out - subtract;
935 } else if anchor_rows.ncols() != 0 {
936 return Err(DeviationRuntimeError::DimensionMismatch {
939 reason: format!(
940 "design_with_anchor_rows: runtime has no installed flex block but anchor_rows has {} cols",
941 anchor_rows.ncols(),
942 ),
943 }
944 .into());
945 }
946 Ok(out)
947 }
948
949 pub(crate) fn design_at_training_with_residual(
952 &self,
953 values: &Array1<f64>,
954 ) -> Result<Array2<f64>, String> {
955 if let Some(rows) = self.anchor_rows_at_training.as_ref() {
956 self.design_with_anchor_rows(values, rows.view())
957 } else if self.installed_flex_block.is_some() {
958 Err(
959 "design_at_training_with_residual: runtime has installed_flex_block but no cached training anchor rows"
960 .to_string(),
961 )
962 } else {
963 self.design(values)
964 }
965 }
966
967 pub fn degree(&self) -> usize {
970 self.degree
971 }
972
973 pub fn value_span_degree(&self) -> usize {
974 self.value_span_degree
975 }
976
977 pub fn basis_dim(&self) -> usize {
978 self.basis_dim
979 }
980
981 pub fn monotonicity_eps(&self) -> f64 {
982 self.monotonicity_eps
983 }
984
985 pub fn span_c0(&self) -> &Array2<f64> {
986 &self.span_c0
987 }
988
989 pub fn span_c1(&self) -> &Array2<f64> {
990 &self.span_c1
991 }
992
993 pub fn span_c2(&self) -> &Array2<f64> {
994 &self.span_c2
995 }
996
997 pub fn span_c3(&self) -> &Array2<f64> {
998 &self.span_c3
999 }
1000
1001 pub(super) fn validate_beta_shape(
1004 &self,
1005 beta: ArrayView1<'_, f64>,
1006 label: &str,
1007 ) -> Result<(), String> {
1008 if beta.len() != self.basis_dim {
1009 return Err(DeviationRuntimeError::DimensionMismatch {
1010 reason: format!(
1011 "{label} length mismatch: got {}, expected {}",
1012 beta.len(),
1013 self.basis_dim
1014 ),
1015 }
1016 .into());
1017 }
1018 Ok::<(), _>(())
1019 }
1020
1021 pub(super) fn evaluate_span_polynomial_design_raw(
1026 &self,
1027 values: &Array1<f64>,
1028 derivative_order: usize,
1029 ) -> Result<Array2<f64>, String> {
1030 let (left_ep, right_ep) = self.support_interval()?;
1031 let mut out = Array2::<f64>::zeros((values.len(), self.basis_dim));
1032 for (row_idx, &value) in values.iter().enumerate() {
1033 if !value.is_finite() {
1034 return Err(DeviationRuntimeError::InvalidInput {
1035 reason: format!(
1036 "deviation runtime design value at row {row_idx} is non-finite ({value})"
1037 ),
1038 }
1039 .into());
1040 }
1041 if value < left_ep {
1042 if derivative_order == 0 {
1043 out.row_mut(row_idx).assign(&self.span_c0.row(0));
1044 }
1045 continue;
1046 }
1047 if value > right_ep {
1048 if derivative_order == 0 {
1049 out.row_mut(row_idx)
1050 .assign(&self.right_boundary_value_row.view());
1051 }
1052 continue;
1053 }
1054 let span_idx = self.left_biased_span_index_for(value)?;
1055 let left = self.endpoint_points[span_idx];
1056 let t = value - left;
1057 for basis_idx in 0..self.basis_dim {
1058 let c0 = self.span_c0[[span_idx, basis_idx]];
1059 let c1 = self.span_c1[[span_idx, basis_idx]];
1060 let c2 = self.span_c2[[span_idx, basis_idx]];
1061 let c3 = self.span_c3[[span_idx, basis_idx]];
1062 out[[row_idx, basis_idx]] = match derivative_order {
1063 0 => c0 + c1 * t + c2 * t * t + c3 * t * t * t,
1064 1 => c1 + 2.0 * c2 * t + 3.0 * c3 * t * t,
1065 2 => 2.0 * c2 + 6.0 * c3 * t,
1066 3 => 6.0 * c3,
1067 4 => 0.0,
1068 other => {
1069 return Err(DeviationRuntimeError::InvalidInput {
1070 reason: format!(
1071 "deviation runtime only supports derivative orders up to 4, got {other}"
1072 ),
1073 }
1074 .into());
1075 }
1076 };
1077 }
1078 }
1079 Ok(out)
1080 }
1081
1082 pub fn design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
1088 assert!(
1089 self.installed_flex_block.is_none(),
1090 "DeviationRuntime::design called on a runtime with an installed flex block; \
1091 use design_with_anchor_rows or design_at_training_with_residual instead"
1092 );
1093 self.evaluate_span_polynomial_design_raw(values, 0)
1094 }
1095
1096 pub fn first_derivative_design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
1097 self.evaluate_span_polynomial_design_raw(values, 1)
1098 }
1099
1100 pub fn second_derivative_design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
1101 self.evaluate_span_polynomial_design_raw(values, 2)
1102 }
1103
1104 pub fn third_derivative_design(&self, values: &Array1<f64>) -> Result<Array2<f64>, String> {
1105 self.evaluate_span_polynomial_design_raw(values, 3)
1106 }
1107
1108 pub(crate) fn integrated_derivative_penalty_with_nullity(
1109 &self,
1110 derivative_order: usize,
1111 ) -> Result<(Array2<f64>, usize), String> {
1112 if derivative_order > self.value_span_degree {
1113 return Err(DeviationRuntimeError::InvalidInput {
1114 reason: format!(
1115 "deviation penalty derivative order {derivative_order} exceeds value-basis degree {}",
1116 self.value_span_degree
1117 ),
1118 }
1119 .into());
1120 }
1121 let mut penalty = Array2::<f64>::zeros((self.basis_dim, self.basis_dim));
1122 for span_idx in 0..self.span_count() {
1123 let (left, right) = self.span_interval(span_idx)?;
1124 let width = right - left;
1125 if !width.is_finite() || width <= 0.0 {
1126 return Err(DeviationRuntimeError::InvalidInput {
1127 reason: format!("deviation penalty span {span_idx} has invalid width {width}"),
1128 }
1129 .into());
1130 }
1131 for i in 0..self.basis_dim {
1132 let ci =
1133 self.span_derivative_polynomial_coefficients(span_idx, i, derivative_order)?;
1134 for j in i..self.basis_dim {
1135 let cj = self.span_derivative_polynomial_coefficients(
1136 span_idx,
1137 j,
1138 derivative_order,
1139 )?;
1140 let contribution = integrate_polynomial_product(&ci, &cj, width);
1141 penalty[[i, j]] += contribution;
1142 if i != j {
1143 penalty[[j, i]] += contribution;
1144 }
1145 }
1146 }
1147 }
1148 let (evals, _) = penalty.eigh(faer::Side::Lower).map_err(|e| {
1149 String::from(DeviationRuntimeError::NumericalFailure {
1150 reason: format!("deviation integrated penalty eigendecomposition failed: {e}"),
1151 })
1152 })?;
1153 let threshold = gam_solve::estimate::reml::reml_outer_engine::positive_eigenvalue_threshold(
1154 evals.as_slice().ok_or_else(|| {
1155 String::from(DeviationRuntimeError::NumericalFailure {
1156 reason: "deviation penalty eigenvalues are not contiguous".to_string(),
1157 })
1158 })?,
1159 );
1160 let rank = evals.iter().filter(|&&value| value > threshold).count();
1161 let nullity = self.basis_dim.saturating_sub(rank);
1162 Ok((penalty, nullity))
1163 }
1164
1165 pub(crate) fn structural_monotonicity_constraints(&self) -> LinearInequalityConstraints {
1166 LinearInequalityConstraints {
1167 a: self.monotonicity_constraint_rows.clone(),
1168 b: Array1::from_elem(
1169 self.monotonicity_constraint_rows.nrows(),
1170 self.monotonicity_eps - 1.0,
1171 ),
1172 }
1173 }
1174
1175 pub(super) fn span_count(&self) -> usize {
1178 self.endpoint_points.len().saturating_sub(1)
1179 }
1180
1181 pub fn breakpoints(&self) -> &Array1<f64> {
1182 &self.endpoint_points
1183 }
1184
1185 pub(super) fn span_interval(&self, span_idx: usize) -> Result<(f64, f64), String> {
1186 if span_idx >= self.span_count() {
1187 return Err(DeviationRuntimeError::InvalidInput {
1188 reason: format!(
1189 "deviation span index {} out of range for {} spans",
1190 span_idx,
1191 self.span_count()
1192 ),
1193 }
1194 .into());
1195 }
1196 Ok((
1197 self.endpoint_points[span_idx],
1198 self.endpoint_points[span_idx + 1],
1199 ))
1200 }
1201
1202 pub(super) fn span_index_for(&self, value: f64) -> Result<usize, String> {
1203 span_index_for_breakpoints(
1204 self.endpoint_points.as_slice().ok_or_else(|| {
1205 String::from(DeviationRuntimeError::InvalidInput {
1206 reason: "deviation runtime breakpoints are not contiguous".to_string(),
1207 })
1208 })?,
1209 value,
1210 "deviation span lookup",
1211 )
1212 }
1213
1214 pub(super) fn left_biased_span_index_for(&self, value: f64) -> Result<usize, String> {
1215 let mut span_idx = self.span_index_for(value)?;
1216 if span_idx > 0 && value == self.endpoint_points[span_idx] {
1220 span_idx -= 1;
1221 }
1222 Ok(span_idx)
1223 }
1224
1225 pub(super) fn span_derivative_polynomial_coefficients(
1226 &self,
1227 span_idx: usize,
1228 basis_idx: usize,
1229 derivative_order: usize,
1230 ) -> Result<Vec<f64>, String> {
1231 if span_idx >= self.span_count() {
1232 return Err(DeviationRuntimeError::InvalidInput {
1233 reason: format!(
1234 "deviation span index {} out of range for {} spans",
1235 span_idx,
1236 self.span_count()
1237 ),
1238 }
1239 .into());
1240 }
1241 if basis_idx >= self.basis_dim {
1242 return Err(DeviationRuntimeError::InvalidInput {
1243 reason: format!(
1244 "deviation basis index {} out of range for {} coefficients",
1245 basis_idx, self.basis_dim
1246 ),
1247 }
1248 .into());
1249 }
1250 let c0 = self.span_c0[[span_idx, basis_idx]];
1251 let c1 = self.span_c1[[span_idx, basis_idx]];
1252 let c2 = self.span_c2[[span_idx, basis_idx]];
1253 let c3 = self.span_c3[[span_idx, basis_idx]];
1254 match derivative_order {
1255 0 => Ok(vec![c0, c1, c2, c3]),
1256 1 => Ok(vec![c1, 2.0 * c2, 3.0 * c3]),
1257 2 => Ok(vec![2.0 * c2, 6.0 * c3]),
1258 3 => Ok(vec![6.0 * c3]),
1259 other => Err(DeviationRuntimeError::InvalidInput {
1260 reason: format!(
1261 "deviation polynomial coefficients only support derivative orders up to 3, got {other}"
1262 ),
1263 }
1264 .into()),
1265 }
1266 }
1267
1268 pub(crate) fn local_cubic_on_span(
1271 &self,
1272 beta: ArrayView1<'_, f64>,
1273 span_idx: usize,
1274 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1275 self.validate_beta_shape(beta.view(), "deviation local cubic coefficients")?;
1276 let (left, right) = self.span_interval(span_idx)?;
1277 Ok(exact_kernel::LocalSpanCubic {
1278 left,
1279 right,
1280 c0: self.span_c0.row(span_idx).dot(&beta),
1281 c1: self.span_c1.row(span_idx).dot(&beta),
1282 c2: self.span_c2.row(span_idx).dot(&beta),
1283 c3: self.span_c3.row(span_idx).dot(&beta),
1284 })
1285 }
1286
1287 pub fn basis_span_cubic(
1288 &self,
1289 span_idx: usize,
1290 basis_idx: usize,
1291 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1292 if basis_idx >= self.basis_dim {
1293 return Err(DeviationRuntimeError::InvalidInput {
1294 reason: format!(
1295 "deviation basis index {} out of range for {} coefficients",
1296 basis_idx, self.basis_dim
1297 ),
1298 }
1299 .into());
1300 }
1301 let (left, right) = self.span_interval(span_idx)?;
1302 Ok(exact_kernel::LocalSpanCubic {
1303 left,
1304 right,
1305 c0: self.span_c0[[span_idx, basis_idx]],
1306 c1: self.span_c1[[span_idx, basis_idx]],
1307 c2: self.span_c2[[span_idx, basis_idx]],
1308 c3: self.span_c3[[span_idx, basis_idx]],
1309 })
1310 }
1311
1312 pub fn basis_cubic_at(
1317 &self,
1318 basis_idx: usize,
1319 value: f64,
1320 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1321 if basis_idx >= self.basis_dim {
1322 return Err(DeviationRuntimeError::InvalidInput {
1323 reason: format!(
1324 "deviation basis index {} out of range for {} coefficients",
1325 basis_idx, self.basis_dim
1326 ),
1327 }
1328 .into());
1329 }
1330 let (left_ep, right_ep) = self.support_interval()?;
1331 if value < left_ep {
1332 return Ok(exact_kernel::LocalSpanCubic {
1333 left: left_ep,
1334 right: left_ep + 1.0,
1335 c0: self.span_c0[[0, basis_idx]],
1336 c1: 0.0,
1337 c2: 0.0,
1338 c3: 0.0,
1339 });
1340 }
1341 if value > right_ep {
1342 return Ok(exact_kernel::LocalSpanCubic {
1343 left: right_ep,
1344 right: right_ep + 1.0,
1345 c0: self.right_boundary_value_row[basis_idx],
1346 c1: 0.0,
1347 c2: 0.0,
1348 c3: 0.0,
1349 });
1350 }
1351 let span_idx = self.left_biased_span_index_for(value)?;
1352 self.basis_span_cubic(span_idx, basis_idx)
1353 }
1354
1355 pub fn for_each_basis_cubic_at<F>(&self, value: f64, mut visit: F) -> Result<(), String>
1356 where
1357 F: FnMut(usize, exact_kernel::LocalSpanCubic) -> Result<(), String>,
1358 {
1359 let (left_ep, right_ep) = self.support_interval()?;
1360 if value < left_ep {
1361 for basis_idx in 0..self.basis_dim {
1362 visit(
1363 basis_idx,
1364 exact_kernel::LocalSpanCubic {
1365 left: left_ep,
1366 right: left_ep + 1.0,
1367 c0: self.span_c0[[0, basis_idx]],
1368 c1: 0.0,
1369 c2: 0.0,
1370 c3: 0.0,
1371 },
1372 )?;
1373 }
1374 return Ok(());
1375 }
1376 if value > right_ep {
1377 for basis_idx in 0..self.basis_dim {
1378 visit(
1379 basis_idx,
1380 exact_kernel::LocalSpanCubic {
1381 left: right_ep,
1382 right: right_ep + 1.0,
1383 c0: self.right_boundary_value_row[basis_idx],
1384 c1: 0.0,
1385 c2: 0.0,
1386 c3: 0.0,
1387 },
1388 )?;
1389 }
1390 return Ok(());
1391 }
1392
1393 let span_idx = self.left_biased_span_index_for(value)?;
1394 let (left, right) = self.span_interval(span_idx)?;
1395 for basis_idx in 0..self.basis_dim {
1396 visit(
1397 basis_idx,
1398 exact_kernel::LocalSpanCubic {
1399 left,
1400 right,
1401 c0: self.span_c0[[span_idx, basis_idx]],
1402 c1: self.span_c1[[span_idx, basis_idx]],
1403 c2: self.span_c2[[span_idx, basis_idx]],
1404 c3: self.span_c3[[span_idx, basis_idx]],
1405 },
1406 )?;
1407 }
1408 Ok(())
1409 }
1410
1411 pub(crate) fn local_cubic_at(
1416 &self,
1417 beta: ArrayView1<'_, f64>,
1418 value: f64,
1419 ) -> Result<exact_kernel::LocalSpanCubic, String> {
1420 self.validate_beta_shape(beta.view(), "deviation local cubic")?;
1421 let (left_ep, right_ep) = self.support_interval()?;
1422 if value < left_ep {
1423 return Ok(exact_kernel::LocalSpanCubic {
1424 left: left_ep,
1425 right: left_ep + 1.0,
1426 c0: self.left_tail_value(beta.view()),
1427 c1: 0.0,
1428 c2: 0.0,
1429 c3: 0.0,
1430 });
1431 }
1432 if value > right_ep {
1433 return Ok(exact_kernel::LocalSpanCubic {
1434 left: right_ep,
1435 right: right_ep + 1.0,
1436 c0: self.right_tail_value(beta.view()),
1437 c1: 0.0,
1438 c2: 0.0,
1439 c3: 0.0,
1440 });
1441 }
1442 let span_idx = self.left_biased_span_index_for(value)?;
1443 self.local_cubic_on_span(beta, span_idx)
1444 }
1445
1446 pub(super) fn left_tail_value(&self, beta: ArrayView1<'_, f64>) -> f64 {
1451 self.span_c0.row(0).dot(&beta)
1452 }
1453
1454 pub(super) fn right_tail_value(&self, beta: ArrayView1<'_, f64>) -> f64 {
1457 self.right_boundary_value_row.dot(&beta)
1458 }
1459
1460 pub(crate) fn value_basis_l1_sup_norm(&self) -> f64 {
1469 let mut total = 0.0;
1470 for basis_idx in 0..self.basis_dim {
1471 let mut col_sup = self.span_c0[[0, basis_idx]]
1472 .abs()
1473 .max(self.right_boundary_value_row[basis_idx].abs());
1474 for span_idx in 0..self.span_count() {
1475 let left = self.endpoint_points[span_idx];
1476 let right = self.endpoint_points[span_idx + 1];
1477 let width = right - left;
1478 if !width.is_finite() || width <= 0.0 {
1479 continue;
1480 }
1481 let c0 = self.span_c0[[span_idx, basis_idx]];
1482 let c1 = self.span_c1[[span_idx, basis_idx]];
1483 let c2 = self.span_c2[[span_idx, basis_idx]];
1484 let c3 = self.span_c3[[span_idx, basis_idx]];
1485 let eval_abs = |t: f64| (c0 + c1 * t + c2 * t * t + c3 * t * t * t).abs();
1486 col_sup = col_sup.max(eval_abs(0.0)).max(eval_abs(width));
1487 let a = 3.0 * c3;
1488 let b = 2.0 * c2;
1489 let c = c1;
1490 if a.abs() <= f64::EPSILON {
1491 if b.abs() > f64::EPSILON {
1492 let t = -c / b;
1493 if t > 0.0 && t < width {
1494 col_sup = col_sup.max(eval_abs(t));
1495 }
1496 }
1497 } else {
1498 let disc = b * b - 4.0 * a * c;
1499 if disc >= 0.0 {
1500 let sqrt_disc = disc.sqrt();
1501 for t in [(-b - sqrt_disc) / (2.0 * a), (-b + sqrt_disc) / (2.0 * a)] {
1502 if t > 0.0 && t < width {
1503 col_sup = col_sup.max(eval_abs(t));
1504 }
1505 }
1506 }
1507 }
1508 }
1509 total += col_sup;
1510 }
1511 total
1512 }
1513
1514 pub(super) fn support_interval(&self) -> Result<(f64, f64), String> {
1517 match (self.endpoint_points.first(), self.endpoint_points.last()) {
1518 (Some(&left), Some(&right)) => Ok((left, right)),
1519 _ => Err(DeviationRuntimeError::InvalidInput {
1520 reason: "deviation runtime is missing monotonicity support points".to_string(),
1521 }
1522 .into()),
1523 }
1524 }
1525
1526 pub(crate) fn exact_monotonicity_min_slack(&self, beta: &Array1<f64>) -> Result<f64, String> {
1527 if beta.len() != self.basis_dim {
1528 return Err(DeviationRuntimeError::DimensionMismatch {
1529 reason: format!(
1530 "deviation monotonicity length mismatch: got {}, expected {}",
1531 beta.len(),
1532 self.basis_dim
1533 ),
1534 }
1535 .into());
1536 }
1537 if beta.iter().any(|value| !value.is_finite()) {
1538 let bad = beta
1539 .iter()
1540 .enumerate()
1541 .find(|(_, value)| !value.is_finite())
1542 .map(|(idx, value)| format!("deviation coefficient {idx} is non-finite ({value})"))
1543 .unwrap_or_else(|| "deviation coefficient is non-finite".to_string());
1544 return Err(DeviationRuntimeError::InvalidInput { reason: bad }.into());
1545 }
1546
1547 let mut min_slack = f64::INFINITY;
1548 for span_idx in 0..self.span_count() {
1549 let left = self.endpoint_points[span_idx];
1550 let right = self.endpoint_points[span_idx + 1];
1551 let width = right - left;
1552 if !width.is_finite() || width <= 0.0 {
1553 continue;
1554 }
1555 let c1 = self.span_c1.row(span_idx).dot(beta);
1556 let c2 = self.span_c2.row(span_idx).dot(beta);
1557 let c3 = self.span_c3.row(span_idx).dot(beta);
1558 let d1_left = c1;
1559 let d1_right = c1 + 2.0 * c2 * width + 3.0 * c3 * width * width;
1560 let d2_left = 2.0 * c2;
1561 let d3 = 6.0 * c3;
1562 let left_slack = 1.0 + d1_left - self.monotonicity_eps;
1563 let right_slack = 1.0 + d1_right - self.monotonicity_eps;
1564 min_slack = min_slack.min(left_slack.min(right_slack));
1565
1566 if d3 > 0.0 {
1567 let t_star = -d2_left / d3;
1568 if t_star > 0.0 && t_star < width {
1569 let interior = 1.0 + d1_left + d2_left * t_star + 0.5 * d3 * t_star * t_star
1570 - self.monotonicity_eps;
1571 min_slack = min_slack.min(interior);
1572 }
1573 }
1574 }
1575 if min_slack.is_finite() {
1576 Ok(min_slack)
1577 } else {
1578 Err(DeviationRuntimeError::NumericalFailure {
1579 reason: "deviation monotonicity slack computation produced no active spans"
1580 .to_string(),
1581 }
1582 .into())
1583 }
1584 }
1585
1586 pub(crate) fn monotonicity_feasible(
1587 &self,
1588 beta: &Array1<f64>,
1589 context: &str,
1590 ) -> Result<(), String> {
1591 let slack = self.exact_monotonicity_min_slack(beta)?;
1592 if slack >= MONOTONICITY_SLACK_ROUNDOFF_TOL {
1593 Ok(())
1594 } else {
1595 let (left, right) = self.support_interval()?;
1596 Err(DeviationRuntimeError::NumericalFailure {
1597 reason: format!(
1598 "{context} violates exact monotonicity on [{left:.6}, {right:.6}] (minimum derivative slack {slack:.3e}, eps={:.3e})",
1599 self.monotonicity_eps
1600 ),
1601 }
1602 .into())
1603 }
1604 }
1605}