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 std::fmt::Display for StationarityRung {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 write!(
34 f,
35 "rung={} derived_standard={}",
36 self.label, self.derived_standard
37 )
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum FixedLambdaSolverStage {
49 BinomialMultiNewton,
50 MultinomialNewton,
51 MultinomialFirth,
52}
53
54impl core::fmt::Display for FixedLambdaSolverStage {
55 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
56 f.write_str(match self {
57 Self::BinomialMultiNewton => "binomial-multi Newton",
58 Self::MultinomialNewton => "multinomial Newton",
59 Self::MultinomialFirth => "multinomial Firth/Jeffreys Newton",
60 })
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum FixedLambdaStallReason {
67 IterationBudgetExhausted,
68 LineSearchExhausted,
69 StationarityCertificateFailed,
70}
71
72impl core::fmt::Display for FixedLambdaStallReason {
73 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
74 f.write_str(match self {
75 Self::IterationBudgetExhausted => "iteration budget exhausted",
76 Self::LineSearchExhausted => "line search exhausted without an accepted step",
77 Self::StationarityCertificateFailed => "stationarity certificate failed",
78 })
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub enum FixedLambdaResidualKind {
85 PenalizedGradientNorm,
87 NewtonDecrement,
89}
90
91impl core::fmt::Display for FixedLambdaResidualKind {
92 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
93 f.write_str(match self {
94 Self::PenalizedGradientNorm => "penalized gradient norm",
95 Self::NewtonDecrement => "Newton decrement",
96 })
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
102pub struct FixedLambdaStationarityEvidence {
103 pub kind: FixedLambdaResidualKind,
104 pub residual: f64,
105 pub bound: f64,
106}
107
108impl core::fmt::Display for FixedLambdaStationarityEvidence {
109 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110 write!(
111 f,
112 "{} {:.6e} against bound {:.6e}",
113 self.kind, self.residual, self.bound
114 )
115 }
116}
117
118#[derive(Clone, PartialEq, Serialize, Deserialize)]
126pub struct FixedLambdaCheckpoint {
127 stage: FixedLambdaSolverStage,
128 coefficients_row_major: Vec<f64>,
129 rows: usize,
130 cols: usize,
131 completed_iterations: usize,
132}
133
134impl FixedLambdaCheckpoint {
135 pub fn new(
136 stage: FixedLambdaSolverStage,
137 coefficients_row_major: Vec<f64>,
138 rows: usize,
139 cols: usize,
140 completed_iterations: usize,
141 ) -> Result<Self, String> {
142 let checkpoint = Self {
143 stage,
144 coefficients_row_major,
145 rows,
146 cols,
147 completed_iterations,
148 };
149 checkpoint.validate()?;
150 Ok(checkpoint)
151 }
152
153 pub fn validate(&self) -> Result<(), String> {
156 if self.rows == 0 || self.cols == 0 {
157 return Err(format!(
158 "fixed-lambda checkpoint shape must be nonempty, got {}x{}",
159 self.rows, self.cols
160 ));
161 }
162 let expected = self.rows.checked_mul(self.cols).ok_or_else(|| {
163 format!(
164 "fixed-lambda checkpoint shape {}x{} overflows usize",
165 self.rows, self.cols
166 )
167 })?;
168 if self.coefficients_row_major.len() != expected {
169 return Err(format!(
170 "fixed-lambda checkpoint has {} coefficient values, expected {} for shape {}x{}",
171 self.coefficients_row_major.len(),
172 expected,
173 self.rows,
174 self.cols
175 ));
176 }
177 if let Some((index, _)) = self
178 .coefficients_row_major
179 .iter()
180 .copied()
181 .enumerate()
182 .find(|(_, value)| !value.is_finite())
183 {
184 return Err(format!(
185 "fixed-lambda checkpoint coefficient {index} must be finite"
186 ));
187 }
188 Ok(())
189 }
190
191 pub fn stage(&self) -> FixedLambdaSolverStage {
192 self.stage
193 }
194
195 pub fn values(&self) -> &[f64] {
196 &self.coefficients_row_major
197 }
198
199 pub fn rows(&self) -> usize {
200 self.rows
201 }
202
203 pub fn cols(&self) -> usize {
204 self.cols
205 }
206
207 pub fn completed_iterations(&self) -> usize {
208 self.completed_iterations
209 }
210}
211
212impl core::fmt::Display for FixedLambdaCheckpoint {
213 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
214 write!(
215 f,
216 "{} checkpoint {}x{} after {} iteration(s)",
217 self.stage, self.rows, self.cols, self.completed_iterations
218 )
219 }
220}
221
222impl core::fmt::Debug for FixedLambdaCheckpoint {
223 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
224 core::fmt::Display::fmt(self, f)
225 }
226}
227
228#[derive(thiserror::Error)]
230pub enum EstimationError {
231 #[error(transparent)]
232 InvalidStabilization(#[from] crate::InvalidStabilization),
233
234 #[error("Underlying basis function generation failed: {0}")]
235 BasisError(#[from] BasisError),
236
237 #[error("Custom-family fit failed: {0}")]
238 CustomFamily(#[from] CustomFamilyError),
239
240 #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
241 LinearSystemSolveFailed(FaerLinalgError),
242
243 #[error("Eigendecomposition failed: {0}")]
244 EigendecompositionFailed(FaerLinalgError),
245
246 #[error(
247 "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
248 )]
249 PenaltySpectrumNonFinite {
250 context: String,
251 index: usize,
252 value: f64,
253 },
254
255 #[error(
256 "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
257 )]
258 PenaltySpectrumIndefinite {
259 context: String,
260 index: usize,
261 value: f64,
262 tolerance: f64,
263 scale: f64,
264 },
265
266 #[error("Parameter constraint violation: {0}")]
267 ParameterConstraintViolation(String),
268
269 #[error(
270 "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
271 )]
272 PirlsDidNotConverge {
273 max_iterations: usize,
274 last_change: f64,
275 },
276
277 #[error(
278 "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
279 {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
280 only minted from a converged optimization; resume by passing the carried checkpoint \
281 through the fixed-lambda input's `resume_from` field ({checkpoint}).",
282 .checkpoint.completed_iterations()
283 )]
284 FixedLambdaNewtonDidNotConverge {
285 context: String,
288 reason: FixedLambdaStallReason,
290 objective_value: f64,
294 stationarity: FixedLambdaStationarityEvidence,
296 checkpoint: FixedLambdaCheckpoint,
300 },
301
302 #[error(
303 "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
304 max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
305 minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
306 {profile_curvature_roundoff:.3e}; last scale fixed-point step \
307 {last_scale_step:.6e}{}). \
308 A fit is only minted from a converged optimization; resume from the \
309 checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
310 if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
311 )]
312 BlockOrthogonalRemlDidNotConverge {
313 iterations: usize,
315 max_score_residual: f64,
318 score_tol: f64,
320 min_profile_curvature: f64,
323 profile_curvature_roundoff: f64,
326 last_scale_step: f64,
329 cycle_detected: bool,
332 rho_checkpoint: Vec<f64>,
335 },
336
337 #[error(
338 "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
339 {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
340 {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
341 against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
342 partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
343 and rho={rho_checkpoint:?}."
344 )]
345 NegativeBinomialAlternationDidNotConverge {
346 rounds: usize,
348 theta_checkpoint: f64,
350 rho_projected_grad_norm: f64,
352 rho_stationarity_bound: f64,
354 theta_score_residual: f64,
356 theta_stationarity_bound: f64,
358 rho_checkpoint: Vec<f64>,
360 },
361
362 #[error(
363 "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
364 The model cannot converge because a predictor perfectly separates the binary outcomes. \
365 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
366 )]
367 PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
368
369 #[error(
370 "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
371 has a threshold {threshold:.6e} that separates the binary outcomes \
372 (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
373 enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
374 )]
375 PrefitPerfectSeparationDetected {
376 column_index: usize,
377 threshold: f64,
378 positive_above_threshold: bool,
379 },
380
381 #[error(
382 "Pre-fit linear separation detected in the realized binomial inverse-link design: \
383 {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
384 with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
385 The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
386 remove/reparameterize/penalize the separating columns."
387 )]
388 PrefitLinearSeparationDetected {
389 min_signed_margin: f64,
390 num_unpenalized_columns: usize,
391 column_indices: Vec<usize>,
392 },
393
394 #[error(
395 "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
396 unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
397 Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
398 )]
399 PrefitRankDeficientDesignDetected {
400 rank: usize,
401 num_unpenalized_columns: usize,
402 min_eigenvalue: f64,
403 tolerance: f64,
404 column_indices: Vec<usize>,
405 },
406
407 #[error(
408 "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
409 unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
410 exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
411 columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
412 so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
413 penalty/constraint before fitting."
414 )]
415 PrefitNearDegenerateDesignDetected {
416 num_unpenalized_columns: usize,
417 condition_number: f64,
418 min_eigenvalue: f64,
419 max_eigenvalue: f64,
420 tolerance: f64,
421 column_indices: Vec<usize>,
422 },
423
424 #[error(
425 "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
426 The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
427 so the unpenalized softmax MLE is not finite in that direction. \
428 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
429 )]
430 MultinomialSeparationDetected {
431 iteration: usize,
432 max_abs_eta: f64,
433 active_class_index: usize,
434 row_index: usize,
435 },
436
437 #[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
438 HessianNotPositiveDefinite { min_eigenvalue: f64 },
439
440 #[error("REML smoothing optimization failed to converge: {0}")]
441 RemlOptimizationFailed(String),
442
443 #[error("Fatal outer-objective evaluation failure ({context}): {source}")]
444 OuterObjectiveEvaluationFailed {
445 context: String,
446 #[source]
447 source: Box<EstimationError>,
448 },
449
450 #[error(
451 "Outer smoothing-parameter optimization did not certify a stationary optimum \
452 ({context}): {reason} after {iterations} outer iteration(s); final objective \
453 {final_value:.6e}, projected gradient norm {} against stationarity bound \
454 {stationarity_bound:.3e} ({}). A fit is only minted from a converged optimization; \
455 the best iterate is carried as a checkpoint — resume by seeding the outer \
456 search at rho_checkpoint = {rho_checkpoint:?}.",
457 .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}")),
458 .stationarity_bound_rung.map_or_else(
459 || "rung=unrecorded".to_string(),
460 |rung| rung.to_string(),
461 )
462 )]
463 RemlDidNotConverge {
464 context: String,
466 reason: String,
470 iterations: usize,
472 final_value: f64,
474 projected_grad_norm: Option<f64>,
477 stationarity_bound: f64,
480 stationarity_bound_rung: Option<StationarityRung>,
486 rho_checkpoint: Vec<f64>,
490 },
491
492 #[error(
493 "Fit assembly rejected a non-converged optimization state: inner status \
494 {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
495 iteration(s); final objective {final_value:.6e}; stationarity residual \
496 {stationarity_residual:?} against {stationarity_bound:?}, step residual \
497 {step_residual:?} against {step_bound:?}. The best rho checkpoint is \
498 {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
499 API was constructed."
500 )]
501 FitDidNotConverge {
502 inner_status: String,
506 outer_status: String,
508 outer_iterations: usize,
510 final_value: f64,
512 stationarity_residual: Option<f64>,
515 stationarity_bound: Option<f64>,
517 step_residual: Option<f64>,
519 step_bound: Option<f64>,
521 rho_checkpoint: Vec<f64>,
523 resume_token: Option<String>,
526 },
527
528 #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
529 GradientUnavailable {
530 context: &'static str,
531 mode: &'static str,
532 },
533
534 #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
535 LayoutError(String),
536
537 #[error(
538 "Model is over-parameterized: {num_coeffs} coefficients for {num_samples} samples.\n\n\
539 Coefficient Breakdown:\n\
540 - Intercept: {intercept_coeffs}\n\
541 - Binary Main Effects: {binary_main_coeffs}\n\
542 - Primary Smooth Effects: {primary_smooth_coeffs}\n\
543 - Binary×Primary Interactions: {binary_primary_interaction_coeffs}\n\
544 - Auxiliary Main Effects: {aux_main_coeffs}\n\
545 - Auxiliary Interactions: {aux_interaction_coeffs}"
546 )]
547 ModelOverparameterized {
548 num_coeffs: usize,
549 num_samples: usize,
550 intercept_coeffs: usize,
551 binary_main_coeffs: usize,
552 primary_smooth_coeffs: usize,
553 aux_main_coeffs: usize,
554 binary_primary_interaction_coeffs: usize,
555 aux_interaction_coeffs: usize,
556 },
557
558 #[error(
559 "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."
560 )]
561 ModelIsIllConditioned { condition_number: f64 },
562
563 #[error("Invalid input: {0}")]
564 InvalidInput(String),
565
566 #[error(
567 "Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
568 interval [{lower}, {upper}]"
569 )]
570 InverseLinkDomainViolation {
571 link: &'static str,
572 eta: f64,
573 lower: f64,
574 upper: f64,
575 },
576
577 #[error(
578 "PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
579 eta={eta:?} produced {value:?}"
580 )]
581 PirlsRowGeometryUnrepresentable {
582 row: usize,
583 quantity: &'static str,
584 eta: f64,
585 value: f64,
586 },
587
588 #[error(
589 "Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
590 )]
591 ExactTweedieSeriesWorkLimit {
592 row: usize,
593 required_terms_lower_bound: f64,
594 budget: usize,
595 },
596
597 #[error(
598 "Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
599 the supported interval [{lower}, {upper}]"
600 )]
601 LogStrengthDomainViolation {
602 coordinate: usize,
603 value: f64,
604 lower: f64,
605 upper: f64,
606 },
607
608 #[error("monotone root solve: {0}")]
609 MonotoneRoot(#[from] MonotoneRootError),
610
611 #[error("Calibrator training failed: {0}")]
612 CalibratorTrainingFailed(String),
613
614 #[error("Invalid specification: {0}")]
615 InvalidSpecification(String),
616
617 #[error("Prediction error")]
618 PredictionError,
619}
620
621impl core::fmt::Debug for EstimationError {
623 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
624 write!(f, "{}", self)
625 }
626}
627
628impl EstimationError {
629 pub fn fatal_outer_evaluation(
635 context: impl Into<String>,
636 source: EstimationError,
637 ) -> Self {
638 if matches!(
639 &source,
640 EstimationError::OuterObjectiveEvaluationFailed { .. }
641 ) {
642 source
643 } else {
644 EstimationError::OuterObjectiveEvaluationFailed {
645 context: context.into(),
646 source: Box::new(source),
647 }
648 }
649 }
650
651 pub fn is_fatal_outer_evaluation(&self) -> bool {
652 matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
653 }
654
655 pub fn is_inner_solve_retreat(&self) -> bool {
666 matches!(
667 self,
668 EstimationError::ModelIsIllConditioned { .. }
669 | EstimationError::PerfectSeparationDetected { .. }
670 | EstimationError::MultinomialSeparationDetected { .. }
671 | EstimationError::PirlsDidNotConverge { .. }
672 | EstimationError::FixedLambdaNewtonDidNotConverge { .. }
673 )
674 }
675}
676
677impl From<LinalgError> for EstimationError {
678 fn from(error: LinalgError) -> Self {
679 match error {
680 LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
681 LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
682 EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
683 }
684 LinalgError::ModelIsIllConditioned { condition_number } => {
685 EstimationError::ModelIsIllConditioned { condition_number }
686 }
687 }
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn reml_refusal(rung: Option<StationarityRung>) -> EstimationError {
698 EstimationError::RemlDidNotConverge {
699 context: "unit".to_string(),
700 reason: "budget exhausted".to_string(),
701 iterations: 7,
702 final_value: -1.25,
703 projected_grad_norm: Some(7.5e-1),
704 stationarity_bound: 1.0e-2,
705 stationarity_bound_rung: rung,
706 rho_checkpoint: vec![0.5],
707 }
708 }
709
710 #[test]
713 fn refusal_message_carries_the_rung_and_whether_it_is_derived() {
714 let derived = reml_refusal(Some(StationarityRung {
715 label: "curvature-resolvability",
716 derived_standard: true,
717 }))
718 .to_string();
719 assert!(
720 derived.contains("rung=curvature-resolvability"),
721 "refusal must name its rung: {derived}"
722 );
723 assert!(
724 derived.contains("derived_standard=true"),
725 "refusal must say whether the rung is the derived standard: {derived}"
726 );
727
728 let substitute = reml_refusal(Some(StationarityRung {
729 label: "solver-band",
730 derived_standard: false,
731 }))
732 .to_string();
733 assert!(substitute.contains("rung=solver-band"), "{substitute}");
734 assert!(
735 substitute.contains("derived_standard=false"),
736 "a gradient-magnitude substitute must not read as the derived standard: {substitute}"
737 );
738 }
739
740 #[test]
744 fn unrecorded_rung_is_stated_not_defaulted() {
745 let message = reml_refusal(None).to_string();
746 assert!(
747 message.contains("rung=unrecorded"),
748 "an unclassified bound must say so: {message}"
749 );
750 assert!(
751 !message.contains("derived_standard=true"),
752 "an unrecorded rung must never read as the derived standard: {message}"
753 );
754 }
755
756 #[test]
759 fn rung_rides_beside_the_bound_without_displacing_it() {
760 let message = reml_refusal(None).to_string();
761 assert!(message.contains("1.000e-2"), "bound must survive: {message}");
762 assert!(
763 message.contains("7.500e-1"),
764 "projected gradient norm must survive: {message}"
765 );
766 }
767
768 #[test]
771 fn model_ill_conditioned_is_retreat() {
772 assert!(
773 EstimationError::ModelIsIllConditioned {
774 condition_number: 1e15
775 }
776 .is_inner_solve_retreat()
777 );
778 }
779
780 #[test]
781 fn perfect_separation_is_retreat() {
782 assert!(
783 EstimationError::PerfectSeparationDetected {
784 iteration: 3,
785 max_abs_eta: 50.0
786 }
787 .is_inner_solve_retreat()
788 );
789 }
790
791 #[test]
792 fn multinomial_separation_is_retreat() {
793 assert!(
794 EstimationError::MultinomialSeparationDetected {
795 iteration: 1,
796 max_abs_eta: 100.0,
797 active_class_index: 2,
798 row_index: 7
799 }
800 .is_inner_solve_retreat()
801 );
802 }
803
804 #[test]
805 fn pirls_did_not_converge_is_retreat() {
806 assert!(
807 EstimationError::PirlsDidNotConverge {
808 max_iterations: 100,
809 last_change: 1e-3
810 }
811 .is_inner_solve_retreat()
812 );
813 }
814
815 #[test]
816 fn invalid_input_is_not_retreat() {
817 assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
818 }
819
820 #[test]
821 fn reml_optimization_failed_is_not_retreat() {
822 assert!(
823 !EstimationError::RemlOptimizationFailed("outer fail".to_string())
824 .is_inner_solve_retreat()
825 );
826 }
827
828 #[test]
829 fn fatal_outer_evaluation_is_typed_and_idempotent() {
830 let error = EstimationError::fatal_outer_evaluation(
831 "seed screening",
832 EstimationError::InvalidInput("frame mismatch".to_string()),
833 );
834 assert!(error.is_fatal_outer_evaluation());
835 assert!(error.to_string().contains("frame mismatch"));
836
837 let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
838 assert!(nested.is_fatal_outer_evaluation());
839 assert_eq!(
840 nested.to_string().matches("Fatal outer-objective").count(),
841 1,
842 "fatal provenance must not be re-wrapped at every orchestration layer"
843 );
844 }
845
846 #[test]
849 fn invalid_input_message_appears_in_display() {
850 let err = EstimationError::InvalidInput("test_message".to_string());
851 assert!(err.to_string().contains("test_message"));
852 }
853
854 #[test]
855 fn pirls_did_not_converge_mentions_max_iterations() {
856 let err = EstimationError::PirlsDidNotConverge {
857 max_iterations: 42,
858 last_change: 0.001,
859 };
860 assert!(err.to_string().contains("42"));
861 }
862
863 #[test]
864 fn fixed_lambda_checkpoint_validates_shape_and_values() {
865 let checkpoint = FixedLambdaCheckpoint::new(
866 FixedLambdaSolverStage::MultinomialNewton,
867 vec![1.0, 2.0, 3.0, 4.0],
868 2,
869 2,
870 7,
871 )
872 .expect("well-shaped finite checkpoint");
873 assert_eq!(
874 checkpoint.stage(),
875 FixedLambdaSolverStage::MultinomialNewton
876 );
877 assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
878 assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
879 assert_eq!(checkpoint.completed_iterations(), 7);
880
881 assert!(
882 FixedLambdaCheckpoint::new(
883 FixedLambdaSolverStage::BinomialMultiNewton,
884 vec![1.0],
885 2,
886 1,
887 0,
888 )
889 .is_err(),
890 "coefficient length must match rows * cols"
891 );
892 assert!(
893 FixedLambdaCheckpoint::new(
894 FixedLambdaSolverStage::BinomialMultiNewton,
895 vec![f64::NAN],
896 1,
897 1,
898 0,
899 )
900 .is_err(),
901 "checkpoint coefficients must be finite"
902 );
903 assert!(
904 FixedLambdaCheckpoint::new(
905 FixedLambdaSolverStage::MultinomialFirth,
906 Vec::new(),
907 usize::MAX,
908 2,
909 0,
910 )
911 .is_err(),
912 "checkpoint shape multiplication must not overflow"
913 );
914 }
915
916 #[test]
917 fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
918 let checkpoint = FixedLambdaCheckpoint::new(
919 FixedLambdaSolverStage::MultinomialFirth,
920 vec![12_345.678_9, -98_765.432_1],
921 2,
922 1,
923 11,
924 )
925 .expect("valid checkpoint");
926 let checkpoint_debug = format!("{checkpoint:?}");
927 assert!(!checkpoint_debug.contains("12345.6789"));
928 assert!(!checkpoint_debug.contains("98765.4321"));
929 let err = EstimationError::FixedLambdaNewtonDidNotConverge {
930 context: "test Firth solve".to_string(),
931 reason: FixedLambdaStallReason::LineSearchExhausted,
932 objective_value: 3.25,
933 stationarity: FixedLambdaStationarityEvidence {
934 kind: FixedLambdaResidualKind::NewtonDecrement,
935 residual: 0.125,
936 bound: 1.0e-7,
937 },
938 checkpoint,
939 };
940
941 let display = err.to_string();
942 assert!(display.contains("test Firth solve"));
943 assert!(display.contains("line search exhausted"));
944 assert!(display.contains("Newton decrement"));
945 assert!(display.contains("2x1"));
946 assert!(display.contains("11 iteration"));
947 assert!(!display.contains("12345.6789"));
948 assert!(!display.contains("98765.4321"));
949 assert_eq!(
950 format!("{err:?}"),
951 display,
952 "Debug delegates to safe Display"
953 );
954 assert!(err.is_inner_solve_retreat());
955 }
956
957 #[test]
960 fn from_linalg_invalid_input_maps_to_invalid_input() {
961 let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
962 let err = EstimationError::from(linalg_err);
963 assert!(matches!(err, EstimationError::InvalidInput(_)));
964 assert!(err.to_string().contains("linalg msg"));
965 }
966
967 #[test]
968 fn from_linalg_hessian_not_spd_maps_correctly() {
969 let linalg_err = LinalgError::HessianNotPositiveDefinite {
970 min_eigenvalue: -1.0,
971 };
972 let err = EstimationError::from(linalg_err);
973 assert!(matches!(
974 err,
975 EstimationError::HessianNotPositiveDefinite { .. }
976 ));
977 }
978}
979
980fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
989 if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
990 format!(
991 "Hessian factorization failed although the (lower-triangle) spectrum is positive \
992 (minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
993 precision or the assembled matrix is asymmetric/non-finite outside the factored \
994 triangle. This indicates a numerical instability in the Hessian assembly or scaling."
995 )
996 } else {
997 format!(
998 "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
999 This indicates a numerical instability."
1000 )
1001 }
1002}