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("Underlying basis function generation failed: {0}")]
198    BasisError(#[from] BasisError),
199
200    #[error("Custom-family fit failed: {0}")]
201    CustomFamily(#[from] CustomFamilyError),
202
203    #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
204    LinearSystemSolveFailed(FaerLinalgError),
205
206    #[error("Eigendecomposition failed: {0}")]
207    EigendecompositionFailed(FaerLinalgError),
208
209    #[error(
210        "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
211    )]
212    PenaltySpectrumNonFinite {
213        context: String,
214        index: usize,
215        value: f64,
216    },
217
218    #[error(
219        "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
220    )]
221    PenaltySpectrumIndefinite {
222        context: String,
223        index: usize,
224        value: f64,
225        tolerance: f64,
226        scale: f64,
227    },
228
229    #[error("Parameter constraint violation: {0}")]
230    ParameterConstraintViolation(String),
231
232    #[error(
233        "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
234    )]
235    PirlsDidNotConverge {
236        max_iterations: usize,
237        last_change: f64,
238    },
239
240    #[error(
241        "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
242         {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
243         only minted from a converged optimization; resume by passing the carried checkpoint \
244         through the fixed-lambda input's `resume_from` field ({checkpoint}).",
245        .checkpoint.completed_iterations()
246    )]
247    FixedLambdaNewtonDidNotConverge {
248        /// Which fixed-λ Newton entry stalled (e.g. the multinomial softmax or
249        /// independent-binomial vector-GLM solve, or the Firth refit lane).
250        context: String,
251        /// Why the solver stopped without its convergence certificate.
252        reason: FixedLambdaStallReason,
253        /// Final value of the solver's minimized criterion. For ordinary vector
254        /// GLMs this is `-log L + penalty`; for the Firth lane it also includes
255        /// the negative Jeffreys `0.5 log det(I)` contribution.
256        objective_value: f64,
257        /// Exact first-order residual and the bound it failed to clear.
258        stationarity: FixedLambdaStationarityEvidence,
259        /// Last accepted coefficients and cumulative iteration count. This is
260        /// work-preservation state, not a fitted model, and carries no covariance
261        /// or prediction surface.
262        checkpoint: FixedLambdaCheckpoint,
263    },
264
265    #[error(
266        "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
267         max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
268         minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
269         {profile_curvature_roundoff:.3e}; last scale fixed-point step \
270         {last_scale_step:.6e}{}). \
271         A fit is only minted from a converged optimization; resume from the \
272         checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
273        if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
274    )]
275    BlockOrthogonalRemlDidNotConverge {
276        /// Outer alternation passes executed before exhaustion.
277        iterations: usize,
278        /// Largest per-block |dV/drho| at the final iterate, normalized by the
279        /// score's natural magnitude `d * max(1, rank)`.
280        max_score_residual: f64,
281        /// Tolerance the residual had to meet for the convergence certificate.
282        score_tol: f64,
283        /// Smallest eigenvalue of the analytic rho Hessian after profiling out
284        /// the exact conditional scale block.
285        min_profile_curvature: f64,
286        /// Dimension-scaled eigensolver roundoff allowed below zero when
287        /// certifying positive semidefiniteness.
288        profile_curvature_roundoff: f64,
289        /// Last max |Δ log scale-precision| fixed-point movement (evidence of
290        /// whether the alternation was still moving or had stalled).
291        last_scale_step: f64,
292        /// The alternation revisited an earlier `(rho, scale)` state exactly;
293        /// as a deterministic map it can never certify, so it stopped early.
294        cycle_detected: bool,
295        /// Per-block log-lambda iterates at exhaustion; feed back through the
296        /// entry point's `init_rhos` to resume rather than restart.
297        rho_checkpoint: Vec<f64>,
298    },
299
300    #[error(
301        "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
302         {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
303         {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
304         against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
305         partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
306         and rho={rho_checkpoint:?}."
307    )]
308    NegativeBinomialAlternationDidNotConverge {
309        /// Joint block-coordinate rounds executed before exhaustion.
310        rounds: usize,
311        /// Conditional theta coordinate at the best measured checkpoint.
312        theta_checkpoint: f64,
313        /// KKT-projected rho-gradient norm at that checkpoint.
314        rho_projected_grad_norm: f64,
315        /// Bound the rho residual had to clear.
316        rho_stationarity_bound: f64,
317        /// Curvature-normalized log-theta score residual at that checkpoint.
318        theta_score_residual: f64,
319        /// Bound the theta residual had to clear.
320        theta_stationarity_bound: f64,
321        /// Best measured log-smoothing checkpoint for warm-started resume.
322        rho_checkpoint: Vec<f64>,
323    },
324
325    #[error(
326        "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
327        The model cannot converge because a predictor perfectly separates the binary outcomes. \
328        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
329    )]
330    PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
331
332    #[error(
333        "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
334        has a threshold {threshold:.6e} that separates the binary outcomes \
335        (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
336        enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
337    )]
338    PrefitPerfectSeparationDetected {
339        column_index: usize,
340        threshold: f64,
341        positive_above_threshold: bool,
342    },
343
344    #[error(
345        "Pre-fit linear separation detected in the realized binomial inverse-link design: \
346        {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
347        with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
348        The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
349        remove/reparameterize/penalize the separating columns."
350    )]
351    PrefitLinearSeparationDetected {
352        min_signed_margin: f64,
353        num_unpenalized_columns: usize,
354        column_indices: Vec<usize>,
355    },
356
357    #[error(
358        "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
359        unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
360        Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
361    )]
362    PrefitRankDeficientDesignDetected {
363        rank: usize,
364        num_unpenalized_columns: usize,
365        min_eigenvalue: f64,
366        tolerance: f64,
367        column_indices: Vec<usize>,
368    },
369
370    #[error(
371        "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
372        unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
373        exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
374        columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
375        so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
376        penalty/constraint before fitting."
377    )]
378    PrefitNearDegenerateDesignDetected {
379        num_unpenalized_columns: usize,
380        condition_number: f64,
381        min_eigenvalue: f64,
382        max_eigenvalue: f64,
383        tolerance: f64,
384        column_indices: Vec<usize>,
385    },
386
387    #[error(
388        "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
389        The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
390        so the unpenalized softmax MLE is not finite in that direction. \
391        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
392    )]
393    MultinomialSeparationDetected {
394        iteration: usize,
395        max_abs_eta: f64,
396        active_class_index: usize,
397        row_index: usize,
398    },
399
400    #[error(
401        "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). This indicates a numerical instability."
402    )]
403    HessianNotPositiveDefinite { min_eigenvalue: f64 },
404
405    #[error("REML smoothing optimization failed to converge: {0}")]
406    RemlOptimizationFailed(String),
407
408    #[error(
409        "Outer smoothing-parameter optimization did not certify a stationary optimum \
410         ({context}): {reason} after {iterations} outer iteration(s); final objective \
411         {final_value:.6e}, projected gradient norm {} against stationarity bound \
412         {stationarity_bound:.3e}. A fit is only minted from a converged optimization; \
413         the best iterate is carried as a checkpoint — resume by seeding the outer \
414         search at rho_checkpoint = {rho_checkpoint:?}.",
415        .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}"))
416    )]
417    RemlDidNotConverge {
418        /// Fit context label (the same string the outer runner logs under).
419        context: String,
420        /// Which certificate failed: budget exhaustion, line-search collapse,
421        /// non-stationary cost stall, or a failed post-solve stationarity
422        /// certificate.
423        reason: String,
424        /// Outer iterations executed across all solver restarts.
425        iterations: usize,
426        /// Objective value at the abandoned best iterate.
427        final_value: f64,
428        /// KKT-projected gradient norm at the best iterate, when the solver
429        /// measured a gradient there (`None` for gradient-free exits).
430        projected_grad_norm: Option<f64>,
431        /// Bound the projected gradient had to clear for the stationarity
432        /// certificate.
433        stationarity_bound: f64,
434        /// Best (lowest-objective feasible) outer iterate at exhaustion. This
435        /// is work-preservation evidence for resume — it is NOT a fit and no
436        /// fitted-model API is reachable from it.
437        rho_checkpoint: Vec<f64>,
438    },
439
440    #[error(
441        "Fit assembly rejected a non-converged optimization state: inner status \
442         {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
443         iteration(s); final objective {final_value:.6e}; stationarity residual \
444         {stationarity_residual:?} against {stationarity_bound:?}, step residual \
445         {step_residual:?} against {step_bound:?}. The best rho checkpoint is \
446         {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
447         API was constructed."
448    )]
449    FitDidNotConverge {
450        /// Diagnostic inner-solver terminal status. This is deliberately a
451        /// string at the neutral problem layer; concrete solver status enums
452        /// live in downstream fitting crates.
453        inner_status: String,
454        /// Outer terminal/certificate verdict.
455        outer_status: String,
456        /// Completed outer iterations at the rejected checkpoint.
457        outer_iterations: usize,
458        /// Objective value at the best available checkpoint.
459        final_value: f64,
460        /// Exact analytic first-order gradient or root-equivalent fixed-point
461        /// residual, when it was measured.
462        stationarity_residual: Option<f64>,
463        /// Bound the first-order residual had to clear.
464        stationarity_bound: Option<f64>,
465        /// Final accepted-step residual, when the solver exported it.
466        step_residual: Option<f64>,
467        /// Bound the step residual had to clear.
468        step_bound: Option<f64>,
469        /// Work-preserving smoothing checkpoint; this is not a fit.
470        rho_checkpoint: Vec<f64>,
471        /// Opaque durable-cache resume token, when checkpoint persistence was
472        /// enabled for the failed run.
473        resume_token: Option<String>,
474    },
475
476    #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
477    GradientUnavailable {
478        context: &'static str,
479        mode: &'static str,
480    },
481
482    #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
483    LayoutError(String),
484
485    #[error(
486        "Model is over-parameterized: {num_coeffs} coefficients for {num_samples} samples.\n\n\
487        Coefficient Breakdown:\n\
488          - Intercept:                     {intercept_coeffs}\n\
489          - Binary Main Effects:           {binary_main_coeffs}\n\
490          - Primary Smooth Effects:        {primary_smooth_coeffs}\n\
491          - Binary×Primary Interactions:   {binary_primary_interaction_coeffs}\n\
492          - Auxiliary Main Effects:        {aux_main_coeffs}\n\
493          - Auxiliary Interactions:        {aux_interaction_coeffs}"
494    )]
495    ModelOverparameterized {
496        num_coeffs: usize,
497        num_samples: usize,
498        intercept_coeffs: usize,
499        binary_main_coeffs: usize,
500        primary_smooth_coeffs: usize,
501        aux_main_coeffs: usize,
502        binary_primary_interaction_coeffs: usize,
503        aux_interaction_coeffs: usize,
504    },
505
506    #[error(
507        "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."
508    )]
509    ModelIsIllConditioned { condition_number: f64 },
510
511    #[error("Invalid input: {0}")]
512    InvalidInput(String),
513
514    #[error("monotone root solve: {0}")]
515    MonotoneRoot(#[from] MonotoneRootError),
516
517    #[error("Calibrator training failed: {0}")]
518    CalibratorTrainingFailed(String),
519
520    #[error("Invalid specification: {0}")]
521    InvalidSpecification(String),
522
523    #[error("Prediction error")]
524    PredictionError,
525}
526
527// Ensure Debug prints with actual line breaks by delegating to Display
528impl core::fmt::Debug for EstimationError {
529    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
530        write!(f, "{}", self)
531    }
532}
533
534impl EstimationError {
535    /// Classifies inner-solve failures that the outer REML loop should
536    /// treat as a soft retreat (return +inf cost / infeasible outer-eval)
537    /// rather than propagate as a hard error.
538    ///
539    /// Why: when the penalised Hessian becomes effectively singular at the
540    /// current rho, when P-IRLS hits a perfect-separation diagnostic, or when
541    /// it exhausts its iteration budget, the outer optimiser's correct
542    /// response is to back away from this rho — not to terminate the fit.
543    /// All three variants encode "the inner problem at this rho is too hard
544    /// to evaluate, try a different rho".
545    pub fn is_inner_solve_retreat(&self) -> bool {
546        matches!(
547            self,
548            EstimationError::ModelIsIllConditioned { .. }
549                | EstimationError::PerfectSeparationDetected { .. }
550                | EstimationError::MultinomialSeparationDetected { .. }
551                | EstimationError::PirlsDidNotConverge { .. }
552                | EstimationError::FixedLambdaNewtonDidNotConverge { .. }
553        )
554    }
555}
556
557impl From<LinalgError> for EstimationError {
558    fn from(error: LinalgError) -> Self {
559        match error {
560            LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
561            LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
562                EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
563            }
564            LinalgError::ModelIsIllConditioned { condition_number } => {
565                EstimationError::ModelIsIllConditioned { condition_number }
566            }
567        }
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    // ── is_inner_solve_retreat ────────────────────────────────────────────────
576
577    #[test]
578    fn model_ill_conditioned_is_retreat() {
579        assert!(
580            EstimationError::ModelIsIllConditioned {
581                condition_number: 1e15
582            }
583            .is_inner_solve_retreat()
584        );
585    }
586
587    #[test]
588    fn perfect_separation_is_retreat() {
589        assert!(
590            EstimationError::PerfectSeparationDetected {
591                iteration: 3,
592                max_abs_eta: 50.0
593            }
594            .is_inner_solve_retreat()
595        );
596    }
597
598    #[test]
599    fn multinomial_separation_is_retreat() {
600        assert!(
601            EstimationError::MultinomialSeparationDetected {
602                iteration: 1,
603                max_abs_eta: 100.0,
604                active_class_index: 2,
605                row_index: 7
606            }
607            .is_inner_solve_retreat()
608        );
609    }
610
611    #[test]
612    fn pirls_did_not_converge_is_retreat() {
613        assert!(
614            EstimationError::PirlsDidNotConverge {
615                max_iterations: 100,
616                last_change: 1e-3
617            }
618            .is_inner_solve_retreat()
619        );
620    }
621
622    #[test]
623    fn invalid_input_is_not_retreat() {
624        assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
625    }
626
627    #[test]
628    fn reml_optimization_failed_is_not_retreat() {
629        assert!(
630            !EstimationError::RemlOptimizationFailed("outer fail".to_string())
631                .is_inner_solve_retreat()
632        );
633    }
634
635    // ── error message content ─────────────────────────────────────────────────
636
637    #[test]
638    fn invalid_input_message_appears_in_display() {
639        let err = EstimationError::InvalidInput("test_message".to_string());
640        assert!(err.to_string().contains("test_message"));
641    }
642
643    #[test]
644    fn pirls_did_not_converge_mentions_max_iterations() {
645        let err = EstimationError::PirlsDidNotConverge {
646            max_iterations: 42,
647            last_change: 0.001,
648        };
649        assert!(err.to_string().contains("42"));
650    }
651
652    #[test]
653    fn fixed_lambda_checkpoint_validates_shape_and_values() {
654        let checkpoint = FixedLambdaCheckpoint::new(
655            FixedLambdaSolverStage::MultinomialNewton,
656            vec![1.0, 2.0, 3.0, 4.0],
657            2,
658            2,
659            7,
660        )
661        .expect("well-shaped finite checkpoint");
662        assert_eq!(
663            checkpoint.stage(),
664            FixedLambdaSolverStage::MultinomialNewton
665        );
666        assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
667        assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
668        assert_eq!(checkpoint.completed_iterations(), 7);
669
670        assert!(
671            FixedLambdaCheckpoint::new(
672                FixedLambdaSolverStage::BinomialMultiNewton,
673                vec![1.0],
674                2,
675                1,
676                0,
677            )
678            .is_err(),
679            "coefficient length must match rows * cols"
680        );
681        assert!(
682            FixedLambdaCheckpoint::new(
683                FixedLambdaSolverStage::BinomialMultiNewton,
684                vec![f64::NAN],
685                1,
686                1,
687                0,
688            )
689            .is_err(),
690            "checkpoint coefficients must be finite"
691        );
692        assert!(
693            FixedLambdaCheckpoint::new(
694                FixedLambdaSolverStage::MultinomialFirth,
695                Vec::new(),
696                usize::MAX,
697                2,
698                0,
699            )
700            .is_err(),
701            "checkpoint shape multiplication must not overflow"
702        );
703    }
704
705    #[test]
706    fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
707        let checkpoint = FixedLambdaCheckpoint::new(
708            FixedLambdaSolverStage::MultinomialFirth,
709            vec![12_345.678_9, -98_765.432_1],
710            2,
711            1,
712            11,
713        )
714        .expect("valid checkpoint");
715        let checkpoint_debug = format!("{checkpoint:?}");
716        assert!(!checkpoint_debug.contains("12345.6789"));
717        assert!(!checkpoint_debug.contains("98765.4321"));
718        let err = EstimationError::FixedLambdaNewtonDidNotConverge {
719            context: "test Firth solve".to_string(),
720            reason: FixedLambdaStallReason::LineSearchExhausted,
721            objective_value: 3.25,
722            stationarity: FixedLambdaStationarityEvidence {
723                kind: FixedLambdaResidualKind::NewtonDecrement,
724                residual: 0.125,
725                bound: 1.0e-7,
726            },
727            checkpoint,
728        };
729
730        let display = err.to_string();
731        assert!(display.contains("test Firth solve"));
732        assert!(display.contains("line search exhausted"));
733        assert!(display.contains("Newton decrement"));
734        assert!(display.contains("2x1"));
735        assert!(display.contains("11 iteration"));
736        assert!(!display.contains("12345.6789"));
737        assert!(!display.contains("98765.4321"));
738        assert_eq!(
739            format!("{err:?}"),
740            display,
741            "Debug delegates to safe Display"
742        );
743        assert!(err.is_inner_solve_retreat());
744    }
745
746    // ── From<LinalgError> ─────────────────────────────────────────────────────
747
748    #[test]
749    fn from_linalg_invalid_input_maps_to_invalid_input() {
750        let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
751        let err = EstimationError::from(linalg_err);
752        assert!(matches!(err, EstimationError::InvalidInput(_)));
753        assert!(err.to_string().contains("linalg msg"));
754    }
755
756    #[test]
757    fn from_linalg_hessian_not_spd_maps_correctly() {
758        let linalg_err = LinalgError::HessianNotPositiveDefinite {
759            min_eigenvalue: -1.0,
760        };
761        let err = EstimationError::from(linalg_err);
762        assert!(matches!(
763            err,
764            EstimationError::HessianNotPositiveDefinite { .. }
765        ));
766    }
767}