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/// Which rung of the stationarity ladder produced the bound a refusal was
8/// measured against (#2458).
9///
10/// A non-convergence refusal reports `|Pg|` against a bound, but routes reach
11/// that bound by different machinery, and only one rung — the curvature
12/// resolvability standard `√(2·h·τ)` — is derived from what a gradient of that
13/// size does to the criterion. The rest are gradient-magnitude substitutes
14/// adopted where the derived standard was unavailable. Without this field, "did
15/// this route hold itself to the derived standard" is an inference from the
16/// numbers rather than something the refusal states, which is what made the
17/// duchon and #2479 adjudications read the ladder out of prose.
18///
19/// The rung enum itself lives in the solver that owns the ladder; this is the
20/// neutral projection of it, so the problem layer carries the provenance
21/// without importing a solver-specific vocabulary.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub struct StationarityRung {
24    /// Stable rung label, e.g. `"curvature-resolvability"`.
25    pub label: &'static str,
26    /// Whether this rung is the derived resolvability standard rather than a
27    /// gradient-magnitude substitute.
28    pub derived_standard: bool,
29}
30
31impl std::fmt::Display for StationarityRung {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        write!(
34            f,
35            "rung={} derived_standard={}",
36            self.label, self.derived_standard
37        )
38    }
39}
40
41/// Fixed-lambda solver stage that owns a resumable coefficient checkpoint.
42///
43/// The multinomial fitter has two distinct objectives: the ordinary softmax
44/// likelihood and the Firth/Jeffreys separation refit. Recording the stage is
45/// therefore part of correctness: a Firth checkpoint must resume the Firth
46/// objective rather than being mistaken for an ordinary multinomial start.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48pub enum FixedLambdaSolverStage {
49    BinomialMultiNewton,
50    MultinomialNewton,
51    MultinomialFirth,
52}
53
54impl core::fmt::Display for FixedLambdaSolverStage {
55    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
56        f.write_str(match self {
57            Self::BinomialMultiNewton => "binomial-multi Newton",
58            Self::MultinomialNewton => "multinomial Newton",
59            Self::MultinomialFirth => "multinomial Firth/Jeffreys Newton",
60        })
61    }
62}
63
64/// Exhaustive terminal reason for a fixed-lambda solve without a certificate.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum FixedLambdaStallReason {
67    IterationBudgetExhausted,
68    LineSearchExhausted,
69    StationarityCertificateFailed,
70}
71
72impl core::fmt::Display for FixedLambdaStallReason {
73    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
74        f.write_str(match self {
75            Self::IterationBudgetExhausted => "iteration budget exhausted",
76            Self::LineSearchExhausted => "line search exhausted without an accepted step",
77            Self::StationarityCertificateFailed => "stationarity certificate failed",
78        })
79    }
80}
81
82/// Solver-native first-order residual carried by a fixed-lambda stall.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub enum FixedLambdaResidualKind {
85    /// Euclidean norm of the exact penalized likelihood gradient.
86    PenalizedGradientNorm,
87    /// Firth/Jeffreys Newton decrement `0.5 * |score' H^-1 score|`.
88    NewtonDecrement,
89}
90
91impl core::fmt::Display for FixedLambdaResidualKind {
92    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
93        f.write_str(match self {
94            Self::PenalizedGradientNorm => "penalized gradient norm",
95            Self::NewtonDecrement => "Newton decrement",
96        })
97    }
98}
99
100/// Evidence from the exact stationarity check at the last accepted iterate.
101#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
102pub struct FixedLambdaStationarityEvidence {
103    pub kind: FixedLambdaResidualKind,
104    pub residual: f64,
105    pub bound: f64,
106}
107
108impl core::fmt::Display for FixedLambdaStationarityEvidence {
109    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110        write!(
111            f,
112            "{} {:.6e} against bound {:.6e}",
113            self.kind, self.residual, self.bound
114        )
115    }
116}
117
118/// Owned, serde-safe coefficient checkpoint for a fixed-lambda Newton solve.
119///
120/// Coefficients are row-major with shape `(rows, cols)`, where rows are the
121/// per-output coefficient count and columns are active outputs/classes. The
122/// values deliberately remain private so diagnostics cannot accidentally dump
123/// a potentially large coefficient vector; resume code accesses them through
124/// [`Self::values`] after calling [`Self::validate`].
125#[derive(Clone, PartialEq, Serialize, Deserialize)]
126pub struct FixedLambdaCheckpoint {
127    stage: FixedLambdaSolverStage,
128    coefficients_row_major: Vec<f64>,
129    rows: usize,
130    cols: usize,
131    completed_iterations: usize,
132}
133
134impl FixedLambdaCheckpoint {
135    pub fn new(
136        stage: FixedLambdaSolverStage,
137        coefficients_row_major: Vec<f64>,
138        rows: usize,
139        cols: usize,
140        completed_iterations: usize,
141    ) -> Result<Self, String> {
142        let checkpoint = Self {
143            stage,
144            coefficients_row_major,
145            rows,
146            cols,
147            completed_iterations,
148        };
149        checkpoint.validate()?;
150        Ok(checkpoint)
151    }
152
153    /// Validate persisted checkpoint geometry and coefficient finiteness before
154    /// rebuilding an ndarray view in a resumed solver.
155    pub fn validate(&self) -> Result<(), String> {
156        if self.rows == 0 || self.cols == 0 {
157            return Err(format!(
158                "fixed-lambda checkpoint shape must be nonempty, got {}x{}",
159                self.rows, self.cols
160            ));
161        }
162        let expected = self.rows.checked_mul(self.cols).ok_or_else(|| {
163            format!(
164                "fixed-lambda checkpoint shape {}x{} overflows usize",
165                self.rows, self.cols
166            )
167        })?;
168        if self.coefficients_row_major.len() != expected {
169            return Err(format!(
170                "fixed-lambda checkpoint has {} coefficient values, expected {} for shape {}x{}",
171                self.coefficients_row_major.len(),
172                expected,
173                self.rows,
174                self.cols
175            ));
176        }
177        if let Some((index, _)) = self
178            .coefficients_row_major
179            .iter()
180            .copied()
181            .enumerate()
182            .find(|(_, value)| !value.is_finite())
183        {
184            return Err(format!(
185                "fixed-lambda checkpoint coefficient {index} must be finite"
186            ));
187        }
188        Ok(())
189    }
190
191    pub fn stage(&self) -> FixedLambdaSolverStage {
192        self.stage
193    }
194
195    pub fn values(&self) -> &[f64] {
196        &self.coefficients_row_major
197    }
198
199    pub fn rows(&self) -> usize {
200        self.rows
201    }
202
203    pub fn cols(&self) -> usize {
204        self.cols
205    }
206
207    pub fn completed_iterations(&self) -> usize {
208        self.completed_iterations
209    }
210}
211
212impl core::fmt::Display for FixedLambdaCheckpoint {
213    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
214        write!(
215            f,
216            "{} checkpoint {}x{} after {} iteration(s)",
217            self.stage, self.rows, self.cols, self.completed_iterations
218        )
219    }
220}
221
222impl core::fmt::Debug for FixedLambdaCheckpoint {
223    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
224        core::fmt::Display::fmt(self, f)
225    }
226}
227
228/// A comprehensive error type for the model estimation process.
229#[derive(thiserror::Error)]
230pub enum EstimationError {
231    #[error(transparent)]
232    InvalidStabilization(#[from] crate::InvalidStabilization),
233
234    #[error("Underlying basis function generation failed: {0}")]
235    BasisError(#[from] BasisError),
236
237    #[error("Custom-family fit failed: {0}")]
238    CustomFamily(#[from] CustomFamilyError),
239
240    #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
241    LinearSystemSolveFailed(FaerLinalgError),
242
243    #[error("Eigendecomposition failed: {0}")]
244    EigendecompositionFailed(FaerLinalgError),
245
246    #[error(
247        "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
248    )]
249    PenaltySpectrumNonFinite {
250        context: String,
251        index: usize,
252        value: f64,
253    },
254
255    #[error(
256        "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
257    )]
258    PenaltySpectrumIndefinite {
259        context: String,
260        index: usize,
261        value: f64,
262        tolerance: f64,
263        scale: f64,
264    },
265
266    #[error("Parameter constraint violation: {0}")]
267    ParameterConstraintViolation(String),
268
269    #[error(
270        "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
271    )]
272    PirlsDidNotConverge {
273        max_iterations: usize,
274        last_change: f64,
275    },
276
277    #[error(
278        "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
279         {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
280         only minted from a converged optimization; resume by passing the carried checkpoint \
281         through the fixed-lambda input's `resume_from` field ({checkpoint}).",
282        .checkpoint.completed_iterations()
283    )]
284    FixedLambdaNewtonDidNotConverge {
285        /// Which fixed-λ Newton entry stalled (e.g. the multinomial softmax or
286        /// independent-binomial vector-GLM solve, or the Firth refit lane).
287        context: String,
288        /// Why the solver stopped without its convergence certificate.
289        reason: FixedLambdaStallReason,
290        /// Final value of the solver's minimized criterion. For ordinary vector
291        /// GLMs this is `-log L + penalty`; for the Firth lane it also includes
292        /// the negative Jeffreys `0.5 log det(I)` contribution.
293        objective_value: f64,
294        /// Exact first-order residual and the bound it failed to clear.
295        stationarity: FixedLambdaStationarityEvidence,
296        /// Last accepted coefficients and cumulative iteration count. This is
297        /// work-preservation state, not a fitted model, and carries no covariance
298        /// or prediction surface.
299        checkpoint: FixedLambdaCheckpoint,
300    },
301
302    #[error(
303        "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
304         max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
305         minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
306         {profile_curvature_roundoff:.3e}; last scale fixed-point step \
307         {last_scale_step:.6e}{}). \
308         A fit is only minted from a converged optimization; resume from the \
309         checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
310        if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
311    )]
312    BlockOrthogonalRemlDidNotConverge {
313        /// Outer alternation passes executed before exhaustion.
314        iterations: usize,
315        /// Largest per-block |dV/drho| at the final iterate, normalized by the
316        /// score's natural magnitude `d * max(1, rank)`.
317        max_score_residual: f64,
318        /// Tolerance the residual had to meet for the convergence certificate.
319        score_tol: f64,
320        /// Smallest eigenvalue of the analytic rho Hessian after profiling out
321        /// the exact conditional scale block.
322        min_profile_curvature: f64,
323        /// Dimension-scaled eigensolver roundoff allowed below zero when
324        /// certifying positive semidefiniteness.
325        profile_curvature_roundoff: f64,
326        /// Last max |Δ log scale-precision| fixed-point movement (evidence of
327        /// whether the alternation was still moving or had stalled).
328        last_scale_step: f64,
329        /// The alternation revisited an earlier `(rho, scale)` state exactly;
330        /// as a deterministic map it can never certify, so it stopped early.
331        cycle_detected: bool,
332        /// Per-block log-lambda iterates at exhaustion; feed back through the
333        /// entry point's `init_rhos` to resume rather than restart.
334        rho_checkpoint: Vec<f64>,
335    },
336
337    #[error(
338        "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
339         {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
340         {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
341         against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
342         partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
343         and rho={rho_checkpoint:?}."
344    )]
345    NegativeBinomialAlternationDidNotConverge {
346        /// Joint block-coordinate rounds executed before exhaustion.
347        rounds: usize,
348        /// Conditional theta coordinate at the best measured checkpoint.
349        theta_checkpoint: f64,
350        /// KKT-projected rho-gradient norm at that checkpoint.
351        rho_projected_grad_norm: f64,
352        /// Bound the rho residual had to clear.
353        rho_stationarity_bound: f64,
354        /// Curvature-normalized log-theta score residual at that checkpoint.
355        theta_score_residual: f64,
356        /// Bound the theta residual had to clear.
357        theta_stationarity_bound: f64,
358        /// Best measured log-smoothing checkpoint for warm-started resume.
359        rho_checkpoint: Vec<f64>,
360    },
361
362    #[error(
363        "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
364        The model cannot converge because a predictor perfectly separates the binary outcomes. \
365        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
366    )]
367    PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
368
369    #[error(
370        "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
371        has a threshold {threshold:.6e} that separates the binary outcomes \
372        (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
373        enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
374    )]
375    PrefitPerfectSeparationDetected {
376        column_index: usize,
377        threshold: f64,
378        positive_above_threshold: bool,
379    },
380
381    #[error(
382        "Pre-fit linear separation detected in the realized binomial inverse-link design: \
383        {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
384        with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
385        The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
386        remove/reparameterize/penalize the separating columns."
387    )]
388    PrefitLinearSeparationDetected {
389        min_signed_margin: f64,
390        num_unpenalized_columns: usize,
391        column_indices: Vec<usize>,
392    },
393
394    #[error(
395        "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
396        unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
397        Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
398    )]
399    PrefitRankDeficientDesignDetected {
400        rank: usize,
401        num_unpenalized_columns: usize,
402        min_eigenvalue: f64,
403        tolerance: f64,
404        column_indices: Vec<usize>,
405    },
406
407    #[error(
408        "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
409        unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
410        exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
411        columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
412        so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
413        penalty/constraint before fitting."
414    )]
415    PrefitNearDegenerateDesignDetected {
416        num_unpenalized_columns: usize,
417        condition_number: f64,
418        min_eigenvalue: f64,
419        max_eigenvalue: f64,
420        tolerance: f64,
421        column_indices: Vec<usize>,
422    },
423
424    #[error(
425        "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
426        The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
427        so the unpenalized softmax MLE is not finite in that direction. \
428        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
429    )]
430    MultinomialSeparationDetected {
431        iteration: usize,
432        max_abs_eta: f64,
433        active_class_index: usize,
434        row_index: usize,
435    },
436
437    #[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
438    HessianNotPositiveDefinite { min_eigenvalue: f64 },
439
440    #[error("REML smoothing optimization failed to converge: {0}")]
441    RemlOptimizationFailed(String),
442
443    #[error("Fatal outer-objective evaluation failure ({context}): {source}")]
444    OuterObjectiveEvaluationFailed {
445        context: String,
446        #[source]
447        source: Box<EstimationError>,
448    },
449
450    #[error(
451        "Outer smoothing-parameter optimization did not certify a stationary optimum \
452         ({context}): {reason} after {iterations} outer iteration(s); final objective \
453         {final_value:.6e}, projected gradient norm {} against stationarity bound \
454         {stationarity_bound:.3e} ({}). A fit is only minted from a converged optimization; \
455         the best iterate is carried as a checkpoint — resume by seeding the outer \
456         search at rho_checkpoint = {rho_checkpoint:?}.",
457        .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}")),
458        .stationarity_bound_rung.map_or_else(
459            || "rung=unrecorded".to_string(),
460            |rung| rung.to_string(),
461        )
462    )]
463    RemlDidNotConverge {
464        /// Fit context label (the same string the outer runner logs under).
465        context: String,
466        /// Which certificate failed: budget exhaustion, line-search collapse,
467        /// non-stationary cost stall, or a failed post-solve stationarity
468        /// certificate.
469        reason: String,
470        /// Outer iterations executed across all solver restarts.
471        iterations: usize,
472        /// Objective value at the abandoned best iterate.
473        final_value: f64,
474        /// KKT-projected gradient norm at the best iterate, when the solver
475        /// measured a gradient there (`None` for gradient-free exits).
476        projected_grad_norm: Option<f64>,
477        /// Bound the projected gradient had to clear for the stationarity
478        /// certificate.
479        stationarity_bound: f64,
480        /// Which rung of the ladder produced `stationarity_bound` (#2458).
481        ///
482        /// `None` means the refusing route does not record its rung — which is
483        /// itself the finding, not a gap in the message: it marks exactly the
484        /// routes whose stationarity standard is still unobservable.
485        stationarity_bound_rung: Option<StationarityRung>,
486        /// Best (lowest-objective feasible) outer iterate at exhaustion. This
487        /// is work-preservation evidence for resume — it is NOT a fit and no
488        /// fitted-model API is reachable from it.
489        rho_checkpoint: Vec<f64>,
490    },
491
492    #[error(
493        "Fit assembly rejected a non-converged optimization state: inner status \
494         {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
495         iteration(s); final objective {final_value:.6e}; stationarity residual \
496         {stationarity_residual:?} against {stationarity_bound:?}, step residual \
497         {step_residual:?} against {step_bound:?}. The best rho checkpoint is \
498         {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
499         API was constructed."
500    )]
501    FitDidNotConverge {
502        /// Diagnostic inner-solver terminal status. This is deliberately a
503        /// string at the neutral problem layer; concrete solver status enums
504        /// live in downstream fitting crates.
505        inner_status: String,
506        /// Outer terminal/certificate verdict.
507        outer_status: String,
508        /// Completed outer iterations at the rejected checkpoint.
509        outer_iterations: usize,
510        /// Objective value at the best available checkpoint.
511        final_value: f64,
512        /// Exact analytic first-order gradient or root-equivalent fixed-point
513        /// residual, when it was measured.
514        stationarity_residual: Option<f64>,
515        /// Bound the first-order residual had to clear.
516        stationarity_bound: Option<f64>,
517        /// Final accepted-step residual, when the solver exported it.
518        step_residual: Option<f64>,
519        /// Bound the step residual had to clear.
520        step_bound: Option<f64>,
521        /// Work-preserving smoothing checkpoint; this is not a fit.
522        rho_checkpoint: Vec<f64>,
523        /// Opaque durable-cache resume token, when checkpoint persistence was
524        /// enabled for the failed run.
525        resume_token: Option<String>,
526    },
527
528    #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
529    GradientUnavailable {
530        context: &'static str,
531        mode: &'static str,
532    },
533
534    #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
535    LayoutError(String),
536
537    #[error(
538        "Model is over-parameterized: {num_coeffs} coefficients for {num_samples} samples.\n\n\
539        Coefficient Breakdown:\n\
540          - Intercept:                     {intercept_coeffs}\n\
541          - Binary Main Effects:           {binary_main_coeffs}\n\
542          - Primary Smooth Effects:        {primary_smooth_coeffs}\n\
543          - Binary×Primary Interactions:   {binary_primary_interaction_coeffs}\n\
544          - Auxiliary Main Effects:        {aux_main_coeffs}\n\
545          - Auxiliary Interactions:        {aux_interaction_coeffs}"
546    )]
547    ModelOverparameterized {
548        num_coeffs: usize,
549        num_samples: usize,
550        intercept_coeffs: usize,
551        binary_main_coeffs: usize,
552        primary_smooth_coeffs: usize,
553        aux_main_coeffs: usize,
554        binary_primary_interaction_coeffs: usize,
555        aux_interaction_coeffs: usize,
556    },
557
558    #[error(
559        "Model is ill-conditioned with condition number {condition_number:.2e}. This typically occurs when the model is over-parameterized (too many knots relative to data points). Consider reducing the number of knots or increasing regularization."
560    )]
561    ModelIsIllConditioned { condition_number: f64 },
562
563    #[error("Invalid input: {0}")]
564    InvalidInput(String),
565
566    #[error(
567        "Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
568         interval [{lower}, {upper}]"
569    )]
570    InverseLinkDomainViolation {
571        link: &'static str,
572        eta: f64,
573        lower: f64,
574        upper: f64,
575    },
576
577    #[error(
578        "PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
579         eta={eta:?} produced {value:?}"
580    )]
581    PirlsRowGeometryUnrepresentable {
582        row: usize,
583        quantity: &'static str,
584        eta: f64,
585        value: f64,
586    },
587
588    #[error(
589        "Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
590    )]
591    ExactTweedieSeriesWorkLimit {
592        row: usize,
593        required_terms_lower_bound: f64,
594        budget: usize,
595    },
596
597    #[error(
598        "Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
599         the supported interval [{lower}, {upper}]"
600    )]
601    LogStrengthDomainViolation {
602        coordinate: usize,
603        value: f64,
604        lower: f64,
605        upper: f64,
606    },
607
608    #[error("monotone root solve: {0}")]
609    MonotoneRoot(#[from] MonotoneRootError),
610
611    #[error("Calibrator training failed: {0}")]
612    CalibratorTrainingFailed(String),
613
614    #[error("Invalid specification: {0}")]
615    InvalidSpecification(String),
616
617    #[error("Prediction error")]
618    PredictionError,
619}
620
621// Ensure Debug prints with actual line breaks by delegating to Display
622impl core::fmt::Debug for EstimationError {
623    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
624        write!(f, "{}", self)
625    }
626}
627
628impl EstimationError {
629    /// Preserve a thrown outer-objective failure across seed, solver, and
630    /// fallback-plan orchestration. Trial-domain refusals must be represented
631    /// as a finite API outcome (`+inf` / `OuterEval::infeasible`); an `Err`
632    /// means the evaluation artifact itself could not be constructed and must
633    /// never be retried as another numerical point.
634    pub fn fatal_outer_evaluation(
635        context: impl Into<String>,
636        source: EstimationError,
637    ) -> Self {
638        if matches!(
639            &source,
640            EstimationError::OuterObjectiveEvaluationFailed { .. }
641        ) {
642            source
643        } else {
644            EstimationError::OuterObjectiveEvaluationFailed {
645                context: context.into(),
646                source: Box::new(source),
647            }
648        }
649    }
650
651    pub fn is_fatal_outer_evaluation(&self) -> bool {
652        matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
653    }
654
655    /// Classifies inner-solve failures that the outer REML loop should
656    /// treat as a soft retreat (return +inf cost / infeasible outer-eval)
657    /// rather than propagate as a hard error.
658    ///
659    /// Why: when the penalised Hessian becomes effectively singular at the
660    /// current rho, when P-IRLS hits a perfect-separation diagnostic, or when
661    /// it exhausts its iteration budget, the outer optimiser's correct
662    /// response is to back away from this rho — not to terminate the fit.
663    /// All three variants encode "the inner problem at this rho is too hard
664    /// to evaluate, try a different rho".
665    pub fn is_inner_solve_retreat(&self) -> bool {
666        matches!(
667            self,
668            EstimationError::ModelIsIllConditioned { .. }
669                | EstimationError::PerfectSeparationDetected { .. }
670                | EstimationError::MultinomialSeparationDetected { .. }
671                | EstimationError::PirlsDidNotConverge { .. }
672                | EstimationError::FixedLambdaNewtonDidNotConverge { .. }
673        )
674    }
675}
676
677impl From<LinalgError> for EstimationError {
678    fn from(error: LinalgError) -> Self {
679        match error {
680            LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
681            LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
682                EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
683            }
684            LinalgError::ModelIsIllConditioned { condition_number } => {
685                EstimationError::ModelIsIllConditioned { condition_number }
686            }
687        }
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    // ── stationarity rung provenance (#2458) ─────────────────────────────────
696
697    fn reml_refusal(rung: Option<StationarityRung>) -> EstimationError {
698        EstimationError::RemlDidNotConverge {
699            context: "unit".to_string(),
700            reason: "budget exhausted".to_string(),
701            iterations: 7,
702            final_value: -1.25,
703            projected_grad_norm: Some(7.5e-1),
704            stationarity_bound: 1.0e-2,
705            stationarity_bound_rung: rung,
706            rho_checkpoint: vec![0.5],
707        }
708    }
709
710    /// The whole point of the increment: a red states which standard it was
711    /// held to, so a reader does not have to infer the rung from the numbers.
712    #[test]
713    fn refusal_message_carries_the_rung_and_whether_it_is_derived() {
714        let derived = reml_refusal(Some(StationarityRung {
715            label: "curvature-resolvability",
716            derived_standard: true,
717        }))
718        .to_string();
719        assert!(
720            derived.contains("rung=curvature-resolvability"),
721            "refusal must name its rung: {derived}"
722        );
723        assert!(
724            derived.contains("derived_standard=true"),
725            "refusal must say whether the rung is the derived standard: {derived}"
726        );
727
728        let substitute = reml_refusal(Some(StationarityRung {
729            label: "solver-band",
730            derived_standard: false,
731        }))
732        .to_string();
733        assert!(substitute.contains("rung=solver-band"), "{substitute}");
734        assert!(
735            substitute.contains("derived_standard=false"),
736            "a gradient-magnitude substitute must not read as the derived standard: {substitute}"
737        );
738    }
739
740    /// A route that does not record its rung says so, rather than defaulting to
741    /// a rung it never used. `rung=unrecorded` is the finding — it names a route
742    /// whose stationarity standard is still unstated.
743    #[test]
744    fn unrecorded_rung_is_stated_not_defaulted() {
745        let message = reml_refusal(None).to_string();
746        assert!(
747            message.contains("rung=unrecorded"),
748            "an unclassified bound must say so: {message}"
749        );
750        assert!(
751            !message.contains("derived_standard=true"),
752            "an unrecorded rung must never read as the derived standard: {message}"
753        );
754    }
755
756    /// The bound itself still reaches the message unchanged — the rung rides
757    /// alongside it, it does not replace it.
758    #[test]
759    fn rung_rides_beside_the_bound_without_displacing_it() {
760        let message = reml_refusal(None).to_string();
761        assert!(message.contains("1.000e-2"), "bound must survive: {message}");
762        assert!(
763            message.contains("7.500e-1"),
764            "projected gradient norm must survive: {message}"
765        );
766    }
767
768    // ── is_inner_solve_retreat ────────────────────────────────────────────────
769
770    #[test]
771    fn model_ill_conditioned_is_retreat() {
772        assert!(
773            EstimationError::ModelIsIllConditioned {
774                condition_number: 1e15
775            }
776            .is_inner_solve_retreat()
777        );
778    }
779
780    #[test]
781    fn perfect_separation_is_retreat() {
782        assert!(
783            EstimationError::PerfectSeparationDetected {
784                iteration: 3,
785                max_abs_eta: 50.0
786            }
787            .is_inner_solve_retreat()
788        );
789    }
790
791    #[test]
792    fn multinomial_separation_is_retreat() {
793        assert!(
794            EstimationError::MultinomialSeparationDetected {
795                iteration: 1,
796                max_abs_eta: 100.0,
797                active_class_index: 2,
798                row_index: 7
799            }
800            .is_inner_solve_retreat()
801        );
802    }
803
804    #[test]
805    fn pirls_did_not_converge_is_retreat() {
806        assert!(
807            EstimationError::PirlsDidNotConverge {
808                max_iterations: 100,
809                last_change: 1e-3
810            }
811            .is_inner_solve_retreat()
812        );
813    }
814
815    #[test]
816    fn invalid_input_is_not_retreat() {
817        assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
818    }
819
820    #[test]
821    fn reml_optimization_failed_is_not_retreat() {
822        assert!(
823            !EstimationError::RemlOptimizationFailed("outer fail".to_string())
824                .is_inner_solve_retreat()
825        );
826    }
827
828    #[test]
829    fn fatal_outer_evaluation_is_typed_and_idempotent() {
830        let error = EstimationError::fatal_outer_evaluation(
831            "seed screening",
832            EstimationError::InvalidInput("frame mismatch".to_string()),
833        );
834        assert!(error.is_fatal_outer_evaluation());
835        assert!(error.to_string().contains("frame mismatch"));
836
837        let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
838        assert!(nested.is_fatal_outer_evaluation());
839        assert_eq!(
840            nested.to_string().matches("Fatal outer-objective").count(),
841            1,
842            "fatal provenance must not be re-wrapped at every orchestration layer"
843        );
844    }
845
846    // ── error message content ─────────────────────────────────────────────────
847
848    #[test]
849    fn invalid_input_message_appears_in_display() {
850        let err = EstimationError::InvalidInput("test_message".to_string());
851        assert!(err.to_string().contains("test_message"));
852    }
853
854    #[test]
855    fn pirls_did_not_converge_mentions_max_iterations() {
856        let err = EstimationError::PirlsDidNotConverge {
857            max_iterations: 42,
858            last_change: 0.001,
859        };
860        assert!(err.to_string().contains("42"));
861    }
862
863    #[test]
864    fn fixed_lambda_checkpoint_validates_shape_and_values() {
865        let checkpoint = FixedLambdaCheckpoint::new(
866            FixedLambdaSolverStage::MultinomialNewton,
867            vec![1.0, 2.0, 3.0, 4.0],
868            2,
869            2,
870            7,
871        )
872        .expect("well-shaped finite checkpoint");
873        assert_eq!(
874            checkpoint.stage(),
875            FixedLambdaSolverStage::MultinomialNewton
876        );
877        assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
878        assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
879        assert_eq!(checkpoint.completed_iterations(), 7);
880
881        assert!(
882            FixedLambdaCheckpoint::new(
883                FixedLambdaSolverStage::BinomialMultiNewton,
884                vec![1.0],
885                2,
886                1,
887                0,
888            )
889            .is_err(),
890            "coefficient length must match rows * cols"
891        );
892        assert!(
893            FixedLambdaCheckpoint::new(
894                FixedLambdaSolverStage::BinomialMultiNewton,
895                vec![f64::NAN],
896                1,
897                1,
898                0,
899            )
900            .is_err(),
901            "checkpoint coefficients must be finite"
902        );
903        assert!(
904            FixedLambdaCheckpoint::new(
905                FixedLambdaSolverStage::MultinomialFirth,
906                Vec::new(),
907                usize::MAX,
908                2,
909                0,
910            )
911            .is_err(),
912            "checkpoint shape multiplication must not overflow"
913        );
914    }
915
916    #[test]
917    fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
918        let checkpoint = FixedLambdaCheckpoint::new(
919            FixedLambdaSolverStage::MultinomialFirth,
920            vec![12_345.678_9, -98_765.432_1],
921            2,
922            1,
923            11,
924        )
925        .expect("valid checkpoint");
926        let checkpoint_debug = format!("{checkpoint:?}");
927        assert!(!checkpoint_debug.contains("12345.6789"));
928        assert!(!checkpoint_debug.contains("98765.4321"));
929        let err = EstimationError::FixedLambdaNewtonDidNotConverge {
930            context: "test Firth solve".to_string(),
931            reason: FixedLambdaStallReason::LineSearchExhausted,
932            objective_value: 3.25,
933            stationarity: FixedLambdaStationarityEvidence {
934                kind: FixedLambdaResidualKind::NewtonDecrement,
935                residual: 0.125,
936                bound: 1.0e-7,
937            },
938            checkpoint,
939        };
940
941        let display = err.to_string();
942        assert!(display.contains("test Firth solve"));
943        assert!(display.contains("line search exhausted"));
944        assert!(display.contains("Newton decrement"));
945        assert!(display.contains("2x1"));
946        assert!(display.contains("11 iteration"));
947        assert!(!display.contains("12345.6789"));
948        assert!(!display.contains("98765.4321"));
949        assert_eq!(
950            format!("{err:?}"),
951            display,
952            "Debug delegates to safe Display"
953        );
954        assert!(err.is_inner_solve_retreat());
955    }
956
957    // ── From<LinalgError> ─────────────────────────────────────────────────────
958
959    #[test]
960    fn from_linalg_invalid_input_maps_to_invalid_input() {
961        let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
962        let err = EstimationError::from(linalg_err);
963        assert!(matches!(err, EstimationError::InvalidInput(_)));
964        assert!(err.to_string().contains("linalg msg"));
965    }
966
967    #[test]
968    fn from_linalg_hessian_not_spd_maps_correctly() {
969        let linalg_err = LinalgError::HessianNotPositiveDefinite {
970            min_eigenvalue: -1.0,
971        };
972        let err = EstimationError::from(linalg_err);
973        assert!(matches!(
974            err,
975            EstimationError::HessianNotPositiveDefinite { .. }
976        ));
977    }
978}
979
980/// Honest failure text for [`EstimationError::HessianNotPositiveDefinite`].
981///
982/// A failed Cholesky with a strictly POSITIVE reported minimum eigenvalue is
983/// not an indefinite matrix — it is a positive spectrum whose condition
984/// number exceeds float precision (the pivots collapse under roundoff), or a
985/// non-finite assembly. Saying "not positive definite (minimum eigenvalue:
986/// 4.1e1)" sent debugging at the wrong defect class (#2316 triage), so the
987/// message now names the regime the eigenvalue actually indicates.
988fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
989    if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
990        format!(
991            "Hessian factorization failed although the (lower-triangle) spectrum is positive \
992             (minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
993             precision or the assembled matrix is asymmetric/non-finite outside the factored \
994             triangle. This indicates a numerical instability in the Hessian assembly or scaling."
995        )
996    } else {
997        format!(
998            "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
999             This indicates a numerical instability."
1000        )
1001    }
1002}