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)]
23pub struct StationarityRung {
24 pub label: &'static str,
26 pub derived_standard: bool,
29}
30
31impl StationarityRung {
32 pub const EMPTY_ESTIMAND: Self = Self {
40 label: "empty-estimand",
41 derived_standard: false,
42 };
43}
44
45impl std::fmt::Display for StationarityRung {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(
48 f,
49 "rung={} derived_standard={}",
50 self.label, self.derived_standard
51 )
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78#[serde(bound(deserialize = "'de: 'static"))]
83pub enum StationarityStandard {
84 Measured {
87 bound: f64,
88 rung: StationarityRung,
89 },
90 NoComparison,
96}
97
98impl StationarityStandard {
99 pub fn bound(&self) -> Option<f64> {
101 match self {
102 Self::Measured { bound, .. } => Some(*bound),
103 Self::NoComparison => None,
104 }
105 }
106
107 pub fn rung(&self) -> Option<StationarityRung> {
109 match self {
110 Self::Measured { rung, .. } => Some(*rung),
111 Self::NoComparison => None,
112 }
113 }
114}
115
116impl std::fmt::Display for StationarityStandard {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::Measured { bound, rung } => {
120 write!(f, "against stationarity bound {bound:.3e} ({rung})")
121 }
122 Self::NoComparison => f.write_str(
123 "against no stationarity bound: this refusal was decided by the reason \
124 above, not by a stationarity comparison",
125 ),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
146pub enum FitStationarityEvidence {
147 Certified { residual: f64, bound: f64 },
150 NoComparison,
153}
154
155impl std::fmt::Display for FitStationarityEvidence {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 match self {
158 Self::Certified { residual, bound } => {
159 write!(f, "residual {residual:.3e} against bound {bound:.3e}")
160 }
161 Self::NoComparison => f.write_str("not compared: no certificate was assembled"),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173pub enum FixedLambdaSolverStage {
174 BinomialMultiNewton,
175 MultinomialNewton,
176 MultinomialFirth,
177}
178
179impl core::fmt::Display for FixedLambdaSolverStage {
180 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
181 f.write_str(match self {
182 Self::BinomialMultiNewton => "binomial-multi Newton",
183 Self::MultinomialNewton => "multinomial Newton",
184 Self::MultinomialFirth => "multinomial Firth/Jeffreys Newton",
185 })
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub enum FixedLambdaStallReason {
192 IterationBudgetExhausted,
193 LineSearchExhausted,
194 StationarityCertificateFailed,
195}
196
197impl core::fmt::Display for FixedLambdaStallReason {
198 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
199 f.write_str(match self {
200 Self::IterationBudgetExhausted => "iteration budget exhausted",
201 Self::LineSearchExhausted => "line search exhausted without an accepted step",
202 Self::StationarityCertificateFailed => "stationarity certificate failed",
203 })
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209pub enum FixedLambdaResidualKind {
210 PenalizedGradientNorm,
212 NewtonDecrement,
214}
215
216impl core::fmt::Display for FixedLambdaResidualKind {
217 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
218 f.write_str(match self {
219 Self::PenalizedGradientNorm => "penalized gradient norm",
220 Self::NewtonDecrement => "Newton decrement",
221 })
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
227pub struct FixedLambdaStationarityEvidence {
228 pub kind: FixedLambdaResidualKind,
229 pub residual: f64,
230 pub bound: f64,
231}
232
233impl core::fmt::Display for FixedLambdaStationarityEvidence {
234 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
235 write!(
236 f,
237 "{} {:.6e} against bound {:.6e}",
238 self.kind, self.residual, self.bound
239 )
240 }
241}
242
243#[derive(Clone, PartialEq, Serialize, Deserialize)]
251pub struct FixedLambdaCheckpoint {
252 stage: FixedLambdaSolverStage,
253 coefficients_row_major: Vec<f64>,
254 rows: usize,
255 cols: usize,
256 completed_iterations: usize,
257}
258
259impl FixedLambdaCheckpoint {
260 pub fn new(
261 stage: FixedLambdaSolverStage,
262 coefficients_row_major: Vec<f64>,
263 rows: usize,
264 cols: usize,
265 completed_iterations: usize,
266 ) -> Result<Self, String> {
267 let checkpoint = Self {
268 stage,
269 coefficients_row_major,
270 rows,
271 cols,
272 completed_iterations,
273 };
274 checkpoint.validate()?;
275 Ok(checkpoint)
276 }
277
278 pub fn validate(&self) -> Result<(), String> {
281 if self.rows == 0 || self.cols == 0 {
282 return Err(format!(
283 "fixed-lambda checkpoint shape must be nonempty, got {}x{}",
284 self.rows, self.cols
285 ));
286 }
287 let expected = self.rows.checked_mul(self.cols).ok_or_else(|| {
288 format!(
289 "fixed-lambda checkpoint shape {}x{} overflows usize",
290 self.rows, self.cols
291 )
292 })?;
293 if self.coefficients_row_major.len() != expected {
294 return Err(format!(
295 "fixed-lambda checkpoint has {} coefficient values, expected {} for shape {}x{}",
296 self.coefficients_row_major.len(),
297 expected,
298 self.rows,
299 self.cols
300 ));
301 }
302 if let Some((index, _)) = self
303 .coefficients_row_major
304 .iter()
305 .copied()
306 .enumerate()
307 .find(|(_, value)| !value.is_finite())
308 {
309 return Err(format!(
310 "fixed-lambda checkpoint coefficient {index} must be finite"
311 ));
312 }
313 Ok(())
314 }
315
316 pub fn stage(&self) -> FixedLambdaSolverStage {
317 self.stage
318 }
319
320 pub fn values(&self) -> &[f64] {
321 &self.coefficients_row_major
322 }
323
324 pub fn rows(&self) -> usize {
325 self.rows
326 }
327
328 pub fn cols(&self) -> usize {
329 self.cols
330 }
331
332 pub fn completed_iterations(&self) -> usize {
333 self.completed_iterations
334 }
335}
336
337impl core::fmt::Display for FixedLambdaCheckpoint {
338 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
339 write!(
340 f,
341 "{} checkpoint {}x{} after {} iteration(s)",
342 self.stage, self.rows, self.cols, self.completed_iterations
343 )
344 }
345}
346
347impl core::fmt::Debug for FixedLambdaCheckpoint {
348 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
349 core::fmt::Display::fmt(self, f)
350 }
351}
352
353#[derive(Debug, thiserror::Error)]
366pub enum OuterObjectiveErrorSource {
367 #[error(transparent)]
368 Estimation(Box<EstimationError>),
369 #[error(transparent)]
370 Objective(opt::ObjectiveEvalError),
371}
372
373impl OuterObjectiveErrorSource {
374 #[must_use]
381 pub fn estimation_error(&self) -> Option<&EstimationError> {
382 match self {
383 Self::Estimation(source) => Some(source),
384 Self::Objective(source) => source.downcast_ref::<EstimationError>(),
385 }
386 }
387
388}
389
390#[derive(thiserror::Error)]
392pub enum EstimationError {
393 #[error(transparent)]
394 InvalidStabilization(#[from] crate::InvalidStabilization),
395
396 #[error("Underlying basis function generation failed: {0}")]
397 BasisError(#[from] BasisError),
398
399 #[error("Custom-family fit failed: {0}")]
400 CustomFamily(#[from] CustomFamilyError),
401
402 #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
403 LinearSystemSolveFailed(FaerLinalgError),
404
405 #[error("Eigendecomposition failed: {0}")]
406 EigendecompositionFailed(FaerLinalgError),
407
408 #[error(
409 "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
410 )]
411 PenaltySpectrumNonFinite {
412 context: String,
413 index: usize,
414 value: f64,
415 },
416
417 #[error(
418 "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
419 )]
420 PenaltySpectrumIndefinite {
421 context: String,
422 index: usize,
423 value: f64,
424 tolerance: f64,
425 scale: f64,
426 },
427
428 #[error("Parameter constraint violation: {0}")]
429 ParameterConstraintViolation(String),
430
431 #[error(
432 "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
433 )]
434 PirlsDidNotConverge {
435 max_iterations: usize,
436 last_change: f64,
437 },
438
439 #[error(
440 "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
441 {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
442 only minted from a converged optimization; resume by passing the carried checkpoint \
443 through the fixed-lambda input's `resume_from` field ({checkpoint}).",
444 .checkpoint.completed_iterations()
445 )]
446 FixedLambdaNewtonDidNotConverge {
447 context: String,
450 reason: FixedLambdaStallReason,
452 objective_value: f64,
456 stationarity: FixedLambdaStationarityEvidence,
458 checkpoint: FixedLambdaCheckpoint,
462 },
463
464 #[error(
465 "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
466 max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
467 minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
468 {profile_curvature_roundoff:.3e}; last scale fixed-point step \
469 {last_scale_step:.6e}{}). \
470 A fit is only minted from a converged optimization; resume from the \
471 checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
472 if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
473 )]
474 BlockOrthogonalRemlDidNotConverge {
475 iterations: usize,
477 max_score_residual: f64,
480 score_tol: f64,
482 min_profile_curvature: f64,
485 profile_curvature_roundoff: f64,
488 last_scale_step: f64,
491 cycle_detected: bool,
494 rho_checkpoint: Vec<f64>,
497 },
498
499 #[error(
500 "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
501 {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
502 {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
503 against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
504 partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
505 and rho={rho_checkpoint:?}."
506 )]
507 NegativeBinomialAlternationDidNotConverge {
508 rounds: usize,
510 theta_checkpoint: f64,
512 rho_projected_grad_norm: f64,
514 rho_stationarity_bound: f64,
516 theta_score_residual: f64,
518 theta_stationarity_bound: f64,
520 rho_checkpoint: Vec<f64>,
522 },
523
524 #[error(
525 "Beta precision refinement did not converge: after {passes} alternation pass(es) at the \
526 selected smoothing the moment estimate moved from phi={prior_phi:.6e} to \
527 phi={refreshed_phi:.6e} and the mean re-solve at the refreshed precision ended \
528 '{inner_status}' (deviance {deviance:.6e}). The (beta, phi) alternation is only minted at \
529 a fixed point where both the mean and the precision are stationary; a precision that \
530 keeps growing means the response carries no dispersion around the fitted mean at this \
531 smoothing, so no finite beta precision exists to certify."
532 )]
533 BetaPrecisionRefinementDidNotConverge {
534 passes: usize,
536 prior_phi: f64,
538 refreshed_phi: f64,
540 deviance: f64,
542 inner_status: String,
544 },
545
546 #[error(
547 "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
548 The model cannot converge because a predictor perfectly separates the binary outcomes. \
549 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
550 )]
551 PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
552
553 #[error(
554 "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
555 has a threshold {threshold:.6e} that separates the binary outcomes \
556 (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
557 enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
558 )]
559 PrefitPerfectSeparationDetected {
560 column_index: usize,
561 threshold: f64,
562 positive_above_threshold: bool,
563 },
564
565 #[error(
566 "Pre-fit linear separation detected in the realized binomial inverse-link design: \
567 {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
568 with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
569 The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
570 remove/reparameterize/penalize the separating columns."
571 )]
572 PrefitLinearSeparationDetected {
573 min_signed_margin: f64,
574 num_unpenalized_columns: usize,
575 column_indices: Vec<usize>,
576 },
577
578 #[error(
579 "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
580 unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
581 Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
582 )]
583 PrefitRankDeficientDesignDetected {
584 rank: usize,
585 num_unpenalized_columns: usize,
586 min_eigenvalue: f64,
587 tolerance: f64,
588 column_indices: Vec<usize>,
589 },
590
591 #[error(
592 "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
593 unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
594 exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
595 columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
596 so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
597 penalty/constraint before fitting."
598 )]
599 PrefitNearDegenerateDesignDetected {
600 num_unpenalized_columns: usize,
601 condition_number: f64,
602 min_eigenvalue: f64,
603 max_eigenvalue: f64,
604 tolerance: f64,
605 column_indices: Vec<usize>,
606 },
607
608 #[error(
609 "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
610 The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
611 so the unpenalized softmax MLE is not finite in that direction. \
612 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
613 )]
614 MultinomialSeparationDetected {
615 iteration: usize,
616 max_abs_eta: f64,
617 active_class_index: usize,
618 row_index: usize,
619 },
620
621 #[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
622 HessianNotPositiveDefinite { min_eigenvalue: f64 },
623
624 #[error("REML smoothing optimization failed to converge: {0}")]
625 RemlOptimizationFailed(String),
626
627 #[error("{reason}")]
644 TrialPointRefused { reason: String },
645
646 #[error("Fatal outer-objective evaluation failure ({context}): {source}")]
647 OuterObjectiveEvaluationFailed {
648 context: String,
649 #[source]
650 source: OuterObjectiveErrorSource,
651 },
652
653 #[error(
654 "Outer smoothing-parameter optimization did not certify a stationary optimum \
655 ({context}): {reason} after {iterations} outer iteration(s); final objective \
656 {final_value:.6e}, projected gradient norm {} {stationarity_standard}. A fit is \
657 only minted from a converged optimization; the best iterate is carried as a \
658 checkpoint — resume by seeding the outer search at rho_checkpoint = \
659 {rho_checkpoint:?}.",
660 .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}")),
661 )]
662 RemlDidNotConverge {
663 context: String,
665 reason: String,
669 iterations: usize,
671 final_value: f64,
673 projected_grad_norm: Option<f64>,
676 stationarity_standard: StationarityStandard,
682 rho_checkpoint: Vec<f64>,
686 },
687
688 #[error(
689 "Fit assembly rejected a non-converged optimization state: inner status \
690 {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
691 iteration(s); final objective {}; stationarity {stationarity}, \
692 step {step}. The best rho checkpoint is \
693 {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
694 API was constructed.",
695 .final_value.map_or_else(
696 || "unavailable (this fit has no criterion value)".to_string(),
697 |value| format!("{value:.6e}"),
698 ),
699 )]
700 FitDidNotConverge {
701 inner_status: String,
705 outer_status: String,
707 outer_iterations: usize,
709 final_value: Option<f64>,
713 stationarity: FitStationarityEvidence,
716 step: FitStationarityEvidence,
721 rho_checkpoint: Vec<f64>,
723 resume_token: Option<String>,
726 },
727
728 #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
729 GradientUnavailable {
730 context: &'static str,
731 mode: &'static str,
732 },
733
734 #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
735 LayoutError(String),
736
737 #[error(
738 "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."
739 )]
740 ModelIsIllConditioned { condition_number: f64 },
741
742 #[error("Invalid input: {0}")]
743 InvalidInput(String),
744
745 #[error(
746 "Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
747 interval [{lower}, {upper}]"
748 )]
749 InverseLinkDomainViolation {
750 link: &'static str,
751 eta: f64,
752 lower: f64,
753 upper: f64,
754 },
755
756 #[error(
757 "PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
758 eta={eta:?} produced {value:?}"
759 )]
760 PirlsRowGeometryUnrepresentable {
761 row: usize,
762 quantity: &'static str,
763 eta: f64,
764 value: f64,
765 },
766
767 #[error(
768 "Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
769 )]
770 ExactTweedieSeriesWorkLimit {
771 row: usize,
772 required_terms_lower_bound: f64,
773 budget: usize,
774 },
775
776 #[error(
777 "Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
778 the supported interval [{lower}, {upper}]"
779 )]
780 LogStrengthDomainViolation {
781 coordinate: usize,
782 value: f64,
783 lower: f64,
784 upper: f64,
785 },
786
787 #[error("monotone root solve: {0}")]
788 MonotoneRoot(#[from] MonotoneRootError),
789
790 #[error("Calibrator training failed: {0}")]
791 CalibratorTrainingFailed(String),
792
793 #[error("Invalid specification: {0}")]
794 InvalidSpecification(String),
795
796 #[error("Prediction error")]
797 PredictionError,
798}
799
800impl core::fmt::Debug for EstimationError {
802 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
803 write!(f, "{}", self)
804 }
805}
806
807impl EstimationError {
808 #[must_use]
817 pub fn pirls_row_geometry_unrepresentable(
818 row: usize,
819 quantity: &'static str,
820 eta: f64,
821 value: f64,
822 ) -> Self {
823 Self::PirlsRowGeometryUnrepresentable {
824 row,
825 quantity,
826 eta,
827 value,
828 }
829 }
830
831 #[must_use]
837 pub fn advice(&self) -> Option<String> {
838 const SEPARATION: &str = "Enable Firth/Jeffreys bias reduction, remove or regularize \
839 the separating predictor, or switch link via link(type=...).";
840 const CONDITIONING: &str = "Check for collinear or constant predictors and overly \
841 complex smooth bases.";
842 match self {
843 Self::BasisError(inner) => inner.advice(),
844 Self::OuterObjectiveEvaluationFailed { source, .. } => {
845 source.estimation_error().and_then(Self::advice)
846 }
847 Self::PerfectSeparationDetected { .. }
848 | Self::MultinomialSeparationDetected { .. } => {
849 Some(format!("Detected (quasi-)separation. {SEPARATION}"))
850 }
851 Self::PrefitPerfectSeparationDetected { column_index, .. } => Some(format!(
852 "Detected separation driven by unpenalized column {column_index}. {SEPARATION}"
853 )),
854 Self::PrefitLinearSeparationDetected { column_indices, .. } => Some(format!(
855 "Detected separation driven by unpenalized columns {column_indices:?}. {SEPARATION}"
856 )),
857 Self::PrefitRankDeficientDesignDetected { column_indices, .. }
858 | Self::PrefitNearDegenerateDesignDetected { column_indices, .. } => Some(format!(
859 "Matrix conditioning issue in unpenalized columns {column_indices:?}. {CONDITIONING}"
860 )),
861 Self::ModelIsIllConditioned { .. }
862 | Self::HessianNotPositiveDefinite { .. }
863 | Self::LinearSystemSolveFailed(_)
864 | Self::EigendecompositionFailed(_) => {
865 Some(format!("Matrix conditioning issue detected. {CONDITIONING}"))
866 }
867 _ => None,
868 }
869 }
870
871 #[must_use]
893 pub fn is_trial_point_infeasible(&self) -> bool {
894 match self {
895 Self::CustomFamily(err) => err.is_trial_point_infeasible(),
897 Self::TrialPointRefused { .. } => true,
899 Self::ModelIsIllConditioned { .. }
907 | Self::PerfectSeparationDetected { .. }
908 | Self::MultinomialSeparationDetected { .. }
909 | Self::PirlsDidNotConverge { .. }
910 | Self::FixedLambdaNewtonDidNotConverge { .. } => true,
911 Self::InvalidStabilization { .. }
915 | Self::BasisError { .. }
916 | Self::LinearSystemSolveFailed { .. }
917 | Self::EigendecompositionFailed { .. }
918 | Self::PenaltySpectrumNonFinite { .. }
919 | Self::PenaltySpectrumIndefinite { .. }
920 | Self::ParameterConstraintViolation { .. }
921 | Self::BlockOrthogonalRemlDidNotConverge { .. }
922 | Self::NegativeBinomialAlternationDidNotConverge { .. }
923 | Self::BetaPrecisionRefinementDidNotConverge { .. }
924 | Self::PrefitPerfectSeparationDetected { .. }
925 | Self::PrefitLinearSeparationDetected { .. }
926 | Self::PrefitRankDeficientDesignDetected { .. }
927 | Self::PrefitNearDegenerateDesignDetected { .. }
928 | Self::HessianNotPositiveDefinite { .. }
929 | Self::RemlOptimizationFailed { .. }
930 | Self::OuterObjectiveEvaluationFailed { .. }
931 | Self::RemlDidNotConverge { .. }
932 | Self::FitDidNotConverge { .. }
933 | Self::GradientUnavailable { .. }
934 | Self::LayoutError { .. }
935 | Self::InvalidInput { .. }
936 | Self::InverseLinkDomainViolation { .. }
937 | Self::PirlsRowGeometryUnrepresentable { .. }
938 | Self::ExactTweedieSeriesWorkLimit { .. }
939 | Self::LogStrengthDomainViolation { .. }
940 | Self::MonotoneRoot { .. }
941 | Self::CalibratorTrainingFailed { .. }
942 | Self::InvalidSpecification { .. }
943 | Self::PredictionError { .. } => false,
944 }
945 }
946
947 pub fn fatal_outer_evaluation(context: impl Into<String>, source: EstimationError) -> Self {
953 if matches!(
954 &source,
955 EstimationError::OuterObjectiveEvaluationFailed { .. }
956 ) {
957 source
958 } else {
959 EstimationError::OuterObjectiveEvaluationFailed {
960 context: context.into(),
961 source: OuterObjectiveErrorSource::Estimation(Box::new(source)),
962 }
963 }
964 }
965
966 pub fn fatal_objective_evaluation(
974 context: impl Into<String>,
975 source: opt::ObjectiveEvalError,
976 ) -> Self {
977 assert!(
978 source.is_fatal(),
979 "fatal_objective_evaluation requires a producer-classified fatal error"
980 );
981 EstimationError::OuterObjectiveEvaluationFailed {
982 context: context.into(),
983 source: OuterObjectiveErrorSource::Objective(source),
984 }
985 }
986
987 pub fn is_fatal_outer_evaluation(&self) -> bool {
988 matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
989 }
990
991 #[must_use]
1012 pub fn wrap_preserving_trial_point(self, context: &str) -> Self {
1013 let infeasible = self.is_trial_point_infeasible();
1014 let reason = format!("{context}: {self}");
1015 if infeasible {
1016 Self::TrialPointRefused { reason }
1017 } else {
1018 Self::InvalidInput(reason)
1019 }
1020 }
1021
1022 pub fn is_inner_solve_retreat(&self) -> bool {
1023 self.is_trial_point_infeasible()
1035 }
1036}
1037
1038#[cfg(test)]
1039mod advice_policy_tests {
1040 use super::*;
1041
1042 #[test]
1043 fn advice_is_keyed_on_the_variant_and_flows_through_the_basis_wrapper() {
1044 let separation = EstimationError::PrefitPerfectSeparationDetected {
1045 column_index: 3,
1046 threshold: 0.5,
1047 positive_above_threshold: true,
1048 };
1049 let advice = separation.advice().expect("separation advice");
1050 assert!(advice.contains("column 3"), "{advice}");
1051 assert!(advice.contains("Firth"), "{advice}");
1052
1053 let conditioning = EstimationError::ModelIsIllConditioned {
1054 condition_number: 1e18,
1055 };
1056 let advice = conditioning.advice().expect("conditioning advice");
1057 assert!(advice.contains("collinear"), "{advice}");
1058
1059 let basis = EstimationError::BasisError(BasisError::duchon_smoothness_insufficient(
1060 "hybrid diagonal",
1061 0,
1062 3,
1063 1,
1064 0.5,
1065 ));
1066 let advice = basis.advice().expect("basis advice");
1067 assert!(advice.contains("power"), "{advice}");
1068
1069 assert!(EstimationError::InvalidInput("dimension=16".into()).advice().is_none());
1070 }
1071}
1072
1073#[cfg(test)]
1074mod trial_point_classification_tests {
1075 use super::*;
1076
1077 #[test]
1082 fn every_inner_solve_retreat_is_a_trial_point_infeasibility() {
1083 let retreats = [
1084 EstimationError::ModelIsIllConditioned {
1085 condition_number: 1.0e18,
1086 },
1087 EstimationError::PerfectSeparationDetected {
1088 iteration: 3,
1089 max_abs_eta: 1.0e3,
1090 },
1091 EstimationError::MultinomialSeparationDetected {
1092 iteration: 3,
1093 max_abs_eta: 1.0e3,
1094 active_class_index: 1,
1095 row_index: 2,
1096 },
1097 EstimationError::PirlsDidNotConverge {
1098 max_iterations: 40,
1099 last_change: 1.0e-2,
1100 },
1101 EstimationError::FixedLambdaNewtonDidNotConverge {
1108 context: "trial-point classification fixture".to_string(),
1109 reason: FixedLambdaStallReason::IterationBudgetExhausted,
1110 objective_value: 12.5,
1111 stationarity: FixedLambdaStationarityEvidence {
1112 kind: FixedLambdaResidualKind::PenalizedGradientNorm,
1113 residual: 1.0e-3,
1114 bound: 1.0e-8,
1115 },
1116 checkpoint: FixedLambdaCheckpoint::new(
1117 FixedLambdaSolverStage::MultinomialNewton,
1118 vec![0.0, 0.0],
1119 2,
1120 1,
1121 40,
1122 )
1123 .expect("fixture checkpoint geometry is valid"),
1124 },
1125 ];
1126 for error in retreats {
1127 assert!(
1128 error.is_inner_solve_retreat(),
1129 "fixture must be a retreat: {error}"
1130 );
1131 assert!(
1132 error.is_trial_point_infeasible(),
1133 "a retreat is by its own definition a trial-point infeasibility: {error}"
1134 );
1135 }
1136 }
1137
1138 #[test]
1141 fn a_custom_family_trial_point_refusal_stays_recoverable() {
1142 let reason = "joint Newton returned an indefinite mode at this rho";
1143 assert!(
1144 EstimationError::CustomFamily(CustomFamilyError::trial_point(reason))
1145 .is_trial_point_infeasible()
1146 );
1147 assert!(
1148 !EstimationError::RemlOptimizationFailed(reason.to_string())
1149 .is_trial_point_infeasible(),
1150 "the prose-only variant is exactly what must NOT carry a rho-local refusal"
1151 );
1152 }
1153}
1154
1155impl From<LinalgError> for EstimationError {
1156 fn from(error: LinalgError) -> Self {
1157 match error {
1158 LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
1159 LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
1160 EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
1161 }
1162 LinalgError::ModelIsIllConditioned { condition_number } => {
1163 EstimationError::ModelIsIllConditioned { condition_number }
1164 }
1165 }
1166 }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172
1173 fn reml_refusal(standard: StationarityStandard) -> EstimationError {
1176 EstimationError::RemlDidNotConverge {
1177 context: "unit".to_string(),
1178 reason: "budget exhausted".to_string(),
1179 iterations: 7,
1180 final_value: -1.25,
1181 projected_grad_norm: Some(7.5e-1),
1182 stationarity_standard: standard,
1183 rho_checkpoint: vec![0.5],
1184 }
1185 }
1186
1187 fn measured(label: &'static str, derived_standard: bool) -> StationarityStandard {
1188 StationarityStandard::Measured {
1189 bound: 1.0e-2,
1190 rung: StationarityRung {
1191 label,
1192 derived_standard,
1193 },
1194 }
1195 }
1196
1197 #[test]
1200 fn refusal_message_carries_the_rung_and_whether_it_is_derived() {
1201 let derived = reml_refusal(measured("curvature-resolvability", true)).to_string();
1202 assert!(
1203 derived.contains("rung=curvature-resolvability"),
1204 "refusal must name its rung: {derived}"
1205 );
1206 assert!(
1207 derived.contains("derived_standard=true"),
1208 "refusal must say whether the rung is the derived standard: {derived}"
1209 );
1210
1211 let substitute = reml_refusal(measured("solver-band", false)).to_string();
1212 assert!(substitute.contains("rung=solver-band"), "{substitute}");
1213 assert!(
1214 substitute.contains("derived_standard=false"),
1215 "a gradient-magnitude substitute must not read as the derived standard: {substitute}"
1216 );
1217 }
1218
1219 #[test]
1225 fn a_refusal_without_a_comparison_reports_no_bound() {
1226 let message = reml_refusal(StationarityStandard::NoComparison).to_string();
1227 assert!(
1228 message.contains("against no stationarity bound"),
1229 "a refusal that applied no bound must say so: {message}"
1230 );
1231 assert!(
1232 !message.contains("rung="),
1233 "no rung may be claimed where no bound was applied: {message}"
1234 );
1235 assert!(
1236 !message.contains("1.000e-2"),
1237 "no bound value may appear where none was applied: {message}"
1238 );
1239 }
1240
1241 #[test]
1244 fn the_bound_and_its_rung_are_one_field() {
1245 let standard = measured("probe-noise-floor", false);
1246 assert_eq!(standard.bound(), Some(1.0e-2));
1247 assert_eq!(
1248 standard.rung().map(|rung| rung.label),
1249 Some("probe-noise-floor")
1250 );
1251 assert_eq!(StationarityStandard::NoComparison.bound(), None);
1252 assert_eq!(StationarityStandard::NoComparison.rung(), None);
1253 }
1254
1255 #[test]
1258 fn rung_rides_beside_the_bound_without_displacing_it() {
1259 let message = reml_refusal(measured("solver-band", false)).to_string();
1260 assert!(
1261 message.contains("1.000e-2"),
1262 "bound must survive: {message}"
1263 );
1264 assert!(
1265 message.contains("7.500e-1"),
1266 "projected gradient norm must survive: {message}"
1267 );
1268 }
1269
1270 #[test]
1273 fn model_ill_conditioned_is_retreat() {
1274 assert!(
1275 EstimationError::ModelIsIllConditioned {
1276 condition_number: 1e15
1277 }
1278 .is_inner_solve_retreat()
1279 );
1280 }
1281
1282 #[test]
1283 fn perfect_separation_is_retreat() {
1284 assert!(
1285 EstimationError::PerfectSeparationDetected {
1286 iteration: 3,
1287 max_abs_eta: 50.0
1288 }
1289 .is_inner_solve_retreat()
1290 );
1291 }
1292
1293 #[test]
1294 fn multinomial_separation_is_retreat() {
1295 assert!(
1296 EstimationError::MultinomialSeparationDetected {
1297 iteration: 1,
1298 max_abs_eta: 100.0,
1299 active_class_index: 2,
1300 row_index: 7
1301 }
1302 .is_inner_solve_retreat()
1303 );
1304 }
1305
1306 #[test]
1307 fn pirls_did_not_converge_is_retreat() {
1308 assert!(
1309 EstimationError::PirlsDidNotConverge {
1310 max_iterations: 100,
1311 last_change: 1e-3
1312 }
1313 .is_inner_solve_retreat()
1314 );
1315 }
1316
1317 #[test]
1318 fn invalid_input_is_not_retreat() {
1319 assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
1320 }
1321
1322 #[test]
1323 fn reml_optimization_failed_is_not_retreat() {
1324 assert!(
1325 !EstimationError::RemlOptimizationFailed("outer fail".to_string())
1326 .is_inner_solve_retreat()
1327 );
1328 }
1329
1330 #[test]
1331 fn fatal_outer_evaluation_is_typed_and_idempotent() {
1332 let error = EstimationError::fatal_outer_evaluation(
1333 "seed screening",
1334 EstimationError::InvalidInput("frame mismatch".to_string()),
1335 );
1336 assert!(error.is_fatal_outer_evaluation());
1337 assert!(error.to_string().contains("frame mismatch"));
1338
1339 let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
1340 assert!(nested.is_fatal_outer_evaluation());
1341 assert_eq!(
1342 nested.to_string().matches("Fatal outer-objective").count(),
1343 1,
1344 "fatal provenance must not be re-wrapped at every orchestration layer"
1345 );
1346 }
1347
1348 #[test]
1351 fn invalid_input_message_appears_in_display() {
1352 let err = EstimationError::InvalidInput("test_message".to_string());
1353 assert!(err.to_string().contains("test_message"));
1354 }
1355
1356 #[test]
1357 fn pirls_did_not_converge_mentions_max_iterations() {
1358 let err = EstimationError::PirlsDidNotConverge {
1359 max_iterations: 42,
1360 last_change: 0.001,
1361 };
1362 assert!(err.to_string().contains("42"));
1363 }
1364
1365 #[test]
1366 fn fixed_lambda_checkpoint_validates_shape_and_values() {
1367 let checkpoint = FixedLambdaCheckpoint::new(
1368 FixedLambdaSolverStage::MultinomialNewton,
1369 vec![1.0, 2.0, 3.0, 4.0],
1370 2,
1371 2,
1372 7,
1373 )
1374 .expect("well-shaped finite checkpoint");
1375 assert_eq!(
1376 checkpoint.stage(),
1377 FixedLambdaSolverStage::MultinomialNewton
1378 );
1379 assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
1380 assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
1381 assert_eq!(checkpoint.completed_iterations(), 7);
1382
1383 assert!(
1384 FixedLambdaCheckpoint::new(
1385 FixedLambdaSolverStage::BinomialMultiNewton,
1386 vec![1.0],
1387 2,
1388 1,
1389 0,
1390 )
1391 .is_err(),
1392 "coefficient length must match rows * cols"
1393 );
1394 assert!(
1395 FixedLambdaCheckpoint::new(
1396 FixedLambdaSolverStage::BinomialMultiNewton,
1397 vec![f64::NAN],
1398 1,
1399 1,
1400 0,
1401 )
1402 .is_err(),
1403 "checkpoint coefficients must be finite"
1404 );
1405 assert!(
1406 FixedLambdaCheckpoint::new(
1407 FixedLambdaSolverStage::MultinomialFirth,
1408 Vec::new(),
1409 usize::MAX,
1410 2,
1411 0,
1412 )
1413 .is_err(),
1414 "checkpoint shape multiplication must not overflow"
1415 );
1416 }
1417
1418 #[test]
1419 fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
1420 let checkpoint = FixedLambdaCheckpoint::new(
1421 FixedLambdaSolverStage::MultinomialFirth,
1422 vec![12_345.678_9, -98_765.432_1],
1423 2,
1424 1,
1425 11,
1426 )
1427 .expect("valid checkpoint");
1428 let checkpoint_debug = format!("{checkpoint:?}");
1429 assert!(!checkpoint_debug.contains("12345.6789"));
1430 assert!(!checkpoint_debug.contains("98765.4321"));
1431 let err = EstimationError::FixedLambdaNewtonDidNotConverge {
1432 context: "test Firth solve".to_string(),
1433 reason: FixedLambdaStallReason::LineSearchExhausted,
1434 objective_value: 3.25,
1435 stationarity: FixedLambdaStationarityEvidence {
1436 kind: FixedLambdaResidualKind::NewtonDecrement,
1437 residual: 0.125,
1438 bound: 1.0e-7,
1439 },
1440 checkpoint,
1441 };
1442
1443 let display = err.to_string();
1444 assert!(display.contains("test Firth solve"));
1445 assert!(display.contains("line search exhausted"));
1446 assert!(display.contains("Newton decrement"));
1447 assert!(display.contains("2x1"));
1448 assert!(display.contains("11 iteration"));
1449 assert!(!display.contains("12345.6789"));
1450 assert!(!display.contains("98765.4321"));
1451 assert_eq!(
1452 format!("{err:?}"),
1453 display,
1454 "Debug delegates to safe Display"
1455 );
1456 assert!(err.is_inner_solve_retreat());
1457 }
1458
1459 #[test]
1462 fn from_linalg_invalid_input_maps_to_invalid_input() {
1463 let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
1464 let err = EstimationError::from(linalg_err);
1465 assert!(matches!(err, EstimationError::InvalidInput(_)));
1466 assert!(err.to_string().contains("linalg msg"));
1467 }
1468
1469 #[test]
1470 fn from_linalg_hessian_not_spd_maps_correctly() {
1471 let linalg_err = LinalgError::HessianNotPositiveDefinite {
1472 min_eigenvalue: -1.0,
1473 };
1474 let err = EstimationError::from(linalg_err);
1475 assert!(matches!(
1476 err,
1477 EstimationError::HessianNotPositiveDefinite { .. }
1478 ));
1479 }
1480}
1481
1482fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
1491 if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
1492 format!(
1493 "Hessian factorization failed although the (lower-triangle) spectrum is positive \
1494 (minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
1495 precision or the assembled matrix is asymmetric/non-finite outside the factored \
1496 triangle. This indicates a numerical instability in the Hessian assembly or scaling."
1497 )
1498 } else {
1499 format!(
1500 "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
1501 This indicates a numerical instability."
1502 )
1503 }
1504}