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 StationarityRung {
32    /// A zero-dimensional outer problem (#2530): no smoothing estimand exists,
33    /// so the score is empty and exactly stationary *by construction* rather
34    /// than by clearing any band. The `bound` on such a certificate is a
35    /// formality — no ladder ran, because there was nothing to weigh — and
36    /// borrowing a gradient rung for it would claim a comparison that never
37    /// happened, which is the same error `NoComparison` exists to prevent one
38    /// level down.
39    pub const EMPTY_ESTIMAND: Self = Self {
40        label: "empty-estimand",
41        derived_standard: false,
42    };
43}
44
45impl std::fmt::Display for StationarityRung {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(
48            f,
49            "rung={} derived_standard={}",
50            self.label, self.derived_standard
51        )
52    }
53}
54
55/// What a non-convergence refusal was decided against (#2458/#2465).
56///
57/// A verdict is only falsifiable from the run record if it carries the quantity
58/// it was decided against, and for a stationarity refusal that quantity is two
59/// facts travelling together: the bound, and the rung that produced it. They
60/// used to be two independent fields — `stationarity_bound: f64` beside
61/// `stationarity_bound_rung: Option<StationarityRung>` — and the `Option` was a
62/// hole any call site with a number to hand could take. Twenty of the outer
63/// runner's thirty refusal paths took it.
64///
65/// Bundling the pair was not the whole defect. Most of those twenty refuse
66/// *before any stationarity comparison exists*: a failed terminal evaluation, a
67/// malformed gradient, a non-converged inner state. They reported the raw
68/// configured tolerance — a constant the point was never weighed against — in a
69/// sentence reading "projected gradient norm … against stationarity bound …",
70/// asserting a pairing the code does not have. Naming that constant with a rung
71/// makes the sentence *more* confident, not more honest; the fix is to report no
72/// bound, because none was applied.
73///
74/// [`Self::Measured`] therefore means something specific and checkable: the
75/// bound is a property of *this* point, derived from its own evidence by the
76/// named rung, and the reported residual is the quantity weighed against it.
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78// `StationarityRung::label` is a `&'static str` — a fixed vocabulary, not run
79// data — so a borrowing deserializer can only produce one from a `'static`
80// input. Stating that here keeps the rung's stable-label representation rather
81// than allocating a `String` per refusal to satisfy the derive.
82#[serde(bound(deserialize = "'de: 'static"))]
83pub enum StationarityStandard {
84    /// A stationarity residual measured at this point was weighed against
85    /// `bound`, which `rung` derived from this point's own evidence.
86    Measured {
87        bound: f64,
88        rung: StationarityRung,
89    },
90    /// The refusal was decided without any stationarity comparison — the
91    /// terminal evidence was rejected before a residual existed, or the
92    /// predicate was an identity/existence check rather than a bound test. The
93    /// refusal's `reason` carries the basis; no bound is reported because none
94    /// was applied.
95    NoComparison,
96}
97
98impl StationarityStandard {
99    /// The bound, when one was applied.
100    pub fn bound(&self) -> Option<f64> {
101        match self {
102            Self::Measured { bound, .. } => Some(*bound),
103            Self::NoComparison => None,
104        }
105    }
106
107    /// The rung that produced the bound, when one was applied.
108    pub fn rung(&self) -> Option<StationarityRung> {
109        match self {
110            Self::Measured { rung, .. } => Some(*rung),
111            Self::NoComparison => None,
112        }
113    }
114}
115
116impl std::fmt::Display for StationarityStandard {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::Measured { bound, rung } => {
120                write!(f, "against stationarity bound {bound:.3e} ({rung})")
121            }
122            Self::NoComparison => f.write_str(
123                "against no stationarity bound: this refusal was decided by the reason \
124                 above, not by a stationarity comparison",
125            ),
126        }
127    }
128}
129
130/// What an assembly-time convergence gate weighed (#2427/#2530).
131///
132/// A residual and the bound it was weighed against are one fact, not two. They
133/// used to be two independent `Option<f64>` fields filled from DIFFERENT
134/// sources with asymmetric fallbacks: the residual fell back to the exported
135/// outer gradient norm when no certificate existed, the bound had no fallback.
136/// A run with no certificate therefore rendered `stationarity residual
137/// Some(0.0) against None` -- a residual weighed against nothing, printed as
138/// though a comparison had happened, and recorded verbatim on #2471.
139///
140/// [`EstimationError::RemlDidNotConverge`] already forbids that shape one
141/// variant up. This is the same repair here, and it REMOVES the fallback
142/// rather than symmetrising it: if no certificate was assembled then no
143/// first-order comparison was made, and substituting a norm of different
144/// provenance is an absent measurement scored as a value.
145#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
146pub enum FitStationarityEvidence {
147    /// A residual measured at the rejected point, weighed against `bound`.
148    /// Both come from the SAME certificate, so they are one comparison.
149    Certified { residual: f64, bound: f64 },
150    /// No comparison was made: there was no certificate to take a residual and
151    /// a bound from. The refusal's status strings carry the basis.
152    NoComparison,
153}
154
155impl std::fmt::Display for FitStationarityEvidence {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            Self::Certified { residual, bound } => {
159                write!(f, "residual {residual:.3e} against bound {bound:.3e}")
160            }
161            Self::NoComparison => f.write_str("not compared: no certificate was assembled"),
162        }
163    }
164}
165
166/// Fixed-lambda solver stage that owns a resumable coefficient checkpoint.
167///
168/// The multinomial fitter has two distinct objectives: the ordinary softmax
169/// likelihood and the Firth/Jeffreys separation refit. Recording the stage is
170/// therefore part of correctness: a Firth checkpoint must resume the Firth
171/// objective rather than being mistaken for an ordinary multinomial start.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173pub enum FixedLambdaSolverStage {
174    BinomialMultiNewton,
175    MultinomialNewton,
176    MultinomialFirth,
177}
178
179impl core::fmt::Display for FixedLambdaSolverStage {
180    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
181        f.write_str(match self {
182            Self::BinomialMultiNewton => "binomial-multi Newton",
183            Self::MultinomialNewton => "multinomial Newton",
184            Self::MultinomialFirth => "multinomial Firth/Jeffreys Newton",
185        })
186    }
187}
188
189/// Exhaustive terminal reason for a fixed-lambda solve without a certificate.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub enum FixedLambdaStallReason {
192    IterationBudgetExhausted,
193    LineSearchExhausted,
194    StationarityCertificateFailed,
195}
196
197impl core::fmt::Display for FixedLambdaStallReason {
198    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
199        f.write_str(match self {
200            Self::IterationBudgetExhausted => "iteration budget exhausted",
201            Self::LineSearchExhausted => "line search exhausted without an accepted step",
202            Self::StationarityCertificateFailed => "stationarity certificate failed",
203        })
204    }
205}
206
207/// Solver-native first-order residual carried by a fixed-lambda stall.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209pub enum FixedLambdaResidualKind {
210    /// Euclidean norm of the exact penalized likelihood gradient.
211    PenalizedGradientNorm,
212    /// Firth/Jeffreys Newton decrement `0.5 * |score' H^-1 score|`.
213    NewtonDecrement,
214}
215
216impl core::fmt::Display for FixedLambdaResidualKind {
217    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
218        f.write_str(match self {
219            Self::PenalizedGradientNorm => "penalized gradient norm",
220            Self::NewtonDecrement => "Newton decrement",
221        })
222    }
223}
224
225/// Evidence from the exact stationarity check at the last accepted iterate.
226#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
227pub struct FixedLambdaStationarityEvidence {
228    pub kind: FixedLambdaResidualKind,
229    pub residual: f64,
230    pub bound: f64,
231}
232
233impl core::fmt::Display for FixedLambdaStationarityEvidence {
234    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
235        write!(
236            f,
237            "{} {:.6e} against bound {:.6e}",
238            self.kind, self.residual, self.bound
239        )
240    }
241}
242
243/// Owned, serde-safe coefficient checkpoint for a fixed-lambda Newton solve.
244///
245/// Coefficients are row-major with shape `(rows, cols)`, where rows are the
246/// per-output coefficient count and columns are active outputs/classes. The
247/// values deliberately remain private so diagnostics cannot accidentally dump
248/// a potentially large coefficient vector; resume code accesses them through
249/// [`Self::values`] after calling [`Self::validate`].
250#[derive(Clone, PartialEq, Serialize, Deserialize)]
251pub struct FixedLambdaCheckpoint {
252    stage: FixedLambdaSolverStage,
253    coefficients_row_major: Vec<f64>,
254    rows: usize,
255    cols: usize,
256    completed_iterations: usize,
257}
258
259impl FixedLambdaCheckpoint {
260    pub fn new(
261        stage: FixedLambdaSolverStage,
262        coefficients_row_major: Vec<f64>,
263        rows: usize,
264        cols: usize,
265        completed_iterations: usize,
266    ) -> Result<Self, String> {
267        let checkpoint = Self {
268            stage,
269            coefficients_row_major,
270            rows,
271            cols,
272            completed_iterations,
273        };
274        checkpoint.validate()?;
275        Ok(checkpoint)
276    }
277
278    /// Validate persisted checkpoint geometry and coefficient finiteness before
279    /// rebuilding an ndarray view in a resumed solver.
280    pub fn validate(&self) -> Result<(), String> {
281        if self.rows == 0 || self.cols == 0 {
282            return Err(format!(
283                "fixed-lambda checkpoint shape must be nonempty, got {}x{}",
284                self.rows, self.cols
285            ));
286        }
287        let expected = self.rows.checked_mul(self.cols).ok_or_else(|| {
288            format!(
289                "fixed-lambda checkpoint shape {}x{} overflows usize",
290                self.rows, self.cols
291            )
292        })?;
293        if self.coefficients_row_major.len() != expected {
294            return Err(format!(
295                "fixed-lambda checkpoint has {} coefficient values, expected {} for shape {}x{}",
296                self.coefficients_row_major.len(),
297                expected,
298                self.rows,
299                self.cols
300            ));
301        }
302        if let Some((index, _)) = self
303            .coefficients_row_major
304            .iter()
305            .copied()
306            .enumerate()
307            .find(|(_, value)| !value.is_finite())
308        {
309            return Err(format!(
310                "fixed-lambda checkpoint coefficient {index} must be finite"
311            ));
312        }
313        Ok(())
314    }
315
316    pub fn stage(&self) -> FixedLambdaSolverStage {
317        self.stage
318    }
319
320    pub fn values(&self) -> &[f64] {
321        &self.coefficients_row_major
322    }
323
324    pub fn rows(&self) -> usize {
325        self.rows
326    }
327
328    pub fn cols(&self) -> usize {
329        self.cols
330    }
331
332    pub fn completed_iterations(&self) -> usize {
333        self.completed_iterations
334    }
335}
336
337impl core::fmt::Display for FixedLambdaCheckpoint {
338    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
339        write!(
340            f,
341            "{} checkpoint {}x{} after {} iteration(s)",
342            self.stage, self.rows, self.cols, self.completed_iterations
343        )
344    }
345}
346
347impl core::fmt::Debug for FixedLambdaCheckpoint {
348    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
349        core::fmt::Display::fmt(self, f)
350    }
351}
352
353/// The exact error thrown across a fatal outer-objective boundary.
354///
355/// Outer orchestration receives failures through two APIs. Direct evaluators
356/// return [`EstimationError`], while optimizer-facing evaluators return
357/// [`opt::ObjectiveEvalError`], which owns both the producer's recoverable/fatal
358/// verdict and (when available) its typed source. Flattening the latter through
359/// `into_message()` and minting a fresh `RemlOptimizationFailed` destroys that
360/// source precisely where terminal classification and FFI dispatch need it.
361///
362/// This enum is the single owner of that distinction. The recursive
363/// `EstimationError` arm is boxed for a finite representation; the optimizer
364/// arm is retained whole, including its source chain and producer verdict.
365#[derive(Debug, thiserror::Error)]
366pub enum OuterObjectiveErrorSource {
367    #[error(transparent)]
368    Estimation(Box<EstimationError>),
369    #[error(transparent)]
370    Objective(opt::ObjectiveEvalError),
371}
372
373impl OuterObjectiveErrorSource {
374    /// Recover an engine error without inspecting rendered prose.
375    ///
376    /// `ObjectiveEvalError` sources created by gam's objective bridge carry the
377    /// originating `EstimationError` directly. A source owned by another
378    /// optimizer client remains typed as that client's error and correctly
379    /// returns `None`.
380    #[must_use]
381    pub fn estimation_error(&self) -> Option<&EstimationError> {
382        match self {
383            Self::Estimation(source) => Some(source),
384            Self::Objective(source) => source.downcast_ref::<EstimationError>(),
385        }
386    }
387
388    /// The optimizer-facing error, when this boundary was crossed through opt.
389    #[must_use]
390    pub fn objective_error(&self) -> Option<&opt::ObjectiveEvalError> {
391        match self {
392            Self::Estimation(_) => None,
393            Self::Objective(source) => Some(source),
394        }
395    }
396}
397
398/// A comprehensive error type for the model estimation process.
399#[derive(thiserror::Error)]
400pub enum EstimationError {
401    #[error(transparent)]
402    InvalidStabilization(#[from] crate::InvalidStabilization),
403
404    #[error("Underlying basis function generation failed: {0}")]
405    BasisError(#[from] BasisError),
406
407    #[error("Custom-family fit failed: {0}")]
408    CustomFamily(#[from] CustomFamilyError),
409
410    #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
411    LinearSystemSolveFailed(FaerLinalgError),
412
413    #[error("Eigendecomposition failed: {0}")]
414    EigendecompositionFailed(FaerLinalgError),
415
416    #[error(
417        "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
418    )]
419    PenaltySpectrumNonFinite {
420        context: String,
421        index: usize,
422        value: f64,
423    },
424
425    #[error(
426        "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
427    )]
428    PenaltySpectrumIndefinite {
429        context: String,
430        index: usize,
431        value: f64,
432        tolerance: f64,
433        scale: f64,
434    },
435
436    #[error("Parameter constraint violation: {0}")]
437    ParameterConstraintViolation(String),
438
439    #[error(
440        "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
441    )]
442    PirlsDidNotConverge {
443        max_iterations: usize,
444        last_change: f64,
445    },
446
447    #[error(
448        "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
449         {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
450         only minted from a converged optimization; resume by passing the carried checkpoint \
451         through the fixed-lambda input's `resume_from` field ({checkpoint}).",
452        .checkpoint.completed_iterations()
453    )]
454    FixedLambdaNewtonDidNotConverge {
455        /// Which fixed-λ Newton entry stalled (e.g. the multinomial softmax or
456        /// independent-binomial vector-GLM solve, or the Firth refit lane).
457        context: String,
458        /// Why the solver stopped without its convergence certificate.
459        reason: FixedLambdaStallReason,
460        /// Final value of the solver's minimized criterion. For ordinary vector
461        /// GLMs this is `-log L + penalty`; for the Firth lane it also includes
462        /// the negative Jeffreys `0.5 log det(I)` contribution.
463        objective_value: f64,
464        /// Exact first-order residual and the bound it failed to clear.
465        stationarity: FixedLambdaStationarityEvidence,
466        /// Last accepted coefficients and cumulative iteration count. This is
467        /// work-preservation state, not a fitted model, and carries no covariance
468        /// or prediction surface.
469        checkpoint: FixedLambdaCheckpoint,
470    },
471
472    #[error(
473        "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
474         max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
475         minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
476         {profile_curvature_roundoff:.3e}; last scale fixed-point step \
477         {last_scale_step:.6e}{}). \
478         A fit is only minted from a converged optimization; resume from the \
479         checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
480        if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
481    )]
482    BlockOrthogonalRemlDidNotConverge {
483        /// Outer alternation passes executed before exhaustion.
484        iterations: usize,
485        /// Largest per-block |dV/drho| at the final iterate, normalized by the
486        /// score's natural magnitude `d * max(1, rank)`.
487        max_score_residual: f64,
488        /// Tolerance the residual had to meet for the convergence certificate.
489        score_tol: f64,
490        /// Smallest eigenvalue of the analytic rho Hessian after profiling out
491        /// the exact conditional scale block.
492        min_profile_curvature: f64,
493        /// Dimension-scaled eigensolver roundoff allowed below zero when
494        /// certifying positive semidefiniteness.
495        profile_curvature_roundoff: f64,
496        /// Last max |Δ log scale-precision| fixed-point movement (evidence of
497        /// whether the alternation was still moving or had stalled).
498        last_scale_step: f64,
499        /// The alternation revisited an earlier `(rho, scale)` state exactly;
500        /// as a deterministic map it can never certify, so it stopped early.
501        cycle_detected: bool,
502        /// Per-block log-lambda iterates at exhaustion; feed back through the
503        /// entry point's `init_rhos` to resume rather than restart.
504        rho_checkpoint: Vec<f64>,
505    },
506
507    #[error(
508        "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
509         {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
510         {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
511         against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
512         partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
513         and rho={rho_checkpoint:?}."
514    )]
515    NegativeBinomialAlternationDidNotConverge {
516        /// Joint block-coordinate rounds executed before exhaustion.
517        rounds: usize,
518        /// Conditional theta coordinate at the best measured checkpoint.
519        theta_checkpoint: f64,
520        /// KKT-projected rho-gradient norm at that checkpoint.
521        rho_projected_grad_norm: f64,
522        /// Bound the rho residual had to clear.
523        rho_stationarity_bound: f64,
524        /// Curvature-normalized log-theta score residual at that checkpoint.
525        theta_score_residual: f64,
526        /// Bound the theta residual had to clear.
527        theta_stationarity_bound: f64,
528        /// Best measured log-smoothing checkpoint for warm-started resume.
529        rho_checkpoint: Vec<f64>,
530    },
531
532    #[error(
533        "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
534        The model cannot converge because a predictor perfectly separates the binary outcomes. \
535        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
536    )]
537    PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
538
539    #[error(
540        "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
541        has a threshold {threshold:.6e} that separates the binary outcomes \
542        (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
543        enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
544    )]
545    PrefitPerfectSeparationDetected {
546        column_index: usize,
547        threshold: f64,
548        positive_above_threshold: bool,
549    },
550
551    #[error(
552        "Pre-fit linear separation detected in the realized binomial inverse-link design: \
553        {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
554        with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
555        The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
556        remove/reparameterize/penalize the separating columns."
557    )]
558    PrefitLinearSeparationDetected {
559        min_signed_margin: f64,
560        num_unpenalized_columns: usize,
561        column_indices: Vec<usize>,
562    },
563
564    #[error(
565        "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
566        unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
567        Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
568    )]
569    PrefitRankDeficientDesignDetected {
570        rank: usize,
571        num_unpenalized_columns: usize,
572        min_eigenvalue: f64,
573        tolerance: f64,
574        column_indices: Vec<usize>,
575    },
576
577    #[error(
578        "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
579        unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
580        exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
581        columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
582        so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
583        penalty/constraint before fitting."
584    )]
585    PrefitNearDegenerateDesignDetected {
586        num_unpenalized_columns: usize,
587        condition_number: f64,
588        min_eigenvalue: f64,
589        max_eigenvalue: f64,
590        tolerance: f64,
591        column_indices: Vec<usize>,
592    },
593
594    #[error(
595        "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
596        The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
597        so the unpenalized softmax MLE is not finite in that direction. \
598        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
599    )]
600    MultinomialSeparationDetected {
601        iteration: usize,
602        max_abs_eta: f64,
603        active_class_index: usize,
604        row_index: usize,
605    },
606
607    #[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
608    HessianNotPositiveDefinite { min_eigenvalue: f64 },
609
610    #[error("REML smoothing optimization failed to converge: {0}")]
611    RemlOptimizationFailed(String),
612
613    /// A numerical refusal evaluated AT ONE TRIAL POINT of the outer smoothing
614    /// search: no Laplace mode at this rho, an inner solve that missed its KKT
615    /// bar at this rho, an indefinite trial Hessian at this rho.
616    ///
617    /// The outer search's response to this is to map the point to
618    /// `OuterEval::infeasible` and step away — which is a normal thing for a
619    /// lambda-search to consume, and the only response it *has*, since the only
620    /// thing it can change is rho. Saying so in the type is the whole point:
621    /// these refusals used to be reported as
622    /// [`InvalidInput`](Self::InvalidInput) or `RemlOptimizationFailed`, both
623    /// of which carry prose and both of which
624    /// [`Self::is_trial_point_infeasible`] answers `false` for, so a correct
625    /// per-rho verdict aborted the entire fit (#2531, #2590).
626    ///
627    /// It renders as the bare reason so a producer switching to it does not
628    /// change the message a user or a regression test reads.
629    #[error("{reason}")]
630    TrialPointRefused { reason: String },
631
632    #[error("Fatal outer-objective evaluation failure ({context}): {source}")]
633    OuterObjectiveEvaluationFailed {
634        context: String,
635        #[source]
636        source: OuterObjectiveErrorSource,
637    },
638
639    #[error(
640        "Outer smoothing-parameter optimization did not certify a stationary optimum \
641         ({context}): {reason} after {iterations} outer iteration(s); final objective \
642         {final_value:.6e}, projected gradient norm {} {stationarity_standard}. A fit is \
643         only minted from a converged optimization; the best iterate is carried as a \
644         checkpoint — resume by seeding the outer search at rho_checkpoint = \
645         {rho_checkpoint:?}.",
646        .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}")),
647    )]
648    RemlDidNotConverge {
649        /// Fit context label (the same string the outer runner logs under).
650        context: String,
651        /// Which certificate failed: budget exhaustion, line-search collapse,
652        /// non-stationary cost stall, or a failed post-solve stationarity
653        /// certificate.
654        reason: String,
655        /// Outer iterations executed across all solver restarts.
656        iterations: usize,
657        /// Objective value at the abandoned best iterate.
658        final_value: f64,
659        /// KKT-projected gradient norm at the best iterate, when the solver
660        /// measured a gradient there (`None` for gradient-free exits).
661        projected_grad_norm: Option<f64>,
662        /// The standard this refusal was decided against: the bound together
663        /// with the rung that produced it, or an explicit statement that no
664        /// stationarity comparison was made (#2458/#2465). They are ONE field
665        /// precisely so neither can be reported without the other, and so that
666        /// a route which never formed a bound cannot print one.
667        stationarity_standard: StationarityStandard,
668        /// Best (lowest-objective feasible) outer iterate at exhaustion. This
669        /// is work-preservation evidence for resume — it is NOT a fit and no
670        /// fitted-model API is reachable from it.
671        rho_checkpoint: Vec<f64>,
672    },
673
674    #[error(
675        "Fit assembly rejected a non-converged optimization state: inner status \
676         {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
677         iteration(s); final objective {}; stationarity {stationarity}, \
678         step {step}. The best rho checkpoint is \
679         {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
680         API was constructed.",
681        .final_value.map_or_else(
682            || "unavailable (this fit has no criterion value)".to_string(),
683            |value| format!("{value:.6e}"),
684        ),
685    )]
686    FitDidNotConverge {
687        /// Diagnostic inner-solver terminal status. This is deliberately a
688        /// string at the neutral problem layer; concrete solver status enums
689        /// live in downstream fitting crates.
690        inner_status: String,
691        /// Outer terminal/certificate verdict.
692        outer_status: String,
693        /// Completed outer iterations at the rejected checkpoint.
694        outer_iterations: usize,
695        /// Objective value at the best available checkpoint, or `None` when the
696        /// rejected fit has no criterion value at all (the exact-fit Gaussian
697        /// boundary). A refusal must not invent an objective it could not read.
698        final_value: Option<f64>,
699        /// The first-order residual together with the bound it was weighed
700        /// against, or an explicit statement that no comparison was made.
701        stationarity: FitStationarityEvidence,
702        /// The accepted-step residual and its bound, same rule. Currently
703        /// always `NoComparison` at the sole production site -- which is what
704        /// the type should say, rather than leaving two independent `Option`s
705        /// armed with the identical hazard for whoever wires them up.
706        step: FitStationarityEvidence,
707        /// Work-preserving smoothing checkpoint; this is not a fit.
708        rho_checkpoint: Vec<f64>,
709        /// Opaque durable-cache resume token, when checkpoint persistence was
710        /// enabled for the failed run.
711        resume_token: Option<String>,
712    },
713
714    #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
715    GradientUnavailable {
716        context: &'static str,
717        mode: &'static str,
718    },
719
720    #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
721    LayoutError(String),
722
723    #[error(
724        "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."
725    )]
726    ModelIsIllConditioned { condition_number: f64 },
727
728    #[error("Invalid input: {0}")]
729    InvalidInput(String),
730
731    #[error(
732        "Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
733         interval [{lower}, {upper}]"
734    )]
735    InverseLinkDomainViolation {
736        link: &'static str,
737        eta: f64,
738        lower: f64,
739        upper: f64,
740    },
741
742    #[error(
743        "PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
744         eta={eta:?} produced {value:?}"
745    )]
746    PirlsRowGeometryUnrepresentable {
747        row: usize,
748        quantity: &'static str,
749        eta: f64,
750        value: f64,
751    },
752
753    #[error(
754        "Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
755    )]
756    ExactTweedieSeriesWorkLimit {
757        row: usize,
758        required_terms_lower_bound: f64,
759        budget: usize,
760    },
761
762    #[error(
763        "Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
764         the supported interval [{lower}, {upper}]"
765    )]
766    LogStrengthDomainViolation {
767        coordinate: usize,
768        value: f64,
769        lower: f64,
770        upper: f64,
771    },
772
773    #[error("monotone root solve: {0}")]
774    MonotoneRoot(#[from] MonotoneRootError),
775
776    #[error("Calibrator training failed: {0}")]
777    CalibratorTrainingFailed(String),
778
779    #[error("Invalid specification: {0}")]
780    InvalidSpecification(String),
781
782    #[error("Prediction error")]
783    PredictionError,
784}
785
786// Ensure Debug prints with actual line breaks by delegating to Display
787impl core::fmt::Debug for EstimationError {
788    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
789        write!(f, "{}", self)
790    }
791}
792
793impl EstimationError {
794    /// Whether this failure invalidates the whole outer run or only the
795    /// trial point it was produced at.
796    ///
797    /// The outer optimizer can survive an infeasible trial: it maps the
798    /// point to `OuterEval::infeasible`, backs off, and continues. It
799    /// cannot survive a structural failure. Deciding which is which is
800    /// the producer's job, and the answer must travel with the error
801    /// rather than be reconstructed downstream from its rendered text
802    /// (#2553).
803    ///
804    /// Only failures that are genuinely a property of *this theta* answer
805    /// `true`. Everything else stays fatal, which is the conservative
806    /// direction: misclassifying a structural failure as recoverable
807    /// would let the search grind through a problem that can never work.
808    /// The match is deliberately exhaustive with no wildcard arm, for the
809    /// same reason [`CustomFamilyError::is_trial_point_infeasible`] is: under
810    /// a `_ => false` a newly added variant is classified *fatal* by the
811    /// absence of a decision, and whoever adds it is never asked. That is how
812    /// a rho-local refusal reached this function as `RemlOptimizationFailed` —
813    /// a variant that carries only prose — and aborted a fit the outer search
814    /// was equipped to walk away from (#2590).
815    #[must_use]
816    pub fn is_trial_point_infeasible(&self) -> bool {
817        match self {
818            // The producer classified it; ask it (#2553).
819            Self::CustomFamily(err) => err.is_trial_point_infeasible(),
820            // The producer said so directly (#2531).
821            Self::TrialPointRefused { .. } => true,
822            // "The inner problem at THIS rho is too hard to evaluate, try a
823            // different rho" — [`Self::is_inner_solve_retreat`]'s own words for
824            // exactly these five, and verbatim this predicate's definition. The
825            // two used to disagree, so a P-IRLS budget exhaustion was a retreat
826            // at one layer and a fatal at this one; #2593 unified them, and
827            // `is_inner_solve_retreat` now reads this table rather than keeping
828            // a second one.
829            Self::ModelIsIllConditioned { .. }
830            | Self::PerfectSeparationDetected { .. }
831            | Self::MultinomialSeparationDetected { .. }
832            | Self::PirlsDidNotConverge { .. }
833            | Self::FixedLambdaNewtonDidNotConverge { .. } => true,
834            // A structural failure, an already-terminal outer verdict, or a
835            // statement about the configuration, the data, or the prediction
836            // request: none of these becomes true or false by moving rho.
837            Self::InvalidStabilization { .. }
838            | Self::BasisError { .. }
839            | Self::LinearSystemSolveFailed { .. }
840            | Self::EigendecompositionFailed { .. }
841            | Self::PenaltySpectrumNonFinite { .. }
842            | Self::PenaltySpectrumIndefinite { .. }
843            | Self::ParameterConstraintViolation { .. }
844            | Self::BlockOrthogonalRemlDidNotConverge { .. }
845            | Self::NegativeBinomialAlternationDidNotConverge { .. }
846            | Self::PrefitPerfectSeparationDetected { .. }
847            | Self::PrefitLinearSeparationDetected { .. }
848            | Self::PrefitRankDeficientDesignDetected { .. }
849            | Self::PrefitNearDegenerateDesignDetected { .. }
850            | Self::HessianNotPositiveDefinite { .. }
851            | Self::RemlOptimizationFailed { .. }
852            | Self::OuterObjectiveEvaluationFailed { .. }
853            | Self::RemlDidNotConverge { .. }
854            | Self::FitDidNotConverge { .. }
855            | Self::GradientUnavailable { .. }
856            | Self::LayoutError { .. }
857            | Self::InvalidInput { .. }
858            | Self::InverseLinkDomainViolation { .. }
859            | Self::PirlsRowGeometryUnrepresentable { .. }
860            | Self::ExactTweedieSeriesWorkLimit { .. }
861            | Self::LogStrengthDomainViolation { .. }
862            | Self::MonotoneRoot { .. }
863            | Self::CalibratorTrainingFailed { .. }
864            | Self::InvalidSpecification { .. }
865            | Self::PredictionError { .. } => false,
866        }
867    }
868
869    /// Preserve a thrown outer-objective failure across seed, solver, and
870    /// fallback-plan orchestration. Trial-domain refusals must be represented
871    /// as a finite API outcome (`+inf` / `OuterEval::infeasible`); an `Err`
872    /// means the evaluation artifact itself could not be constructed and must
873    /// never be retried as another numerical point.
874    pub fn fatal_outer_evaluation(context: impl Into<String>, source: EstimationError) -> Self {
875        if matches!(
876            &source,
877            EstimationError::OuterObjectiveEvaluationFailed { .. }
878        ) {
879            source
880        } else {
881            EstimationError::OuterObjectiveEvaluationFailed {
882                context: context.into(),
883                source: OuterObjectiveErrorSource::Estimation(Box::new(source)),
884            }
885        }
886    }
887
888    /// Preserve an optimizer-facing fatal evaluator failure without reminting
889    /// its message as an unrelated [`Self::RemlOptimizationFailed`].
890    ///
891    /// The caller must have already consumed recoverable failures as rejected
892    /// trial points. Requiring the producer's fatal verdict here makes an
893    /// accidental promotion fail at the boundary that attempted it instead of
894    /// silently changing control flow.
895    pub fn fatal_objective_evaluation(
896        context: impl Into<String>,
897        source: opt::ObjectiveEvalError,
898    ) -> Self {
899        assert!(
900            source.is_fatal(),
901            "fatal_objective_evaluation requires a producer-classified fatal error"
902        );
903        EstimationError::OuterObjectiveEvaluationFailed {
904            context: context.into(),
905            source: OuterObjectiveErrorSource::Objective(source),
906        }
907    }
908
909    pub fn is_fatal_outer_evaluation(&self) -> bool {
910        matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
911    }
912
913    /// Classifies inner-solve failures that the outer REML loop should
914    /// treat as a soft retreat (return +inf cost / infeasible outer-eval)
915    /// rather than propagate as a hard error.
916    ///
917    /// Why: when the penalised Hessian becomes effectively singular at the
918    /// current rho, when P-IRLS hits a perfect-separation diagnostic, or when
919    /// it exhausts its iteration budget, the outer optimiser's correct
920    /// response is to back away from this rho — not to terminate the fit.
921    /// All three variants encode "the inner problem at this rho is too hard
922    /// to evaluate, try a different rho".
923    /// Re-report this failure with more context WITHOUT changing whether it
924    /// is a trial-point refusal.
925    ///
926    /// A wrapper that renders its source into a string and then picks a fresh
927    /// variant silently overwrites the producer's verdict. That is how a
928    /// per-rho survival-LAML stationarity refusal reached the outer boundary
929    /// as `InvalidInput` and killed the fit (#2531), and how a typed
930    /// `InnerSolveNotConverged` reached it as `RemlOptimizationFailed` and did
931    /// the same (#2590). Any site that adds context to an error it did not
932    /// produce should use this instead of choosing a variant for it.
933    #[must_use]
934    pub fn wrap_preserving_trial_point(self, context: &str) -> Self {
935        let infeasible = self.is_trial_point_infeasible();
936        let reason = format!("{context}: {self}");
937        if infeasible {
938            Self::TrialPointRefused { reason }
939        } else {
940            Self::InvalidInput(reason)
941        }
942    }
943
944    pub fn is_inner_solve_retreat(&self) -> bool {
945        // ONE table. This method and `is_trial_point_infeasible` ask the same
946        // question -- "is this a statement about this rho, or about the
947        // problem?" -- and used to answer it from two separate variant lists
948        // that had drifted apart. Keeping a second list here is what let them
949        // drift, so there is no longer a second list (#2593).
950        //
951        // The relation is delegation, not equality: `is_trial_point_infeasible`
952        // is strictly wider, because it also asks the producer through
953        // `CustomFamily(..)` and honours the typed `TrialPointRefused`. Every
954        // retreat is an infeasibility; not every infeasibility arrives as one
955        // of the five inner-solve shapes.
956        self.is_trial_point_infeasible()
957    }
958}
959
960#[cfg(test)]
961mod trial_point_classification_tests {
962    use super::*;
963
964    /// The two classifiers now agree by construction, and this is what pins
965    /// that: every inner-solve retreat is a trial-point infeasibility. The test
966    /// it replaces pinned the opposite, deliberately, so that unifying them had
967    /// to be a decision rather than a drift (#2593).
968    #[test]
969    fn every_inner_solve_retreat_is_a_trial_point_infeasibility() {
970        let retreats = [
971            EstimationError::ModelIsIllConditioned {
972                condition_number: 1.0e18,
973            },
974            EstimationError::PerfectSeparationDetected {
975                iteration: 3,
976                max_abs_eta: 1.0e3,
977            },
978            EstimationError::MultinomialSeparationDetected {
979                iteration: 3,
980                max_abs_eta: 1.0e3,
981                active_class_index: 1,
982                row_index: 2,
983            },
984            EstimationError::PirlsDidNotConverge {
985                max_iterations: 40,
986                last_change: 1.0e-2,
987            },
988            // The fifth shape. The fixture carried four while the table it
989            // covers -- and this test's own doc, five lines up -- says five, so
990            // the fixed-lambda Newton stall was the one arm nothing pinned.
991            // That is the retreat the multinomial and independent-binomial
992            // vector-GLM lanes actually emit, and it is the arm a regression
993            // flipping to `false` would have slipped past unnoticed.
994            EstimationError::FixedLambdaNewtonDidNotConverge {
995                context: "trial-point classification fixture".to_string(),
996                reason: FixedLambdaStallReason::IterationBudgetExhausted,
997                objective_value: 12.5,
998                stationarity: FixedLambdaStationarityEvidence {
999                    kind: FixedLambdaResidualKind::PenalizedGradientNorm,
1000                    residual: 1.0e-3,
1001                    bound: 1.0e-8,
1002                },
1003                checkpoint: FixedLambdaCheckpoint::new(
1004                    FixedLambdaSolverStage::MultinomialNewton,
1005                    vec![0.0, 0.0],
1006                    2,
1007                    1,
1008                    40,
1009                )
1010                .expect("fixture checkpoint geometry is valid"),
1011            },
1012        ];
1013        for error in retreats {
1014            assert!(
1015                error.is_inner_solve_retreat(),
1016                "fixture must be a retreat: {error}"
1017            );
1018            assert!(
1019                error.is_trial_point_infeasible(),
1020                "a retreat is by its own definition a trial-point infeasibility: {error}"
1021            );
1022        }
1023    }
1024
1025    /// The failure #2590 is about: a refusal produced at one rho must not be
1026    /// graded fatal because it crossed a boundary that kept only its text.
1027    #[test]
1028    fn a_custom_family_trial_point_refusal_stays_recoverable() {
1029        let reason = "joint Newton returned an indefinite mode at this rho";
1030        assert!(
1031            EstimationError::CustomFamily(CustomFamilyError::trial_point(reason))
1032                .is_trial_point_infeasible()
1033        );
1034        assert!(
1035            !EstimationError::RemlOptimizationFailed(reason.to_string())
1036                .is_trial_point_infeasible(),
1037            "the prose-only variant is exactly what must NOT carry a rho-local refusal"
1038        );
1039    }
1040}
1041
1042impl From<LinalgError> for EstimationError {
1043    fn from(error: LinalgError) -> Self {
1044        match error {
1045            LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
1046            LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
1047                EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
1048            }
1049            LinalgError::ModelIsIllConditioned { condition_number } => {
1050                EstimationError::ModelIsIllConditioned { condition_number }
1051            }
1052        }
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059
1060    // ── stationarity rung provenance (#2458) ─────────────────────────────────
1061
1062    fn reml_refusal(standard: StationarityStandard) -> EstimationError {
1063        EstimationError::RemlDidNotConverge {
1064            context: "unit".to_string(),
1065            reason: "budget exhausted".to_string(),
1066            iterations: 7,
1067            final_value: -1.25,
1068            projected_grad_norm: Some(7.5e-1),
1069            stationarity_standard: standard,
1070            rho_checkpoint: vec![0.5],
1071        }
1072    }
1073
1074    fn measured(label: &'static str, derived_standard: bool) -> StationarityStandard {
1075        StationarityStandard::Measured {
1076            bound: 1.0e-2,
1077            rung: StationarityRung {
1078                label,
1079                derived_standard,
1080            },
1081        }
1082    }
1083
1084    /// The whole point of the increment: a red states which standard it was
1085    /// held to, so a reader does not have to infer the rung from the numbers.
1086    #[test]
1087    fn refusal_message_carries_the_rung_and_whether_it_is_derived() {
1088        let derived = reml_refusal(measured("curvature-resolvability", true)).to_string();
1089        assert!(
1090            derived.contains("rung=curvature-resolvability"),
1091            "refusal must name its rung: {derived}"
1092        );
1093        assert!(
1094            derived.contains("derived_standard=true"),
1095            "refusal must say whether the rung is the derived standard: {derived}"
1096        );
1097
1098        let substitute = reml_refusal(measured("solver-band", false)).to_string();
1099        assert!(substitute.contains("rung=solver-band"), "{substitute}");
1100        assert!(
1101            substitute.contains("derived_standard=false"),
1102            "a gradient-magnitude substitute must not read as the derived standard: {substitute}"
1103        );
1104    }
1105
1106    /// A refusal reached before any stationarity comparison must not print a
1107    /// bound. The old shape filled the field with the raw configured tolerance
1108    /// and rendered "projected gradient norm … against stationarity bound …",
1109    /// which reads as a comparison that never happened — and naming the
1110    /// constant with a rung only made the false sentence more confident.
1111    #[test]
1112    fn a_refusal_without_a_comparison_reports_no_bound() {
1113        let message = reml_refusal(StationarityStandard::NoComparison).to_string();
1114        assert!(
1115            message.contains("against no stationarity bound"),
1116            "a refusal that applied no bound must say so: {message}"
1117        );
1118        assert!(
1119            !message.contains("rung="),
1120            "no rung may be claimed where no bound was applied: {message}"
1121        );
1122        assert!(
1123            !message.contains("1.000e-2"),
1124            "no bound value may appear where none was applied: {message}"
1125        );
1126    }
1127
1128    /// The carrier's whole purpose: a bound cannot be read without the rung that
1129    /// produced it, and a route with neither reports neither.
1130    #[test]
1131    fn the_bound_and_its_rung_are_one_field() {
1132        let standard = measured("probe-noise-floor", false);
1133        assert_eq!(standard.bound(), Some(1.0e-2));
1134        assert_eq!(
1135            standard.rung().map(|rung| rung.label),
1136            Some("probe-noise-floor")
1137        );
1138        assert_eq!(StationarityStandard::NoComparison.bound(), None);
1139        assert_eq!(StationarityStandard::NoComparison.rung(), None);
1140    }
1141
1142    /// The bound itself still reaches the message unchanged — the rung rides
1143    /// alongside it, it does not replace it.
1144    #[test]
1145    fn rung_rides_beside_the_bound_without_displacing_it() {
1146        let message = reml_refusal(measured("solver-band", false)).to_string();
1147        assert!(
1148            message.contains("1.000e-2"),
1149            "bound must survive: {message}"
1150        );
1151        assert!(
1152            message.contains("7.500e-1"),
1153            "projected gradient norm must survive: {message}"
1154        );
1155    }
1156
1157    // ── is_inner_solve_retreat ────────────────────────────────────────────────
1158
1159    #[test]
1160    fn model_ill_conditioned_is_retreat() {
1161        assert!(
1162            EstimationError::ModelIsIllConditioned {
1163                condition_number: 1e15
1164            }
1165            .is_inner_solve_retreat()
1166        );
1167    }
1168
1169    #[test]
1170    fn perfect_separation_is_retreat() {
1171        assert!(
1172            EstimationError::PerfectSeparationDetected {
1173                iteration: 3,
1174                max_abs_eta: 50.0
1175            }
1176            .is_inner_solve_retreat()
1177        );
1178    }
1179
1180    #[test]
1181    fn multinomial_separation_is_retreat() {
1182        assert!(
1183            EstimationError::MultinomialSeparationDetected {
1184                iteration: 1,
1185                max_abs_eta: 100.0,
1186                active_class_index: 2,
1187                row_index: 7
1188            }
1189            .is_inner_solve_retreat()
1190        );
1191    }
1192
1193    #[test]
1194    fn pirls_did_not_converge_is_retreat() {
1195        assert!(
1196            EstimationError::PirlsDidNotConverge {
1197                max_iterations: 100,
1198                last_change: 1e-3
1199            }
1200            .is_inner_solve_retreat()
1201        );
1202    }
1203
1204    #[test]
1205    fn invalid_input_is_not_retreat() {
1206        assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
1207    }
1208
1209    #[test]
1210    fn reml_optimization_failed_is_not_retreat() {
1211        assert!(
1212            !EstimationError::RemlOptimizationFailed("outer fail".to_string())
1213                .is_inner_solve_retreat()
1214        );
1215    }
1216
1217    #[test]
1218    fn fatal_outer_evaluation_is_typed_and_idempotent() {
1219        let error = EstimationError::fatal_outer_evaluation(
1220            "seed screening",
1221            EstimationError::InvalidInput("frame mismatch".to_string()),
1222        );
1223        assert!(error.is_fatal_outer_evaluation());
1224        assert!(error.to_string().contains("frame mismatch"));
1225
1226        let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
1227        assert!(nested.is_fatal_outer_evaluation());
1228        assert_eq!(
1229            nested.to_string().matches("Fatal outer-objective").count(),
1230            1,
1231            "fatal provenance must not be re-wrapped at every orchestration layer"
1232        );
1233    }
1234
1235    #[test]
1236    fn fatal_optimizer_evaluation_retains_exact_typed_source_2658() {
1237        let source = EstimationError::CustomFamily(CustomFamilyError::InnerSolveNotConverged {
1238            cycles: 17,
1239            terminal: None,
1240            kkt_residual: Some(3.5),
1241            kkt_tol: Some(0.25),
1242            theta_dim: 4,
1243            rho_dim: 3,
1244            psi_dim: 1,
1245        });
1246        let error = EstimationError::fatal_objective_evaluation(
1247            "outer fixed-point evaluation",
1248            opt::ObjectiveEvalError::fatal_from(source),
1249        );
1250
1251        let EstimationError::OuterObjectiveEvaluationFailed { source, .. } = &error else {
1252            panic!("fatal objective error must retain its boundary type");
1253        };
1254        assert!(
1255            source
1256                .objective_error()
1257                .is_some_and(|error| error.is_fatal())
1258        );
1259        let Some(EstimationError::CustomFamily(CustomFamilyError::InnerSolveNotConverged {
1260            cycles,
1261            kkt_residual,
1262            kkt_tol,
1263            theta_dim,
1264            rho_dim,
1265            psi_dim,
1266            ..
1267        })) = source.estimation_error()
1268        else {
1269            panic!("typed custom-family source was flattened or reminted");
1270        };
1271        assert_eq!(
1272            (
1273                *cycles,
1274                *kkt_residual,
1275                *kkt_tol,
1276                *theta_dim,
1277                *rho_dim,
1278                *psi_dim,
1279            ),
1280            (17, Some(3.5), Some(0.25), 4, 3, 1)
1281        );
1282    }
1283
1284    // ── error message content ─────────────────────────────────────────────────
1285
1286    #[test]
1287    fn invalid_input_message_appears_in_display() {
1288        let err = EstimationError::InvalidInput("test_message".to_string());
1289        assert!(err.to_string().contains("test_message"));
1290    }
1291
1292    #[test]
1293    fn pirls_did_not_converge_mentions_max_iterations() {
1294        let err = EstimationError::PirlsDidNotConverge {
1295            max_iterations: 42,
1296            last_change: 0.001,
1297        };
1298        assert!(err.to_string().contains("42"));
1299    }
1300
1301    #[test]
1302    fn fixed_lambda_checkpoint_validates_shape_and_values() {
1303        let checkpoint = FixedLambdaCheckpoint::new(
1304            FixedLambdaSolverStage::MultinomialNewton,
1305            vec![1.0, 2.0, 3.0, 4.0],
1306            2,
1307            2,
1308            7,
1309        )
1310        .expect("well-shaped finite checkpoint");
1311        assert_eq!(
1312            checkpoint.stage(),
1313            FixedLambdaSolverStage::MultinomialNewton
1314        );
1315        assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
1316        assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
1317        assert_eq!(checkpoint.completed_iterations(), 7);
1318
1319        assert!(
1320            FixedLambdaCheckpoint::new(
1321                FixedLambdaSolverStage::BinomialMultiNewton,
1322                vec![1.0],
1323                2,
1324                1,
1325                0,
1326            )
1327            .is_err(),
1328            "coefficient length must match rows * cols"
1329        );
1330        assert!(
1331            FixedLambdaCheckpoint::new(
1332                FixedLambdaSolverStage::BinomialMultiNewton,
1333                vec![f64::NAN],
1334                1,
1335                1,
1336                0,
1337            )
1338            .is_err(),
1339            "checkpoint coefficients must be finite"
1340        );
1341        assert!(
1342            FixedLambdaCheckpoint::new(
1343                FixedLambdaSolverStage::MultinomialFirth,
1344                Vec::new(),
1345                usize::MAX,
1346                2,
1347                0,
1348            )
1349            .is_err(),
1350            "checkpoint shape multiplication must not overflow"
1351        );
1352    }
1353
1354    #[test]
1355    fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
1356        let checkpoint = FixedLambdaCheckpoint::new(
1357            FixedLambdaSolverStage::MultinomialFirth,
1358            vec![12_345.678_9, -98_765.432_1],
1359            2,
1360            1,
1361            11,
1362        )
1363        .expect("valid checkpoint");
1364        let checkpoint_debug = format!("{checkpoint:?}");
1365        assert!(!checkpoint_debug.contains("12345.6789"));
1366        assert!(!checkpoint_debug.contains("98765.4321"));
1367        let err = EstimationError::FixedLambdaNewtonDidNotConverge {
1368            context: "test Firth solve".to_string(),
1369            reason: FixedLambdaStallReason::LineSearchExhausted,
1370            objective_value: 3.25,
1371            stationarity: FixedLambdaStationarityEvidence {
1372                kind: FixedLambdaResidualKind::NewtonDecrement,
1373                residual: 0.125,
1374                bound: 1.0e-7,
1375            },
1376            checkpoint,
1377        };
1378
1379        let display = err.to_string();
1380        assert!(display.contains("test Firth solve"));
1381        assert!(display.contains("line search exhausted"));
1382        assert!(display.contains("Newton decrement"));
1383        assert!(display.contains("2x1"));
1384        assert!(display.contains("11 iteration"));
1385        assert!(!display.contains("12345.6789"));
1386        assert!(!display.contains("98765.4321"));
1387        assert_eq!(
1388            format!("{err:?}"),
1389            display,
1390            "Debug delegates to safe Display"
1391        );
1392        assert!(err.is_inner_solve_retreat());
1393    }
1394
1395    // ── From<LinalgError> ─────────────────────────────────────────────────────
1396
1397    #[test]
1398    fn from_linalg_invalid_input_maps_to_invalid_input() {
1399        let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
1400        let err = EstimationError::from(linalg_err);
1401        assert!(matches!(err, EstimationError::InvalidInput(_)));
1402        assert!(err.to_string().contains("linalg msg"));
1403    }
1404
1405    #[test]
1406    fn from_linalg_hessian_not_spd_maps_correctly() {
1407        let linalg_err = LinalgError::HessianNotPositiveDefinite {
1408            min_eigenvalue: -1.0,
1409        };
1410        let err = EstimationError::from(linalg_err);
1411        assert!(matches!(
1412            err,
1413            EstimationError::HessianNotPositiveDefinite { .. }
1414        ));
1415    }
1416}
1417
1418/// Honest failure text for [`EstimationError::HessianNotPositiveDefinite`].
1419///
1420/// A failed Cholesky with a strictly POSITIVE reported minimum eigenvalue is
1421/// not an indefinite matrix — it is a positive spectrum whose condition
1422/// number exceeds float precision (the pivots collapse under roundoff), or a
1423/// non-finite assembly. Saying "not positive definite (minimum eigenvalue:
1424/// 4.1e1)" sent debugging at the wrong defect class (#2316 triage), so the
1425/// message now names the regime the eigenvalue actually indicates.
1426fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
1427    if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
1428        format!(
1429            "Hessian factorization failed although the (lower-triangle) spectrum is positive \
1430             (minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
1431             precision or the assembled matrix is asymmetric/non-finite outside the factored \
1432             triangle. This indicates a numerical instability in the Hessian assembly or scaling."
1433        )
1434    } else {
1435        format!(
1436            "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
1437             This indicates a numerical instability."
1438        )
1439    }
1440}