1use gam_linalg::LinalgError;
2use gam_linalg::faer_ndarray::FaerLinalgError;
3use serde::{Deserialize, Serialize};
4
5use crate::{BasisError, CustomFamilyError, MonotoneRootError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum FixedLambdaSolverStage {
15 BinomialMultiNewton,
16 MultinomialNewton,
17 MultinomialFirth,
18}
19
20impl core::fmt::Display for FixedLambdaSolverStage {
21 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
22 f.write_str(match self {
23 Self::BinomialMultiNewton => "binomial-multi Newton",
24 Self::MultinomialNewton => "multinomial Newton",
25 Self::MultinomialFirth => "multinomial Firth/Jeffreys Newton",
26 })
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum FixedLambdaStallReason {
33 IterationBudgetExhausted,
34 LineSearchExhausted,
35 StationarityCertificateFailed,
36}
37
38impl core::fmt::Display for FixedLambdaStallReason {
39 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
40 f.write_str(match self {
41 Self::IterationBudgetExhausted => "iteration budget exhausted",
42 Self::LineSearchExhausted => "line search exhausted without an accepted step",
43 Self::StationarityCertificateFailed => "stationarity certificate failed",
44 })
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum FixedLambdaResidualKind {
51 PenalizedGradientNorm,
53 NewtonDecrement,
55}
56
57impl core::fmt::Display for FixedLambdaResidualKind {
58 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
59 f.write_str(match self {
60 Self::PenalizedGradientNorm => "penalized gradient norm",
61 Self::NewtonDecrement => "Newton decrement",
62 })
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
68pub struct FixedLambdaStationarityEvidence {
69 pub kind: FixedLambdaResidualKind,
70 pub residual: f64,
71 pub bound: f64,
72}
73
74impl core::fmt::Display for FixedLambdaStationarityEvidence {
75 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
76 write!(
77 f,
78 "{} {:.6e} against bound {:.6e}",
79 self.kind, self.residual, self.bound
80 )
81 }
82}
83
84#[derive(Clone, PartialEq, Serialize, Deserialize)]
92pub struct FixedLambdaCheckpoint {
93 stage: FixedLambdaSolverStage,
94 coefficients_row_major: Vec<f64>,
95 rows: usize,
96 cols: usize,
97 completed_iterations: usize,
98}
99
100impl FixedLambdaCheckpoint {
101 pub fn new(
102 stage: FixedLambdaSolverStage,
103 coefficients_row_major: Vec<f64>,
104 rows: usize,
105 cols: usize,
106 completed_iterations: usize,
107 ) -> Result<Self, String> {
108 let checkpoint = Self {
109 stage,
110 coefficients_row_major,
111 rows,
112 cols,
113 completed_iterations,
114 };
115 checkpoint.validate()?;
116 Ok(checkpoint)
117 }
118
119 pub fn validate(&self) -> Result<(), String> {
122 if self.rows == 0 || self.cols == 0 {
123 return Err(format!(
124 "fixed-lambda checkpoint shape must be nonempty, got {}x{}",
125 self.rows, self.cols
126 ));
127 }
128 let expected = self.rows.checked_mul(self.cols).ok_or_else(|| {
129 format!(
130 "fixed-lambda checkpoint shape {}x{} overflows usize",
131 self.rows, self.cols
132 )
133 })?;
134 if self.coefficients_row_major.len() != expected {
135 return Err(format!(
136 "fixed-lambda checkpoint has {} coefficient values, expected {} for shape {}x{}",
137 self.coefficients_row_major.len(),
138 expected,
139 self.rows,
140 self.cols
141 ));
142 }
143 if let Some((index, _)) = self
144 .coefficients_row_major
145 .iter()
146 .copied()
147 .enumerate()
148 .find(|(_, value)| !value.is_finite())
149 {
150 return Err(format!(
151 "fixed-lambda checkpoint coefficient {index} must be finite"
152 ));
153 }
154 Ok(())
155 }
156
157 pub fn stage(&self) -> FixedLambdaSolverStage {
158 self.stage
159 }
160
161 pub fn values(&self) -> &[f64] {
162 &self.coefficients_row_major
163 }
164
165 pub fn rows(&self) -> usize {
166 self.rows
167 }
168
169 pub fn cols(&self) -> usize {
170 self.cols
171 }
172
173 pub fn completed_iterations(&self) -> usize {
174 self.completed_iterations
175 }
176}
177
178impl core::fmt::Display for FixedLambdaCheckpoint {
179 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
180 write!(
181 f,
182 "{} checkpoint {}x{} after {} iteration(s)",
183 self.stage, self.rows, self.cols, self.completed_iterations
184 )
185 }
186}
187
188impl core::fmt::Debug for FixedLambdaCheckpoint {
189 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
190 core::fmt::Display::fmt(self, f)
191 }
192}
193
194#[derive(thiserror::Error)]
196pub enum EstimationError {
197 #[error(transparent)]
198 InvalidStabilization(#[from] crate::InvalidStabilization),
199
200 #[error("Underlying basis function generation failed: {0}")]
201 BasisError(#[from] BasisError),
202
203 #[error("Custom-family fit failed: {0}")]
204 CustomFamily(#[from] CustomFamilyError),
205
206 #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
207 LinearSystemSolveFailed(FaerLinalgError),
208
209 #[error("Eigendecomposition failed: {0}")]
210 EigendecompositionFailed(FaerLinalgError),
211
212 #[error(
213 "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
214 )]
215 PenaltySpectrumNonFinite {
216 context: String,
217 index: usize,
218 value: f64,
219 },
220
221 #[error(
222 "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
223 )]
224 PenaltySpectrumIndefinite {
225 context: String,
226 index: usize,
227 value: f64,
228 tolerance: f64,
229 scale: f64,
230 },
231
232 #[error("Parameter constraint violation: {0}")]
233 ParameterConstraintViolation(String),
234
235 #[error(
236 "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
237 )]
238 PirlsDidNotConverge {
239 max_iterations: usize,
240 last_change: f64,
241 },
242
243 #[error(
244 "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
245 {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
246 only minted from a converged optimization; resume by passing the carried checkpoint \
247 through the fixed-lambda input's `resume_from` field ({checkpoint}).",
248 .checkpoint.completed_iterations()
249 )]
250 FixedLambdaNewtonDidNotConverge {
251 context: String,
254 reason: FixedLambdaStallReason,
256 objective_value: f64,
260 stationarity: FixedLambdaStationarityEvidence,
262 checkpoint: FixedLambdaCheckpoint,
266 },
267
268 #[error(
269 "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
270 max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
271 minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
272 {profile_curvature_roundoff:.3e}; last scale fixed-point step \
273 {last_scale_step:.6e}{}). \
274 A fit is only minted from a converged optimization; resume from the \
275 checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
276 if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
277 )]
278 BlockOrthogonalRemlDidNotConverge {
279 iterations: usize,
281 max_score_residual: f64,
284 score_tol: f64,
286 min_profile_curvature: f64,
289 profile_curvature_roundoff: f64,
292 last_scale_step: f64,
295 cycle_detected: bool,
298 rho_checkpoint: Vec<f64>,
301 },
302
303 #[error(
304 "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
305 {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
306 {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
307 against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
308 partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
309 and rho={rho_checkpoint:?}."
310 )]
311 NegativeBinomialAlternationDidNotConverge {
312 rounds: usize,
314 theta_checkpoint: f64,
316 rho_projected_grad_norm: f64,
318 rho_stationarity_bound: f64,
320 theta_score_residual: f64,
322 theta_stationarity_bound: f64,
324 rho_checkpoint: Vec<f64>,
326 },
327
328 #[error(
329 "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
330 The model cannot converge because a predictor perfectly separates the binary outcomes. \
331 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
332 )]
333 PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
334
335 #[error(
336 "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
337 has a threshold {threshold:.6e} that separates the binary outcomes \
338 (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
339 enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
340 )]
341 PrefitPerfectSeparationDetected {
342 column_index: usize,
343 threshold: f64,
344 positive_above_threshold: bool,
345 },
346
347 #[error(
348 "Pre-fit linear separation detected in the realized binomial inverse-link design: \
349 {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
350 with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
351 The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
352 remove/reparameterize/penalize the separating columns."
353 )]
354 PrefitLinearSeparationDetected {
355 min_signed_margin: f64,
356 num_unpenalized_columns: usize,
357 column_indices: Vec<usize>,
358 },
359
360 #[error(
361 "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
362 unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
363 Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
364 )]
365 PrefitRankDeficientDesignDetected {
366 rank: usize,
367 num_unpenalized_columns: usize,
368 min_eigenvalue: f64,
369 tolerance: f64,
370 column_indices: Vec<usize>,
371 },
372
373 #[error(
374 "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
375 unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
376 exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
377 columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
378 so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
379 penalty/constraint before fitting."
380 )]
381 PrefitNearDegenerateDesignDetected {
382 num_unpenalized_columns: usize,
383 condition_number: f64,
384 min_eigenvalue: f64,
385 max_eigenvalue: f64,
386 tolerance: f64,
387 column_indices: Vec<usize>,
388 },
389
390 #[error(
391 "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
392 The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
393 so the unpenalized softmax MLE is not finite in that direction. \
394 (Diagnostic: max|eta| = {max_abs_eta:.2e})."
395 )]
396 MultinomialSeparationDetected {
397 iteration: usize,
398 max_abs_eta: f64,
399 active_class_index: usize,
400 row_index: usize,
401 },
402
403 #[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
404 HessianNotPositiveDefinite { min_eigenvalue: f64 },
405
406 #[error("REML smoothing optimization failed to converge: {0}")]
407 RemlOptimizationFailed(String),
408
409 #[error("Fatal outer-objective evaluation failure ({context}): {source}")]
410 OuterObjectiveEvaluationFailed {
411 context: String,
412 #[source]
413 source: Box<EstimationError>,
414 },
415
416 #[error(
417 "Outer smoothing-parameter optimization did not certify a stationary optimum \
418 ({context}): {reason} after {iterations} outer iteration(s); final objective \
419 {final_value:.6e}, projected gradient norm {} against stationarity bound \
420 {stationarity_bound:.3e}. A fit is only minted from a converged optimization; \
421 the best iterate is carried as a checkpoint — resume by seeding the outer \
422 search at rho_checkpoint = {rho_checkpoint:?}.",
423 .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}"))
424 )]
425 RemlDidNotConverge {
426 context: String,
428 reason: String,
432 iterations: usize,
434 final_value: f64,
436 projected_grad_norm: Option<f64>,
439 stationarity_bound: f64,
442 rho_checkpoint: Vec<f64>,
446 },
447
448 #[error(
449 "Fit assembly rejected a non-converged optimization state: inner status \
450 {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
451 iteration(s); final objective {final_value:.6e}; stationarity residual \
452 {stationarity_residual:?} against {stationarity_bound:?}, step residual \
453 {step_residual:?} against {step_bound:?}. The best rho checkpoint is \
454 {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
455 API was constructed."
456 )]
457 FitDidNotConverge {
458 inner_status: String,
462 outer_status: String,
464 outer_iterations: usize,
466 final_value: f64,
468 stationarity_residual: Option<f64>,
471 stationarity_bound: Option<f64>,
473 step_residual: Option<f64>,
475 step_bound: Option<f64>,
477 rho_checkpoint: Vec<f64>,
479 resume_token: Option<String>,
482 },
483
484 #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
485 GradientUnavailable {
486 context: &'static str,
487 mode: &'static str,
488 },
489
490 #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
491 LayoutError(String),
492
493 #[error(
494 "Model is over-parameterized: {num_coeffs} coefficients for {num_samples} samples.\n\n\
495 Coefficient Breakdown:\n\
496 - Intercept: {intercept_coeffs}\n\
497 - Binary Main Effects: {binary_main_coeffs}\n\
498 - Primary Smooth Effects: {primary_smooth_coeffs}\n\
499 - Binary×Primary Interactions: {binary_primary_interaction_coeffs}\n\
500 - Auxiliary Main Effects: {aux_main_coeffs}\n\
501 - Auxiliary Interactions: {aux_interaction_coeffs}"
502 )]
503 ModelOverparameterized {
504 num_coeffs: usize,
505 num_samples: usize,
506 intercept_coeffs: usize,
507 binary_main_coeffs: usize,
508 primary_smooth_coeffs: usize,
509 aux_main_coeffs: usize,
510 binary_primary_interaction_coeffs: usize,
511 aux_interaction_coeffs: usize,
512 },
513
514 #[error(
515 "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."
516 )]
517 ModelIsIllConditioned { condition_number: f64 },
518
519 #[error("Invalid input: {0}")]
520 InvalidInput(String),
521
522 #[error(
523 "Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
524 interval [{lower}, {upper}]"
525 )]
526 InverseLinkDomainViolation {
527 link: &'static str,
528 eta: f64,
529 lower: f64,
530 upper: f64,
531 },
532
533 #[error(
534 "PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
535 eta={eta:?} produced {value:?}"
536 )]
537 PirlsRowGeometryUnrepresentable {
538 row: usize,
539 quantity: &'static str,
540 eta: f64,
541 value: f64,
542 },
543
544 #[error(
545 "Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
546 )]
547 ExactTweedieSeriesWorkLimit {
548 row: usize,
549 required_terms_lower_bound: f64,
550 budget: usize,
551 },
552
553 #[error(
554 "Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
555 the supported interval [{lower}, {upper}]"
556 )]
557 LogStrengthDomainViolation {
558 coordinate: usize,
559 value: f64,
560 lower: f64,
561 upper: f64,
562 },
563
564 #[error("monotone root solve: {0}")]
565 MonotoneRoot(#[from] MonotoneRootError),
566
567 #[error("Calibrator training failed: {0}")]
568 CalibratorTrainingFailed(String),
569
570 #[error("Invalid specification: {0}")]
571 InvalidSpecification(String),
572
573 #[error("Prediction error")]
574 PredictionError,
575}
576
577impl core::fmt::Debug for EstimationError {
579 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
580 write!(f, "{}", self)
581 }
582}
583
584impl EstimationError {
585 pub fn fatal_outer_evaluation(
591 context: impl Into<String>,
592 source: EstimationError,
593 ) -> Self {
594 if matches!(
595 &source,
596 EstimationError::OuterObjectiveEvaluationFailed { .. }
597 ) {
598 source
599 } else {
600 EstimationError::OuterObjectiveEvaluationFailed {
601 context: context.into(),
602 source: Box::new(source),
603 }
604 }
605 }
606
607 pub fn is_fatal_outer_evaluation(&self) -> bool {
608 matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
609 }
610
611 pub fn is_inner_solve_retreat(&self) -> bool {
622 matches!(
623 self,
624 EstimationError::ModelIsIllConditioned { .. }
625 | EstimationError::PerfectSeparationDetected { .. }
626 | EstimationError::MultinomialSeparationDetected { .. }
627 | EstimationError::PirlsDidNotConverge { .. }
628 | EstimationError::FixedLambdaNewtonDidNotConverge { .. }
629 )
630 }
631}
632
633impl From<LinalgError> for EstimationError {
634 fn from(error: LinalgError) -> Self {
635 match error {
636 LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
637 LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
638 EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
639 }
640 LinalgError::ModelIsIllConditioned { condition_number } => {
641 EstimationError::ModelIsIllConditioned { condition_number }
642 }
643 }
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
654 fn model_ill_conditioned_is_retreat() {
655 assert!(
656 EstimationError::ModelIsIllConditioned {
657 condition_number: 1e15
658 }
659 .is_inner_solve_retreat()
660 );
661 }
662
663 #[test]
664 fn perfect_separation_is_retreat() {
665 assert!(
666 EstimationError::PerfectSeparationDetected {
667 iteration: 3,
668 max_abs_eta: 50.0
669 }
670 .is_inner_solve_retreat()
671 );
672 }
673
674 #[test]
675 fn multinomial_separation_is_retreat() {
676 assert!(
677 EstimationError::MultinomialSeparationDetected {
678 iteration: 1,
679 max_abs_eta: 100.0,
680 active_class_index: 2,
681 row_index: 7
682 }
683 .is_inner_solve_retreat()
684 );
685 }
686
687 #[test]
688 fn pirls_did_not_converge_is_retreat() {
689 assert!(
690 EstimationError::PirlsDidNotConverge {
691 max_iterations: 100,
692 last_change: 1e-3
693 }
694 .is_inner_solve_retreat()
695 );
696 }
697
698 #[test]
699 fn invalid_input_is_not_retreat() {
700 assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
701 }
702
703 #[test]
704 fn reml_optimization_failed_is_not_retreat() {
705 assert!(
706 !EstimationError::RemlOptimizationFailed("outer fail".to_string())
707 .is_inner_solve_retreat()
708 );
709 }
710
711 #[test]
712 fn fatal_outer_evaluation_is_typed_and_idempotent() {
713 let error = EstimationError::fatal_outer_evaluation(
714 "seed screening",
715 EstimationError::InvalidInput("frame mismatch".to_string()),
716 );
717 assert!(error.is_fatal_outer_evaluation());
718 assert!(error.to_string().contains("frame mismatch"));
719
720 let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
721 assert!(nested.is_fatal_outer_evaluation());
722 assert_eq!(
723 nested.to_string().matches("Fatal outer-objective").count(),
724 1,
725 "fatal provenance must not be re-wrapped at every orchestration layer"
726 );
727 }
728
729 #[test]
732 fn invalid_input_message_appears_in_display() {
733 let err = EstimationError::InvalidInput("test_message".to_string());
734 assert!(err.to_string().contains("test_message"));
735 }
736
737 #[test]
738 fn pirls_did_not_converge_mentions_max_iterations() {
739 let err = EstimationError::PirlsDidNotConverge {
740 max_iterations: 42,
741 last_change: 0.001,
742 };
743 assert!(err.to_string().contains("42"));
744 }
745
746 #[test]
747 fn fixed_lambda_checkpoint_validates_shape_and_values() {
748 let checkpoint = FixedLambdaCheckpoint::new(
749 FixedLambdaSolverStage::MultinomialNewton,
750 vec![1.0, 2.0, 3.0, 4.0],
751 2,
752 2,
753 7,
754 )
755 .expect("well-shaped finite checkpoint");
756 assert_eq!(
757 checkpoint.stage(),
758 FixedLambdaSolverStage::MultinomialNewton
759 );
760 assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
761 assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
762 assert_eq!(checkpoint.completed_iterations(), 7);
763
764 assert!(
765 FixedLambdaCheckpoint::new(
766 FixedLambdaSolverStage::BinomialMultiNewton,
767 vec![1.0],
768 2,
769 1,
770 0,
771 )
772 .is_err(),
773 "coefficient length must match rows * cols"
774 );
775 assert!(
776 FixedLambdaCheckpoint::new(
777 FixedLambdaSolverStage::BinomialMultiNewton,
778 vec![f64::NAN],
779 1,
780 1,
781 0,
782 )
783 .is_err(),
784 "checkpoint coefficients must be finite"
785 );
786 assert!(
787 FixedLambdaCheckpoint::new(
788 FixedLambdaSolverStage::MultinomialFirth,
789 Vec::new(),
790 usize::MAX,
791 2,
792 0,
793 )
794 .is_err(),
795 "checkpoint shape multiplication must not overflow"
796 );
797 }
798
799 #[test]
800 fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
801 let checkpoint = FixedLambdaCheckpoint::new(
802 FixedLambdaSolverStage::MultinomialFirth,
803 vec![12_345.678_9, -98_765.432_1],
804 2,
805 1,
806 11,
807 )
808 .expect("valid checkpoint");
809 let checkpoint_debug = format!("{checkpoint:?}");
810 assert!(!checkpoint_debug.contains("12345.6789"));
811 assert!(!checkpoint_debug.contains("98765.4321"));
812 let err = EstimationError::FixedLambdaNewtonDidNotConverge {
813 context: "test Firth solve".to_string(),
814 reason: FixedLambdaStallReason::LineSearchExhausted,
815 objective_value: 3.25,
816 stationarity: FixedLambdaStationarityEvidence {
817 kind: FixedLambdaResidualKind::NewtonDecrement,
818 residual: 0.125,
819 bound: 1.0e-7,
820 },
821 checkpoint,
822 };
823
824 let display = err.to_string();
825 assert!(display.contains("test Firth solve"));
826 assert!(display.contains("line search exhausted"));
827 assert!(display.contains("Newton decrement"));
828 assert!(display.contains("2x1"));
829 assert!(display.contains("11 iteration"));
830 assert!(!display.contains("12345.6789"));
831 assert!(!display.contains("98765.4321"));
832 assert_eq!(
833 format!("{err:?}"),
834 display,
835 "Debug delegates to safe Display"
836 );
837 assert!(err.is_inner_solve_retreat());
838 }
839
840 #[test]
843 fn from_linalg_invalid_input_maps_to_invalid_input() {
844 let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
845 let err = EstimationError::from(linalg_err);
846 assert!(matches!(err, EstimationError::InvalidInput(_)));
847 assert!(err.to_string().contains("linalg msg"));
848 }
849
850 #[test]
851 fn from_linalg_hessian_not_spd_maps_correctly() {
852 let linalg_err = LinalgError::HessianNotPositiveDefinite {
853 min_eigenvalue: -1.0,
854 };
855 let err = EstimationError::from(linalg_err);
856 assert!(matches!(
857 err,
858 EstimationError::HessianNotPositiveDefinite { .. }
859 ));
860 }
861}
862
863fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
872 if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
873 format!(
874 "Hessian factorization failed although the (lower-triangle) spectrum is positive \
875 (minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
876 precision or the assembled matrix is asymmetric/non-finite outside the factored \
877 triangle. This indicates a numerical instability in the Hessian assembly or scaling."
878 )
879 } else {
880 format!(
881 "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
882 This indicates a numerical instability."
883 )
884 }
885}