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