Skip to main content

gam_problem/
estimation_error.rs

1use gam_linalg::LinalgError;
2use gam_linalg::faer_ndarray::FaerLinalgError;
3use serde::{Deserialize, Serialize};
4
5use crate::{BasisError, CustomFamilyError, MonotoneRootError};
6
7/// Fixed-lambda solver stage that owns a resumable coefficient checkpoint.
8///
9/// The multinomial fitter has two distinct objectives: the ordinary softmax
10/// likelihood and the Firth/Jeffreys separation refit. Recording the stage is
11/// therefore part of correctness: a Firth checkpoint must resume the Firth
12/// objective rather than being mistaken for an ordinary multinomial start.
13#[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/// Exhaustive terminal reason for a fixed-lambda solve without a certificate.
31#[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/// Solver-native first-order residual carried by a fixed-lambda stall.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum FixedLambdaResidualKind {
51    /// Euclidean norm of the exact penalized likelihood gradient.
52    PenalizedGradientNorm,
53    /// Firth/Jeffreys Newton decrement `0.5 * |score' H^-1 score|`.
54    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/// Evidence from the exact stationarity check at the last accepted iterate.
67#[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/// Owned, serde-safe coefficient checkpoint for a fixed-lambda Newton solve.
85///
86/// Coefficients are row-major with shape `(rows, cols)`, where rows are the
87/// per-output coefficient count and columns are active outputs/classes. The
88/// values deliberately remain private so diagnostics cannot accidentally dump
89/// a potentially large coefficient vector; resume code accesses them through
90/// [`Self::values`] after calling [`Self::validate`].
91#[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    /// Validate persisted checkpoint geometry and coefficient finiteness before
120    /// rebuilding an ndarray view in a resumed solver.
121    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/// A comprehensive error type for the model estimation process.
195#[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        /// Which fixed-λ Newton entry stalled (e.g. the multinomial softmax or
252        /// independent-binomial vector-GLM solve, or the Firth refit lane).
253        context: String,
254        /// Why the solver stopped without its convergence certificate.
255        reason: FixedLambdaStallReason,
256        /// Final value of the solver's minimized criterion. For ordinary vector
257        /// GLMs this is `-log L + penalty`; for the Firth lane it also includes
258        /// the negative Jeffreys `0.5 log det(I)` contribution.
259        objective_value: f64,
260        /// Exact first-order residual and the bound it failed to clear.
261        stationarity: FixedLambdaStationarityEvidence,
262        /// Last accepted coefficients and cumulative iteration count. This is
263        /// work-preservation state, not a fitted model, and carries no covariance
264        /// or prediction surface.
265        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        /// Outer alternation passes executed before exhaustion.
280        iterations: usize,
281        /// Largest per-block |dV/drho| at the final iterate, normalized by the
282        /// score's natural magnitude `d * max(1, rank)`.
283        max_score_residual: f64,
284        /// Tolerance the residual had to meet for the convergence certificate.
285        score_tol: f64,
286        /// Smallest eigenvalue of the analytic rho Hessian after profiling out
287        /// the exact conditional scale block.
288        min_profile_curvature: f64,
289        /// Dimension-scaled eigensolver roundoff allowed below zero when
290        /// certifying positive semidefiniteness.
291        profile_curvature_roundoff: f64,
292        /// Last max |Δ log scale-precision| fixed-point movement (evidence of
293        /// whether the alternation was still moving or had stalled).
294        last_scale_step: f64,
295        /// The alternation revisited an earlier `(rho, scale)` state exactly;
296        /// as a deterministic map it can never certify, so it stopped early.
297        cycle_detected: bool,
298        /// Per-block log-lambda iterates at exhaustion; feed back through the
299        /// entry point's `init_rhos` to resume rather than restart.
300        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        /// Joint block-coordinate rounds executed before exhaustion.
313        rounds: usize,
314        /// Conditional theta coordinate at the best measured checkpoint.
315        theta_checkpoint: f64,
316        /// KKT-projected rho-gradient norm at that checkpoint.
317        rho_projected_grad_norm: f64,
318        /// Bound the rho residual had to clear.
319        rho_stationarity_bound: f64,
320        /// Curvature-normalized log-theta score residual at that checkpoint.
321        theta_score_residual: f64,
322        /// Bound the theta residual had to clear.
323        theta_stationarity_bound: f64,
324        /// Best measured log-smoothing checkpoint for warm-started resume.
325        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        /// Fit context label (the same string the outer runner logs under).
427        context: String,
428        /// Which certificate failed: budget exhaustion, line-search collapse,
429        /// non-stationary cost stall, or a failed post-solve stationarity
430        /// certificate.
431        reason: String,
432        /// Outer iterations executed across all solver restarts.
433        iterations: usize,
434        /// Objective value at the abandoned best iterate.
435        final_value: f64,
436        /// KKT-projected gradient norm at the best iterate, when the solver
437        /// measured a gradient there (`None` for gradient-free exits).
438        projected_grad_norm: Option<f64>,
439        /// Bound the projected gradient had to clear for the stationarity
440        /// certificate.
441        stationarity_bound: f64,
442        /// Best (lowest-objective feasible) outer iterate at exhaustion. This
443        /// is work-preservation evidence for resume — it is NOT a fit and no
444        /// fitted-model API is reachable from it.
445        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        /// Diagnostic inner-solver terminal status. This is deliberately a
459        /// string at the neutral problem layer; concrete solver status enums
460        /// live in downstream fitting crates.
461        inner_status: String,
462        /// Outer terminal/certificate verdict.
463        outer_status: String,
464        /// Completed outer iterations at the rejected checkpoint.
465        outer_iterations: usize,
466        /// Objective value at the best available checkpoint.
467        final_value: f64,
468        /// Exact analytic first-order gradient or root-equivalent fixed-point
469        /// residual, when it was measured.
470        stationarity_residual: Option<f64>,
471        /// Bound the first-order residual had to clear.
472        stationarity_bound: Option<f64>,
473        /// Final accepted-step residual, when the solver exported it.
474        step_residual: Option<f64>,
475        /// Bound the step residual had to clear.
476        step_bound: Option<f64>,
477        /// Work-preserving smoothing checkpoint; this is not a fit.
478        rho_checkpoint: Vec<f64>,
479        /// Opaque durable-cache resume token, when checkpoint persistence was
480        /// enabled for the failed run.
481        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
577// Ensure Debug prints with actual line breaks by delegating to Display
578impl 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    /// Preserve a thrown outer-objective failure across seed, solver, and
586    /// fallback-plan orchestration. Trial-domain refusals must be represented
587    /// as a finite API outcome (`+inf` / `OuterEval::infeasible`); an `Err`
588    /// means the evaluation artifact itself could not be constructed and must
589    /// never be retried as another numerical point.
590    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    /// Classifies inner-solve failures that the outer REML loop should
612    /// treat as a soft retreat (return +inf cost / infeasible outer-eval)
613    /// rather than propagate as a hard error.
614    ///
615    /// Why: when the penalised Hessian becomes effectively singular at the
616    /// current rho, when P-IRLS hits a perfect-separation diagnostic, or when
617    /// it exhausts its iteration budget, the outer optimiser's correct
618    /// response is to back away from this rho — not to terminate the fit.
619    /// All three variants encode "the inner problem at this rho is too hard
620    /// to evaluate, try a different rho".
621    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    // ── is_inner_solve_retreat ────────────────────────────────────────────────
652
653    #[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    // ── error message content ─────────────────────────────────────────────────
730
731    #[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    // ── From<LinalgError> ─────────────────────────────────────────────────────
841
842    #[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
863/// Honest failure text for [`EstimationError::HessianNotPositiveDefinite`].
864///
865/// A failed Cholesky with a strictly POSITIVE reported minimum eigenvalue is
866/// not an indefinite matrix — it is a positive spectrum whose condition
867/// number exceeds float precision (the pivots collapse under roundoff), or a
868/// non-finite assembly. Saying "not positive definite (minimum eigenvalue:
869/// 4.1e1)" sent debugging at the wrong defect class (#2316 triage), so the
870/// message now names the regime the eigenvalue actually indicates.
871fn 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}