1use gam_linalg::LinalgError;
2use gam_linalg::faer_ndarray::FaerLinalgError;
3use serde::{Deserialize, Serialize};
4
5use crate::{BasisError, CustomFamilyError, MonotoneRootError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum FixedLambdaSolverStage {
15 BinomialMultiNewton,
16 MultinomialNewton,
17 MultinomialFirth,
18}
19
20impl core::fmt::Display for FixedLambdaSolverStage {
21 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
22 f.write_str(match self {
23 Self::BinomialMultiNewton => "binomial-multi Newton",
24 Self::MultinomialNewton => "multinomial Newton",
25 Self::MultinomialFirth => "multinomial Firth/Jeffreys Newton",
26 })
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum FixedLambdaStallReason {
33 IterationBudgetExhausted,
34 LineSearchExhausted,
35 StationarityCertificateFailed,
36}
37
38impl core::fmt::Display for FixedLambdaStallReason {
39 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
40 f.write_str(match self {
41 Self::IterationBudgetExhausted => "iteration budget exhausted",
42 Self::LineSearchExhausted => "line search exhausted without an accepted step",
43 Self::StationarityCertificateFailed => "stationarity certificate failed",
44 })
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum FixedLambdaResidualKind {
51 PenalizedGradientNorm,
53 NewtonDecrement,
55}
56
57impl core::fmt::Display for FixedLambdaResidualKind {
58 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
59 f.write_str(match self {
60 Self::PenalizedGradientNorm => "penalized gradient norm",
61 Self::NewtonDecrement => "Newton decrement",
62 })
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
68pub struct FixedLambdaStationarityEvidence {
69 pub kind: FixedLambdaResidualKind,
70 pub residual: f64,
71 pub bound: f64,
72}
73
74impl core::fmt::Display for FixedLambdaStationarityEvidence {
75 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
76 write!(
77 f,
78 "{} {:.6e} against bound {:.6e}",
79 self.kind, self.residual, self.bound
80 )
81 }
82}
83
84#[derive(Clone, PartialEq, Serialize, Deserialize)]
92pub struct FixedLambdaCheckpoint {
93 stage: FixedLambdaSolverStage,
94 coefficients_row_major: Vec<f64>,
95 rows: usize,
96 cols: usize,
97 completed_iterations: usize,
98}
99
100impl FixedLambdaCheckpoint {
101 pub fn new(
102 stage: FixedLambdaSolverStage,
103 coefficients_row_major: Vec<f64>,
104 rows: usize,
105 cols: usize,
106 completed_iterations: usize,
107 ) -> Result<Self, String> {
108 let checkpoint = Self {
109 stage,
110 coefficients_row_major,
111 rows,
112 cols,
113 completed_iterations,
114 };
115 checkpoint.validate()?;
116 Ok(checkpoint)
117 }
118
119 pub fn validate(&self) -> Result<(), String> {
122 if self.rows == 0 || self.cols == 0 {
123 return Err(format!(
124 "fixed-lambda checkpoint shape must be nonempty, got {}x{}",
125 self.rows, self.cols
126 ));
127 }
128 let expected = self.rows.checked_mul(self.cols).ok_or_else(|| {
129 format!(
130 "fixed-lambda checkpoint shape {}x{} overflows usize",
131 self.rows, self.cols
132 )
133 })?;
134 if self.coefficients_row_major.len() != expected {
135 return Err(format!(
136 "fixed-lambda checkpoint has {} coefficient values, expected {} for shape {}x{}",
137 self.coefficients_row_major.len(),
138 expected,
139 self.rows,
140 self.cols
141 ));
142 }
143 if let Some((index, _)) = self
144 .coefficients_row_major
145 .iter()
146 .copied()
147 .enumerate()
148 .find(|(_, value)| !value.is_finite())
149 {
150 return Err(format!(
151 "fixed-lambda checkpoint coefficient {index} must be finite"
152 ));
153 }
154 Ok(())
155 }
156
157 pub fn stage(&self) -> FixedLambdaSolverStage {
158 self.stage
159 }
160
161 pub fn values(&self) -> &[f64] {
162 &self.coefficients_row_major
163 }
164
165 pub fn rows(&self) -> usize {
166 self.rows
167 }
168
169 pub fn cols(&self) -> usize {
170 self.cols
171 }
172
173 pub fn completed_iterations(&self) -> usize {
174 self.completed_iterations
175 }
176}
177
178impl core::fmt::Display for FixedLambdaCheckpoint {
179 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
180 write!(
181 f,
182 "{} checkpoint {}x{} after {} iteration(s)",
183 self.stage, self.rows, self.cols, self.completed_iterations
184 )
185 }
186}
187
188impl core::fmt::Debug for FixedLambdaCheckpoint {
189 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
190 core::fmt::Display::fmt(self, f)
191 }
192}
193
194#[derive(thiserror::Error)]
196pub enum EstimationError {
197 #[error("Underlying basis function generation failed: {0}")]
198 BasisError(#[from] BasisError),
199
200 #[error("Custom-family fit failed: {0}")]
201 CustomFamily(#[from] CustomFamilyError),
202
203 #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
204 LinearSystemSolveFailed(FaerLinalgError),
205
206 #[error("Eigendecomposition failed: {0}")]
207 EigendecompositionFailed(FaerLinalgError),
208
209 #[error(
210 "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
211 )]
212 PenaltySpectrumNonFinite {
213 context: String,
214 index: usize,
215 value: f64,
216 },
217
218 #[error(
219 "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
220 )]
221 PenaltySpectrumIndefinite {
222 context: String,
223 index: usize,
224 value: f64,
225 tolerance: f64,
226 scale: f64,
227 },
228
229 #[error("Parameter constraint violation: {0}")]
230 ParameterConstraintViolation(String),
231
232 #[error(
233 "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
234 )]
235 PirlsDidNotConverge {
236 max_iterations: usize,
237 last_change: f64,
238 },
239
240 #[error(
241 "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
242 {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
243 only minted from a converged optimization; resume by passing the carried checkpoint \
244 through the fixed-lambda input's `resume_from` field ({checkpoint}).",
245 .checkpoint.completed_iterations()
246 )]
247 FixedLambdaNewtonDidNotConverge {
248 context: String,
251 reason: FixedLambdaStallReason,
253 objective_value: f64,
257 stationarity: FixedLambdaStationarityEvidence,
259 checkpoint: FixedLambdaCheckpoint,
263 },
264
265 #[error(
266 "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
267 max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
268 minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
269 {profile_curvature_roundoff:.3e}; last scale fixed-point step \
270 {last_scale_step:.6e}{}). \
271 A fit is only minted from a converged optimization; resume from the \
272 checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
273 if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
274 )]
275 BlockOrthogonalRemlDidNotConverge {
276 iterations: usize,
278 max_score_residual: f64,
281 score_tol: f64,
283 min_profile_curvature: f64,
286 profile_curvature_roundoff: f64,
289 last_scale_step: f64,
292 cycle_detected: bool,
295 rho_checkpoint: Vec<f64>,
298 },
299
300 #[error(
301 "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
302 {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
303 {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
304 against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
305 partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
306 and rho={rho_checkpoint:?}."
307 )]
308 NegativeBinomialAlternationDidNotConverge {
309 rounds: usize,
311 theta_checkpoint: f64,
313 rho_projected_grad_norm: f64,
315 rho_stationarity_bound: f64,
317 theta_score_residual: f64,
319 theta_stationarity_bound: f64,
321 rho_checkpoint: Vec<f64>,
323 },
324
325 #[error(
326 "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
327 The model cannot converge because a predictor perfectly separates the binary outcomes. \
328 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
329 )]
330 PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
331
332 #[error(
333 "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
334 has a threshold {threshold:.6e} that separates the binary outcomes \
335 (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
336 enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
337 )]
338 PrefitPerfectSeparationDetected {
339 column_index: usize,
340 threshold: f64,
341 positive_above_threshold: bool,
342 },
343
344 #[error(
345 "Pre-fit linear separation detected in the realized binomial inverse-link design: \
346 {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
347 with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
348 The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
349 remove/reparameterize/penalize the separating columns."
350 )]
351 PrefitLinearSeparationDetected {
352 min_signed_margin: f64,
353 num_unpenalized_columns: usize,
354 column_indices: Vec<usize>,
355 },
356
357 #[error(
358 "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
359 unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
360 Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
361 )]
362 PrefitRankDeficientDesignDetected {
363 rank: usize,
364 num_unpenalized_columns: usize,
365 min_eigenvalue: f64,
366 tolerance: f64,
367 column_indices: Vec<usize>,
368 },
369
370 #[error(
371 "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
372 unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
373 exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
374 columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
375 so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
376 penalty/constraint before fitting."
377 )]
378 PrefitNearDegenerateDesignDetected {
379 num_unpenalized_columns: usize,
380 condition_number: f64,
381 min_eigenvalue: f64,
382 max_eigenvalue: f64,
383 tolerance: f64,
384 column_indices: Vec<usize>,
385 },
386
387 #[error(
388 "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
389 The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
390 so the unpenalized softmax MLE is not finite in that direction. \
391 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
392 )]
393 MultinomialSeparationDetected {
394 iteration: usize,
395 max_abs_eta: f64,
396 active_class_index: usize,
397 row_index: usize,
398 },
399
400 #[error(
401 "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). This indicates a numerical instability."
402 )]
403 HessianNotPositiveDefinite { min_eigenvalue: f64 },
404
405 #[error("REML smoothing optimization failed to converge: {0}")]
406 RemlOptimizationFailed(String),
407
408 #[error(
409 "Outer smoothing-parameter optimization did not certify a stationary optimum \
410 ({context}): {reason} after {iterations} outer iteration(s); final objective \
411 {final_value:.6e}, projected gradient norm {} against stationarity bound \
412 {stationarity_bound:.3e}. A fit is only minted from a converged optimization; \
413 the best iterate is carried as a checkpoint — resume by seeding the outer \
414 search at rho_checkpoint = {rho_checkpoint:?}.",
415 .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}"))
416 )]
417 RemlDidNotConverge {
418 context: String,
420 reason: String,
424 iterations: usize,
426 final_value: f64,
428 projected_grad_norm: Option<f64>,
431 stationarity_bound: f64,
434 rho_checkpoint: Vec<f64>,
438 },
439
440 #[error(
441 "Fit assembly rejected a non-converged optimization state: inner status \
442 {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
443 iteration(s); final objective {final_value:.6e}; stationarity residual \
444 {stationarity_residual:?} against {stationarity_bound:?}, step residual \
445 {step_residual:?} against {step_bound:?}. The best rho checkpoint is \
446 {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
447 API was constructed."
448 )]
449 FitDidNotConverge {
450 inner_status: String,
454 outer_status: String,
456 outer_iterations: usize,
458 final_value: f64,
460 stationarity_residual: Option<f64>,
463 stationarity_bound: Option<f64>,
465 step_residual: Option<f64>,
467 step_bound: Option<f64>,
469 rho_checkpoint: Vec<f64>,
471 resume_token: Option<String>,
474 },
475
476 #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
477 GradientUnavailable {
478 context: &'static str,
479 mode: &'static str,
480 },
481
482 #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
483 LayoutError(String),
484
485 #[error(
486 "Model is over-parameterized: {num_coeffs} coefficients for {num_samples} samples.\n\n\
487 Coefficient Breakdown:\n\
488 - Intercept: {intercept_coeffs}\n\
489 - Binary Main Effects: {binary_main_coeffs}\n\
490 - Primary Smooth Effects: {primary_smooth_coeffs}\n\
491 - Binary×Primary Interactions: {binary_primary_interaction_coeffs}\n\
492 - Auxiliary Main Effects: {aux_main_coeffs}\n\
493 - Auxiliary Interactions: {aux_interaction_coeffs}"
494 )]
495 ModelOverparameterized {
496 num_coeffs: usize,
497 num_samples: usize,
498 intercept_coeffs: usize,
499 binary_main_coeffs: usize,
500 primary_smooth_coeffs: usize,
501 aux_main_coeffs: usize,
502 binary_primary_interaction_coeffs: usize,
503 aux_interaction_coeffs: usize,
504 },
505
506 #[error(
507 "Model is ill-conditioned with condition number {condition_number:.2e}. This typically occurs when the model is over-parameterized (too many knots relative to data points). Consider reducing the number of knots or increasing regularization."
508 )]
509 ModelIsIllConditioned { condition_number: f64 },
510
511 #[error("Invalid input: {0}")]
512 InvalidInput(String),
513
514 #[error("monotone root solve: {0}")]
515 MonotoneRoot(#[from] MonotoneRootError),
516
517 #[error("Calibrator training failed: {0}")]
518 CalibratorTrainingFailed(String),
519
520 #[error("Invalid specification: {0}")]
521 InvalidSpecification(String),
522
523 #[error("Prediction error")]
524 PredictionError,
525}
526
527impl core::fmt::Debug for EstimationError {
529 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
530 write!(f, "{}", self)
531 }
532}
533
534impl EstimationError {
535 pub fn is_inner_solve_retreat(&self) -> bool {
546 matches!(
547 self,
548 EstimationError::ModelIsIllConditioned { .. }
549 | EstimationError::PerfectSeparationDetected { .. }
550 | EstimationError::MultinomialSeparationDetected { .. }
551 | EstimationError::PirlsDidNotConverge { .. }
552 | EstimationError::FixedLambdaNewtonDidNotConverge { .. }
553 )
554 }
555}
556
557impl From<LinalgError> for EstimationError {
558 fn from(error: LinalgError) -> Self {
559 match error {
560 LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
561 LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
562 EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
563 }
564 LinalgError::ModelIsIllConditioned { condition_number } => {
565 EstimationError::ModelIsIllConditioned { condition_number }
566 }
567 }
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
578 fn model_ill_conditioned_is_retreat() {
579 assert!(
580 EstimationError::ModelIsIllConditioned {
581 condition_number: 1e15
582 }
583 .is_inner_solve_retreat()
584 );
585 }
586
587 #[test]
588 fn perfect_separation_is_retreat() {
589 assert!(
590 EstimationError::PerfectSeparationDetected {
591 iteration: 3,
592 max_abs_eta: 50.0
593 }
594 .is_inner_solve_retreat()
595 );
596 }
597
598 #[test]
599 fn multinomial_separation_is_retreat() {
600 assert!(
601 EstimationError::MultinomialSeparationDetected {
602 iteration: 1,
603 max_abs_eta: 100.0,
604 active_class_index: 2,
605 row_index: 7
606 }
607 .is_inner_solve_retreat()
608 );
609 }
610
611 #[test]
612 fn pirls_did_not_converge_is_retreat() {
613 assert!(
614 EstimationError::PirlsDidNotConverge {
615 max_iterations: 100,
616 last_change: 1e-3
617 }
618 .is_inner_solve_retreat()
619 );
620 }
621
622 #[test]
623 fn invalid_input_is_not_retreat() {
624 assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
625 }
626
627 #[test]
628 fn reml_optimization_failed_is_not_retreat() {
629 assert!(
630 !EstimationError::RemlOptimizationFailed("outer fail".to_string())
631 .is_inner_solve_retreat()
632 );
633 }
634
635 #[test]
638 fn invalid_input_message_appears_in_display() {
639 let err = EstimationError::InvalidInput("test_message".to_string());
640 assert!(err.to_string().contains("test_message"));
641 }
642
643 #[test]
644 fn pirls_did_not_converge_mentions_max_iterations() {
645 let err = EstimationError::PirlsDidNotConverge {
646 max_iterations: 42,
647 last_change: 0.001,
648 };
649 assert!(err.to_string().contains("42"));
650 }
651
652 #[test]
653 fn fixed_lambda_checkpoint_validates_shape_and_values() {
654 let checkpoint = FixedLambdaCheckpoint::new(
655 FixedLambdaSolverStage::MultinomialNewton,
656 vec![1.0, 2.0, 3.0, 4.0],
657 2,
658 2,
659 7,
660 )
661 .expect("well-shaped finite checkpoint");
662 assert_eq!(
663 checkpoint.stage(),
664 FixedLambdaSolverStage::MultinomialNewton
665 );
666 assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
667 assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
668 assert_eq!(checkpoint.completed_iterations(), 7);
669
670 assert!(
671 FixedLambdaCheckpoint::new(
672 FixedLambdaSolverStage::BinomialMultiNewton,
673 vec![1.0],
674 2,
675 1,
676 0,
677 )
678 .is_err(),
679 "coefficient length must match rows * cols"
680 );
681 assert!(
682 FixedLambdaCheckpoint::new(
683 FixedLambdaSolverStage::BinomialMultiNewton,
684 vec![f64::NAN],
685 1,
686 1,
687 0,
688 )
689 .is_err(),
690 "checkpoint coefficients must be finite"
691 );
692 assert!(
693 FixedLambdaCheckpoint::new(
694 FixedLambdaSolverStage::MultinomialFirth,
695 Vec::new(),
696 usize::MAX,
697 2,
698 0,
699 )
700 .is_err(),
701 "checkpoint shape multiplication must not overflow"
702 );
703 }
704
705 #[test]
706 fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
707 let checkpoint = FixedLambdaCheckpoint::new(
708 FixedLambdaSolverStage::MultinomialFirth,
709 vec![12_345.678_9, -98_765.432_1],
710 2,
711 1,
712 11,
713 )
714 .expect("valid checkpoint");
715 let checkpoint_debug = format!("{checkpoint:?}");
716 assert!(!checkpoint_debug.contains("12345.6789"));
717 assert!(!checkpoint_debug.contains("98765.4321"));
718 let err = EstimationError::FixedLambdaNewtonDidNotConverge {
719 context: "test Firth solve".to_string(),
720 reason: FixedLambdaStallReason::LineSearchExhausted,
721 objective_value: 3.25,
722 stationarity: FixedLambdaStationarityEvidence {
723 kind: FixedLambdaResidualKind::NewtonDecrement,
724 residual: 0.125,
725 bound: 1.0e-7,
726 },
727 checkpoint,
728 };
729
730 let display = err.to_string();
731 assert!(display.contains("test Firth solve"));
732 assert!(display.contains("line search exhausted"));
733 assert!(display.contains("Newton decrement"));
734 assert!(display.contains("2x1"));
735 assert!(display.contains("11 iteration"));
736 assert!(!display.contains("12345.6789"));
737 assert!(!display.contains("98765.4321"));
738 assert_eq!(
739 format!("{err:?}"),
740 display,
741 "Debug delegates to safe Display"
742 );
743 assert!(err.is_inner_solve_retreat());
744 }
745
746 #[test]
749 fn from_linalg_invalid_input_maps_to_invalid_input() {
750 let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
751 let err = EstimationError::from(linalg_err);
752 assert!(matches!(err, EstimationError::InvalidInput(_)));
753 assert!(err.to_string().contains("linalg msg"));
754 }
755
756 #[test]
757 fn from_linalg_hessian_not_spd_maps_correctly() {
758 let linalg_err = LinalgError::HessianNotPositiveDefinite {
759 min_eigenvalue: -1.0,
760 };
761 let err = EstimationError::from(linalg_err);
762 assert!(matches!(
763 err,
764 EstimationError::HessianNotPositiveDefinite { .. }
765 ));
766 }
767}