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 #[must_use]
390 pub fn objective_error(&self) -> Option<&opt::ObjectiveEvalError> {
391 match self {
392 Self::Estimation(_) => None,
393 Self::Objective(source) => Some(source),
394 }
395 }
396}
397
398#[derive(thiserror::Error)]
400pub enum EstimationError {
401 #[error(transparent)]
402 InvalidStabilization(#[from] crate::InvalidStabilization),
403
404 #[error("Underlying basis function generation failed: {0}")]
405 BasisError(#[from] BasisError),
406
407 #[error("Custom-family fit failed: {0}")]
408 CustomFamily(#[from] CustomFamilyError),
409
410 #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
411 LinearSystemSolveFailed(FaerLinalgError),
412
413 #[error("Eigendecomposition failed: {0}")]
414 EigendecompositionFailed(FaerLinalgError),
415
416 #[error(
417 "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
418 )]
419 PenaltySpectrumNonFinite {
420 context: String,
421 index: usize,
422 value: f64,
423 },
424
425 #[error(
426 "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
427 )]
428 PenaltySpectrumIndefinite {
429 context: String,
430 index: usize,
431 value: f64,
432 tolerance: f64,
433 scale: f64,
434 },
435
436 #[error("Parameter constraint violation: {0}")]
437 ParameterConstraintViolation(String),
438
439 #[error(
440 "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
441 )]
442 PirlsDidNotConverge {
443 max_iterations: usize,
444 last_change: f64,
445 },
446
447 #[error(
448 "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
449 {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
450 only minted from a converged optimization; resume by passing the carried checkpoint \
451 through the fixed-lambda input's `resume_from` field ({checkpoint}).",
452 .checkpoint.completed_iterations()
453 )]
454 FixedLambdaNewtonDidNotConverge {
455 context: String,
458 reason: FixedLambdaStallReason,
460 objective_value: f64,
464 stationarity: FixedLambdaStationarityEvidence,
466 checkpoint: FixedLambdaCheckpoint,
470 },
471
472 #[error(
473 "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
474 max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
475 minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
476 {profile_curvature_roundoff:.3e}; last scale fixed-point step \
477 {last_scale_step:.6e}{}). \
478 A fit is only minted from a converged optimization; resume from the \
479 checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
480 if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
481 )]
482 BlockOrthogonalRemlDidNotConverge {
483 iterations: usize,
485 max_score_residual: f64,
488 score_tol: f64,
490 min_profile_curvature: f64,
493 profile_curvature_roundoff: f64,
496 last_scale_step: f64,
499 cycle_detected: bool,
502 rho_checkpoint: Vec<f64>,
505 },
506
507 #[error(
508 "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
509 {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
510 {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
511 against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
512 partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
513 and rho={rho_checkpoint:?}."
514 )]
515 NegativeBinomialAlternationDidNotConverge {
516 rounds: usize,
518 theta_checkpoint: f64,
520 rho_projected_grad_norm: f64,
522 rho_stationarity_bound: f64,
524 theta_score_residual: f64,
526 theta_stationarity_bound: f64,
528 rho_checkpoint: Vec<f64>,
530 },
531
532 #[error(
533 "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
534 The model cannot converge because a predictor perfectly separates the binary outcomes. \
535 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
536 )]
537 PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
538
539 #[error(
540 "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
541 has a threshold {threshold:.6e} that separates the binary outcomes \
542 (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
543 enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
544 )]
545 PrefitPerfectSeparationDetected {
546 column_index: usize,
547 threshold: f64,
548 positive_above_threshold: bool,
549 },
550
551 #[error(
552 "Pre-fit linear separation detected in the realized binomial inverse-link design: \
553 {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
554 with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
555 The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
556 remove/reparameterize/penalize the separating columns."
557 )]
558 PrefitLinearSeparationDetected {
559 min_signed_margin: f64,
560 num_unpenalized_columns: usize,
561 column_indices: Vec<usize>,
562 },
563
564 #[error(
565 "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
566 unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
567 Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
568 )]
569 PrefitRankDeficientDesignDetected {
570 rank: usize,
571 num_unpenalized_columns: usize,
572 min_eigenvalue: f64,
573 tolerance: f64,
574 column_indices: Vec<usize>,
575 },
576
577 #[error(
578 "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
579 unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
580 exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
581 columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
582 so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
583 penalty/constraint before fitting."
584 )]
585 PrefitNearDegenerateDesignDetected {
586 num_unpenalized_columns: usize,
587 condition_number: f64,
588 min_eigenvalue: f64,
589 max_eigenvalue: f64,
590 tolerance: f64,
591 column_indices: Vec<usize>,
592 },
593
594 #[error(
595 "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
596 The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
597 so the unpenalized softmax MLE is not finite in that direction. \
598 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
599 )]
600 MultinomialSeparationDetected {
601 iteration: usize,
602 max_abs_eta: f64,
603 active_class_index: usize,
604 row_index: usize,
605 },
606
607 #[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
608 HessianNotPositiveDefinite { min_eigenvalue: f64 },
609
610 #[error("REML smoothing optimization failed to converge: {0}")]
611 RemlOptimizationFailed(String),
612
613 #[error("{reason}")]
630 TrialPointRefused { reason: String },
631
632 #[error("Fatal outer-objective evaluation failure ({context}): {source}")]
633 OuterObjectiveEvaluationFailed {
634 context: String,
635 #[source]
636 source: OuterObjectiveErrorSource,
637 },
638
639 #[error(
640 "Outer smoothing-parameter optimization did not certify a stationary optimum \
641 ({context}): {reason} after {iterations} outer iteration(s); final objective \
642 {final_value:.6e}, projected gradient norm {} {stationarity_standard}. A fit is \
643 only minted from a converged optimization; the best iterate is carried as a \
644 checkpoint — resume by seeding the outer search at rho_checkpoint = \
645 {rho_checkpoint:?}.",
646 .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}")),
647 )]
648 RemlDidNotConverge {
649 context: String,
651 reason: String,
655 iterations: usize,
657 final_value: f64,
659 projected_grad_norm: Option<f64>,
662 stationarity_standard: StationarityStandard,
668 rho_checkpoint: Vec<f64>,
672 },
673
674 #[error(
675 "Fit assembly rejected a non-converged optimization state: inner status \
676 {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
677 iteration(s); final objective {}; stationarity {stationarity}, \
678 step {step}. The best rho checkpoint is \
679 {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
680 API was constructed.",
681 .final_value.map_or_else(
682 || "unavailable (this fit has no criterion value)".to_string(),
683 |value| format!("{value:.6e}"),
684 ),
685 )]
686 FitDidNotConverge {
687 inner_status: String,
691 outer_status: String,
693 outer_iterations: usize,
695 final_value: Option<f64>,
699 stationarity: FitStationarityEvidence,
702 step: FitStationarityEvidence,
707 rho_checkpoint: Vec<f64>,
709 resume_token: Option<String>,
712 },
713
714 #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
715 GradientUnavailable {
716 context: &'static str,
717 mode: &'static str,
718 },
719
720 #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
721 LayoutError(String),
722
723 #[error(
724 "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."
725 )]
726 ModelIsIllConditioned { condition_number: f64 },
727
728 #[error("Invalid input: {0}")]
729 InvalidInput(String),
730
731 #[error(
732 "Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
733 interval [{lower}, {upper}]"
734 )]
735 InverseLinkDomainViolation {
736 link: &'static str,
737 eta: f64,
738 lower: f64,
739 upper: f64,
740 },
741
742 #[error(
743 "PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
744 eta={eta:?} produced {value:?}"
745 )]
746 PirlsRowGeometryUnrepresentable {
747 row: usize,
748 quantity: &'static str,
749 eta: f64,
750 value: f64,
751 },
752
753 #[error(
754 "Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
755 )]
756 ExactTweedieSeriesWorkLimit {
757 row: usize,
758 required_terms_lower_bound: f64,
759 budget: usize,
760 },
761
762 #[error(
763 "Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
764 the supported interval [{lower}, {upper}]"
765 )]
766 LogStrengthDomainViolation {
767 coordinate: usize,
768 value: f64,
769 lower: f64,
770 upper: f64,
771 },
772
773 #[error("monotone root solve: {0}")]
774 MonotoneRoot(#[from] MonotoneRootError),
775
776 #[error("Calibrator training failed: {0}")]
777 CalibratorTrainingFailed(String),
778
779 #[error("Invalid specification: {0}")]
780 InvalidSpecification(String),
781
782 #[error("Prediction error")]
783 PredictionError,
784}
785
786impl core::fmt::Debug for EstimationError {
788 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
789 write!(f, "{}", self)
790 }
791}
792
793impl EstimationError {
794 #[must_use]
816 pub fn is_trial_point_infeasible(&self) -> bool {
817 match self {
818 Self::CustomFamily(err) => err.is_trial_point_infeasible(),
820 Self::TrialPointRefused { .. } => true,
822 Self::ModelIsIllConditioned { .. }
830 | Self::PerfectSeparationDetected { .. }
831 | Self::MultinomialSeparationDetected { .. }
832 | Self::PirlsDidNotConverge { .. }
833 | Self::FixedLambdaNewtonDidNotConverge { .. } => true,
834 Self::InvalidStabilization { .. }
838 | Self::BasisError { .. }
839 | Self::LinearSystemSolveFailed { .. }
840 | Self::EigendecompositionFailed { .. }
841 | Self::PenaltySpectrumNonFinite { .. }
842 | Self::PenaltySpectrumIndefinite { .. }
843 | Self::ParameterConstraintViolation { .. }
844 | Self::BlockOrthogonalRemlDidNotConverge { .. }
845 | Self::NegativeBinomialAlternationDidNotConverge { .. }
846 | Self::PrefitPerfectSeparationDetected { .. }
847 | Self::PrefitLinearSeparationDetected { .. }
848 | Self::PrefitRankDeficientDesignDetected { .. }
849 | Self::PrefitNearDegenerateDesignDetected { .. }
850 | Self::HessianNotPositiveDefinite { .. }
851 | Self::RemlOptimizationFailed { .. }
852 | Self::OuterObjectiveEvaluationFailed { .. }
853 | Self::RemlDidNotConverge { .. }
854 | Self::FitDidNotConverge { .. }
855 | Self::GradientUnavailable { .. }
856 | Self::LayoutError { .. }
857 | Self::InvalidInput { .. }
858 | Self::InverseLinkDomainViolation { .. }
859 | Self::PirlsRowGeometryUnrepresentable { .. }
860 | Self::ExactTweedieSeriesWorkLimit { .. }
861 | Self::LogStrengthDomainViolation { .. }
862 | Self::MonotoneRoot { .. }
863 | Self::CalibratorTrainingFailed { .. }
864 | Self::InvalidSpecification { .. }
865 | Self::PredictionError { .. } => false,
866 }
867 }
868
869 pub fn fatal_outer_evaluation(context: impl Into<String>, source: EstimationError) -> Self {
875 if matches!(
876 &source,
877 EstimationError::OuterObjectiveEvaluationFailed { .. }
878 ) {
879 source
880 } else {
881 EstimationError::OuterObjectiveEvaluationFailed {
882 context: context.into(),
883 source: OuterObjectiveErrorSource::Estimation(Box::new(source)),
884 }
885 }
886 }
887
888 pub fn fatal_objective_evaluation(
896 context: impl Into<String>,
897 source: opt::ObjectiveEvalError,
898 ) -> Self {
899 assert!(
900 source.is_fatal(),
901 "fatal_objective_evaluation requires a producer-classified fatal error"
902 );
903 EstimationError::OuterObjectiveEvaluationFailed {
904 context: context.into(),
905 source: OuterObjectiveErrorSource::Objective(source),
906 }
907 }
908
909 pub fn is_fatal_outer_evaluation(&self) -> bool {
910 matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
911 }
912
913 #[must_use]
934 pub fn wrap_preserving_trial_point(self, context: &str) -> Self {
935 let infeasible = self.is_trial_point_infeasible();
936 let reason = format!("{context}: {self}");
937 if infeasible {
938 Self::TrialPointRefused { reason }
939 } else {
940 Self::InvalidInput(reason)
941 }
942 }
943
944 pub fn is_inner_solve_retreat(&self) -> bool {
945 self.is_trial_point_infeasible()
957 }
958}
959
960#[cfg(test)]
961mod trial_point_classification_tests {
962 use super::*;
963
964 #[test]
969 fn every_inner_solve_retreat_is_a_trial_point_infeasibility() {
970 let retreats = [
971 EstimationError::ModelIsIllConditioned {
972 condition_number: 1.0e18,
973 },
974 EstimationError::PerfectSeparationDetected {
975 iteration: 3,
976 max_abs_eta: 1.0e3,
977 },
978 EstimationError::MultinomialSeparationDetected {
979 iteration: 3,
980 max_abs_eta: 1.0e3,
981 active_class_index: 1,
982 row_index: 2,
983 },
984 EstimationError::PirlsDidNotConverge {
985 max_iterations: 40,
986 last_change: 1.0e-2,
987 },
988 EstimationError::FixedLambdaNewtonDidNotConverge {
995 context: "trial-point classification fixture".to_string(),
996 reason: FixedLambdaStallReason::IterationBudgetExhausted,
997 objective_value: 12.5,
998 stationarity: FixedLambdaStationarityEvidence {
999 kind: FixedLambdaResidualKind::PenalizedGradientNorm,
1000 residual: 1.0e-3,
1001 bound: 1.0e-8,
1002 },
1003 checkpoint: FixedLambdaCheckpoint::new(
1004 FixedLambdaSolverStage::MultinomialNewton,
1005 vec![0.0, 0.0],
1006 2,
1007 1,
1008 40,
1009 )
1010 .expect("fixture checkpoint geometry is valid"),
1011 },
1012 ];
1013 for error in retreats {
1014 assert!(
1015 error.is_inner_solve_retreat(),
1016 "fixture must be a retreat: {error}"
1017 );
1018 assert!(
1019 error.is_trial_point_infeasible(),
1020 "a retreat is by its own definition a trial-point infeasibility: {error}"
1021 );
1022 }
1023 }
1024
1025 #[test]
1028 fn a_custom_family_trial_point_refusal_stays_recoverable() {
1029 let reason = "joint Newton returned an indefinite mode at this rho";
1030 assert!(
1031 EstimationError::CustomFamily(CustomFamilyError::trial_point(reason))
1032 .is_trial_point_infeasible()
1033 );
1034 assert!(
1035 !EstimationError::RemlOptimizationFailed(reason.to_string())
1036 .is_trial_point_infeasible(),
1037 "the prose-only variant is exactly what must NOT carry a rho-local refusal"
1038 );
1039 }
1040}
1041
1042impl From<LinalgError> for EstimationError {
1043 fn from(error: LinalgError) -> Self {
1044 match error {
1045 LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
1046 LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
1047 EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
1048 }
1049 LinalgError::ModelIsIllConditioned { condition_number } => {
1050 EstimationError::ModelIsIllConditioned { condition_number }
1051 }
1052 }
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 fn reml_refusal(standard: StationarityStandard) -> EstimationError {
1063 EstimationError::RemlDidNotConverge {
1064 context: "unit".to_string(),
1065 reason: "budget exhausted".to_string(),
1066 iterations: 7,
1067 final_value: -1.25,
1068 projected_grad_norm: Some(7.5e-1),
1069 stationarity_standard: standard,
1070 rho_checkpoint: vec![0.5],
1071 }
1072 }
1073
1074 fn measured(label: &'static str, derived_standard: bool) -> StationarityStandard {
1075 StationarityStandard::Measured {
1076 bound: 1.0e-2,
1077 rung: StationarityRung {
1078 label,
1079 derived_standard,
1080 },
1081 }
1082 }
1083
1084 #[test]
1087 fn refusal_message_carries_the_rung_and_whether_it_is_derived() {
1088 let derived = reml_refusal(measured("curvature-resolvability", true)).to_string();
1089 assert!(
1090 derived.contains("rung=curvature-resolvability"),
1091 "refusal must name its rung: {derived}"
1092 );
1093 assert!(
1094 derived.contains("derived_standard=true"),
1095 "refusal must say whether the rung is the derived standard: {derived}"
1096 );
1097
1098 let substitute = reml_refusal(measured("solver-band", false)).to_string();
1099 assert!(substitute.contains("rung=solver-band"), "{substitute}");
1100 assert!(
1101 substitute.contains("derived_standard=false"),
1102 "a gradient-magnitude substitute must not read as the derived standard: {substitute}"
1103 );
1104 }
1105
1106 #[test]
1112 fn a_refusal_without_a_comparison_reports_no_bound() {
1113 let message = reml_refusal(StationarityStandard::NoComparison).to_string();
1114 assert!(
1115 message.contains("against no stationarity bound"),
1116 "a refusal that applied no bound must say so: {message}"
1117 );
1118 assert!(
1119 !message.contains("rung="),
1120 "no rung may be claimed where no bound was applied: {message}"
1121 );
1122 assert!(
1123 !message.contains("1.000e-2"),
1124 "no bound value may appear where none was applied: {message}"
1125 );
1126 }
1127
1128 #[test]
1131 fn the_bound_and_its_rung_are_one_field() {
1132 let standard = measured("probe-noise-floor", false);
1133 assert_eq!(standard.bound(), Some(1.0e-2));
1134 assert_eq!(
1135 standard.rung().map(|rung| rung.label),
1136 Some("probe-noise-floor")
1137 );
1138 assert_eq!(StationarityStandard::NoComparison.bound(), None);
1139 assert_eq!(StationarityStandard::NoComparison.rung(), None);
1140 }
1141
1142 #[test]
1145 fn rung_rides_beside_the_bound_without_displacing_it() {
1146 let message = reml_refusal(measured("solver-band", false)).to_string();
1147 assert!(
1148 message.contains("1.000e-2"),
1149 "bound must survive: {message}"
1150 );
1151 assert!(
1152 message.contains("7.500e-1"),
1153 "projected gradient norm must survive: {message}"
1154 );
1155 }
1156
1157 #[test]
1160 fn model_ill_conditioned_is_retreat() {
1161 assert!(
1162 EstimationError::ModelIsIllConditioned {
1163 condition_number: 1e15
1164 }
1165 .is_inner_solve_retreat()
1166 );
1167 }
1168
1169 #[test]
1170 fn perfect_separation_is_retreat() {
1171 assert!(
1172 EstimationError::PerfectSeparationDetected {
1173 iteration: 3,
1174 max_abs_eta: 50.0
1175 }
1176 .is_inner_solve_retreat()
1177 );
1178 }
1179
1180 #[test]
1181 fn multinomial_separation_is_retreat() {
1182 assert!(
1183 EstimationError::MultinomialSeparationDetected {
1184 iteration: 1,
1185 max_abs_eta: 100.0,
1186 active_class_index: 2,
1187 row_index: 7
1188 }
1189 .is_inner_solve_retreat()
1190 );
1191 }
1192
1193 #[test]
1194 fn pirls_did_not_converge_is_retreat() {
1195 assert!(
1196 EstimationError::PirlsDidNotConverge {
1197 max_iterations: 100,
1198 last_change: 1e-3
1199 }
1200 .is_inner_solve_retreat()
1201 );
1202 }
1203
1204 #[test]
1205 fn invalid_input_is_not_retreat() {
1206 assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
1207 }
1208
1209 #[test]
1210 fn reml_optimization_failed_is_not_retreat() {
1211 assert!(
1212 !EstimationError::RemlOptimizationFailed("outer fail".to_string())
1213 .is_inner_solve_retreat()
1214 );
1215 }
1216
1217 #[test]
1218 fn fatal_outer_evaluation_is_typed_and_idempotent() {
1219 let error = EstimationError::fatal_outer_evaluation(
1220 "seed screening",
1221 EstimationError::InvalidInput("frame mismatch".to_string()),
1222 );
1223 assert!(error.is_fatal_outer_evaluation());
1224 assert!(error.to_string().contains("frame mismatch"));
1225
1226 let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
1227 assert!(nested.is_fatal_outer_evaluation());
1228 assert_eq!(
1229 nested.to_string().matches("Fatal outer-objective").count(),
1230 1,
1231 "fatal provenance must not be re-wrapped at every orchestration layer"
1232 );
1233 }
1234
1235 #[test]
1236 fn fatal_optimizer_evaluation_retains_exact_typed_source_2658() {
1237 let source = EstimationError::CustomFamily(CustomFamilyError::InnerSolveNotConverged {
1238 cycles: 17,
1239 terminal: None,
1240 kkt_residual: Some(3.5),
1241 kkt_tol: Some(0.25),
1242 theta_dim: 4,
1243 rho_dim: 3,
1244 psi_dim: 1,
1245 });
1246 let error = EstimationError::fatal_objective_evaluation(
1247 "outer fixed-point evaluation",
1248 opt::ObjectiveEvalError::fatal_from(source),
1249 );
1250
1251 let EstimationError::OuterObjectiveEvaluationFailed { source, .. } = &error else {
1252 panic!("fatal objective error must retain its boundary type");
1253 };
1254 assert!(
1255 source
1256 .objective_error()
1257 .is_some_and(|error| error.is_fatal())
1258 );
1259 let Some(EstimationError::CustomFamily(CustomFamilyError::InnerSolveNotConverged {
1260 cycles,
1261 kkt_residual,
1262 kkt_tol,
1263 theta_dim,
1264 rho_dim,
1265 psi_dim,
1266 ..
1267 })) = source.estimation_error()
1268 else {
1269 panic!("typed custom-family source was flattened or reminted");
1270 };
1271 assert_eq!(
1272 (
1273 *cycles,
1274 *kkt_residual,
1275 *kkt_tol,
1276 *theta_dim,
1277 *rho_dim,
1278 *psi_dim,
1279 ),
1280 (17, Some(3.5), Some(0.25), 4, 3, 1)
1281 );
1282 }
1283
1284 #[test]
1287 fn invalid_input_message_appears_in_display() {
1288 let err = EstimationError::InvalidInput("test_message".to_string());
1289 assert!(err.to_string().contains("test_message"));
1290 }
1291
1292 #[test]
1293 fn pirls_did_not_converge_mentions_max_iterations() {
1294 let err = EstimationError::PirlsDidNotConverge {
1295 max_iterations: 42,
1296 last_change: 0.001,
1297 };
1298 assert!(err.to_string().contains("42"));
1299 }
1300
1301 #[test]
1302 fn fixed_lambda_checkpoint_validates_shape_and_values() {
1303 let checkpoint = FixedLambdaCheckpoint::new(
1304 FixedLambdaSolverStage::MultinomialNewton,
1305 vec![1.0, 2.0, 3.0, 4.0],
1306 2,
1307 2,
1308 7,
1309 )
1310 .expect("well-shaped finite checkpoint");
1311 assert_eq!(
1312 checkpoint.stage(),
1313 FixedLambdaSolverStage::MultinomialNewton
1314 );
1315 assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
1316 assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
1317 assert_eq!(checkpoint.completed_iterations(), 7);
1318
1319 assert!(
1320 FixedLambdaCheckpoint::new(
1321 FixedLambdaSolverStage::BinomialMultiNewton,
1322 vec![1.0],
1323 2,
1324 1,
1325 0,
1326 )
1327 .is_err(),
1328 "coefficient length must match rows * cols"
1329 );
1330 assert!(
1331 FixedLambdaCheckpoint::new(
1332 FixedLambdaSolverStage::BinomialMultiNewton,
1333 vec![f64::NAN],
1334 1,
1335 1,
1336 0,
1337 )
1338 .is_err(),
1339 "checkpoint coefficients must be finite"
1340 );
1341 assert!(
1342 FixedLambdaCheckpoint::new(
1343 FixedLambdaSolverStage::MultinomialFirth,
1344 Vec::new(),
1345 usize::MAX,
1346 2,
1347 0,
1348 )
1349 .is_err(),
1350 "checkpoint shape multiplication must not overflow"
1351 );
1352 }
1353
1354 #[test]
1355 fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
1356 let checkpoint = FixedLambdaCheckpoint::new(
1357 FixedLambdaSolverStage::MultinomialFirth,
1358 vec![12_345.678_9, -98_765.432_1],
1359 2,
1360 1,
1361 11,
1362 )
1363 .expect("valid checkpoint");
1364 let checkpoint_debug = format!("{checkpoint:?}");
1365 assert!(!checkpoint_debug.contains("12345.6789"));
1366 assert!(!checkpoint_debug.contains("98765.4321"));
1367 let err = EstimationError::FixedLambdaNewtonDidNotConverge {
1368 context: "test Firth solve".to_string(),
1369 reason: FixedLambdaStallReason::LineSearchExhausted,
1370 objective_value: 3.25,
1371 stationarity: FixedLambdaStationarityEvidence {
1372 kind: FixedLambdaResidualKind::NewtonDecrement,
1373 residual: 0.125,
1374 bound: 1.0e-7,
1375 },
1376 checkpoint,
1377 };
1378
1379 let display = err.to_string();
1380 assert!(display.contains("test Firth solve"));
1381 assert!(display.contains("line search exhausted"));
1382 assert!(display.contains("Newton decrement"));
1383 assert!(display.contains("2x1"));
1384 assert!(display.contains("11 iteration"));
1385 assert!(!display.contains("12345.6789"));
1386 assert!(!display.contains("98765.4321"));
1387 assert_eq!(
1388 format!("{err:?}"),
1389 display,
1390 "Debug delegates to safe Display"
1391 );
1392 assert!(err.is_inner_solve_retreat());
1393 }
1394
1395 #[test]
1398 fn from_linalg_invalid_input_maps_to_invalid_input() {
1399 let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
1400 let err = EstimationError::from(linalg_err);
1401 assert!(matches!(err, EstimationError::InvalidInput(_)));
1402 assert!(err.to_string().contains("linalg msg"));
1403 }
1404
1405 #[test]
1406 fn from_linalg_hessian_not_spd_maps_correctly() {
1407 let linalg_err = LinalgError::HessianNotPositiveDefinite {
1408 min_eigenvalue: -1.0,
1409 };
1410 let err = EstimationError::from(linalg_err);
1411 assert!(matches!(
1412 err,
1413 EstimationError::HessianNotPositiveDefinite { .. }
1414 ));
1415 }
1416}
1417
1418fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
1427 if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
1428 format!(
1429 "Hessian factorization failed although the (lower-triangle) spectrum is positive \
1430 (minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
1431 precision or the assembled matrix is asymmetric/non-finite outside the factored \
1432 triangle. This indicates a numerical instability in the Hessian assembly or scaling."
1433 )
1434 } else {
1435 format!(
1436 "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
1437 This indicates a numerical instability."
1438 )
1439 }
1440}