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}
389
390/// A comprehensive error type for the model estimation process.
391#[derive(thiserror::Error)]
392pub enum EstimationError {
393    #[error(transparent)]
394    InvalidStabilization(#[from] crate::InvalidStabilization),
395
396    #[error("Underlying basis function generation failed: {0}")]
397    BasisError(#[from] BasisError),
398
399    #[error("Custom-family fit failed: {0}")]
400    CustomFamily(#[from] CustomFamilyError),
401
402    #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
403    LinearSystemSolveFailed(FaerLinalgError),
404
405    #[error("Eigendecomposition failed: {0}")]
406    EigendecompositionFailed(FaerLinalgError),
407
408    #[error(
409        "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
410    )]
411    PenaltySpectrumNonFinite {
412        context: String,
413        index: usize,
414        value: f64,
415    },
416
417    #[error(
418        "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
419    )]
420    PenaltySpectrumIndefinite {
421        context: String,
422        index: usize,
423        value: f64,
424        tolerance: f64,
425        scale: f64,
426    },
427
428    #[error("Parameter constraint violation: {0}")]
429    ParameterConstraintViolation(String),
430
431    #[error(
432        "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
433    )]
434    PirlsDidNotConverge {
435        max_iterations: usize,
436        last_change: f64,
437    },
438
439    #[error(
440        "{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
441         {reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
442         only minted from a converged optimization; resume by passing the carried checkpoint \
443         through the fixed-lambda input's `resume_from` field ({checkpoint}).",
444        .checkpoint.completed_iterations()
445    )]
446    FixedLambdaNewtonDidNotConverge {
447        /// Which fixed-λ Newton entry stalled (e.g. the multinomial softmax or
448        /// independent-binomial vector-GLM solve, or the Firth refit lane).
449        context: String,
450        /// Why the solver stopped without its convergence certificate.
451        reason: FixedLambdaStallReason,
452        /// Final value of the solver's minimized criterion. For ordinary vector
453        /// GLMs this is `-log L + penalty`; for the Firth lane it also includes
454        /// the negative Jeffreys `0.5 log det(I)` contribution.
455        objective_value: f64,
456        /// Exact first-order residual and the bound it failed to clear.
457        stationarity: FixedLambdaStationarityEvidence,
458        /// Last accepted coefficients and cumulative iteration count. This is
459        /// work-preservation state, not a fitted model, and carries no covariance
460        /// or prediction surface.
461        checkpoint: FixedLambdaCheckpoint,
462    },
463
464    #[error(
465        "Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
466         max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
467         minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
468         {profile_curvature_roundoff:.3e}; last scale fixed-point step \
469         {last_scale_step:.6e}{}). \
470         A fit is only minted from a converged optimization; resume from the \
471         checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
472        if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
473    )]
474    BlockOrthogonalRemlDidNotConverge {
475        /// Outer alternation passes executed before exhaustion.
476        iterations: usize,
477        /// Largest per-block |dV/drho| at the final iterate, normalized by the
478        /// score's natural magnitude `d * max(1, rank)`.
479        max_score_residual: f64,
480        /// Tolerance the residual had to meet for the convergence certificate.
481        score_tol: f64,
482        /// Smallest eigenvalue of the analytic rho Hessian after profiling out
483        /// the exact conditional scale block.
484        min_profile_curvature: f64,
485        /// Dimension-scaled eigensolver roundoff allowed below zero when
486        /// certifying positive semidefiniteness.
487        profile_curvature_roundoff: f64,
488        /// Last max |Δ log scale-precision| fixed-point movement (evidence of
489        /// whether the alternation was still moving or had stalled).
490        last_scale_step: f64,
491        /// The alternation revisited an earlier `(rho, scale)` state exactly;
492        /// as a deterministic map it can never certify, so it stopped early.
493        cycle_detected: bool,
494        /// Per-block log-lambda iterates at exhaustion; feed back through the
495        /// entry point's `init_rhos` to resume rather than restart.
496        rho_checkpoint: Vec<f64>,
497    },
498
499    #[error(
500        "Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
501         {rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
502         {rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
503         against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
504         partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
505         and rho={rho_checkpoint:?}."
506    )]
507    NegativeBinomialAlternationDidNotConverge {
508        /// Joint block-coordinate rounds executed before exhaustion.
509        rounds: usize,
510        /// Conditional theta coordinate at the best measured checkpoint.
511        theta_checkpoint: f64,
512        /// KKT-projected rho-gradient norm at that checkpoint.
513        rho_projected_grad_norm: f64,
514        /// Bound the rho residual had to clear.
515        rho_stationarity_bound: f64,
516        /// Curvature-normalized log-theta score residual at that checkpoint.
517        theta_score_residual: f64,
518        /// Bound the theta residual had to clear.
519        theta_stationarity_bound: f64,
520        /// Best measured log-smoothing checkpoint for warm-started resume.
521        rho_checkpoint: Vec<f64>,
522    },
523
524    #[error(
525        "Beta precision refinement did not converge: after {passes} alternation pass(es) at the \
526         selected smoothing the moment estimate moved from phi={prior_phi:.6e} to \
527         phi={refreshed_phi:.6e} and the mean re-solve at the refreshed precision ended \
528         '{inner_status}' (deviance {deviance:.6e}). The (beta, phi) alternation is only minted at \
529         a fixed point where both the mean and the precision are stationary; a precision that \
530         keeps growing means the response carries no dispersion around the fitted mean at this \
531         smoothing, so no finite beta precision exists to certify."
532    )]
533    BetaPrecisionRefinementDidNotConverge {
534        /// Alternation passes executed, counting the one whose re-solve failed.
535        passes: usize,
536        /// Precision the failed re-solve was warm-started from.
537        prior_phi: f64,
538        /// Precision the failed re-solve was asked to fit at.
539        refreshed_phi: f64,
540        /// Deviance of the last certified mean, at `prior_phi`.
541        deviance: f64,
542        /// Terminal status (or error) of the re-solve at `refreshed_phi`.
543        inner_status: String,
544    },
545
546    #[error(
547        "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
548        The model cannot converge because a predictor perfectly separates the binary outcomes. \
549        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
550    )]
551    PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
552
553    #[error(
554        "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
555        has a threshold {threshold:.6e} that separates the binary outcomes \
556        (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
557        enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
558    )]
559    PrefitPerfectSeparationDetected {
560        column_index: usize,
561        threshold: f64,
562        positive_above_threshold: bool,
563    },
564
565    #[error(
566        "Pre-fit linear separation detected in the realized binomial inverse-link design: \
567        {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
568        with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
569        The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
570        remove/reparameterize/penalize the separating columns."
571    )]
572    PrefitLinearSeparationDetected {
573        min_signed_margin: f64,
574        num_unpenalized_columns: usize,
575        column_indices: Vec<usize>,
576    },
577
578    #[error(
579        "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
580        unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
581        Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
582    )]
583    PrefitRankDeficientDesignDetected {
584        rank: usize,
585        num_unpenalized_columns: usize,
586        min_eigenvalue: f64,
587        tolerance: f64,
588        column_indices: Vec<usize>,
589    },
590
591    #[error(
592        "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
593        unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
594        exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
595        columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
596        so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
597        penalty/constraint before fitting."
598    )]
599    PrefitNearDegenerateDesignDetected {
600        num_unpenalized_columns: usize,
601        condition_number: f64,
602        min_eigenvalue: f64,
603        max_eigenvalue: f64,
604        tolerance: f64,
605        column_indices: Vec<usize>,
606    },
607
608    #[error(
609        "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
610        The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
611        so the unpenalized softmax MLE is not finite in that direction. \
612        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
613    )]
614    MultinomialSeparationDetected {
615        iteration: usize,
616        max_abs_eta: f64,
617        active_class_index: usize,
618        row_index: usize,
619    },
620
621    #[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
622    HessianNotPositiveDefinite { min_eigenvalue: f64 },
623
624    #[error("REML smoothing optimization failed to converge: {0}")]
625    RemlOptimizationFailed(String),
626
627    /// A numerical refusal evaluated AT ONE TRIAL POINT of the outer smoothing
628    /// search: no Laplace mode at this rho, an inner solve that missed its KKT
629    /// bar at this rho, an indefinite trial Hessian at this rho.
630    ///
631    /// The outer search's response to this is to map the point to
632    /// `OuterEval::infeasible` and step away — which is a normal thing for a
633    /// lambda-search to consume, and the only response it *has*, since the only
634    /// thing it can change is rho. Saying so in the type is the whole point:
635    /// these refusals used to be reported as
636    /// [`InvalidInput`](Self::InvalidInput) or `RemlOptimizationFailed`, both
637    /// of which carry prose and both of which
638    /// [`Self::is_trial_point_infeasible`] answers `false` for, so a correct
639    /// per-rho verdict aborted the entire fit (#2531, #2590).
640    ///
641    /// It renders as the bare reason so a producer switching to it does not
642    /// change the message a user or a regression test reads.
643    #[error("{reason}")]
644    TrialPointRefused { reason: String },
645
646    #[error("Fatal outer-objective evaluation failure ({context}): {source}")]
647    OuterObjectiveEvaluationFailed {
648        context: String,
649        #[source]
650        source: OuterObjectiveErrorSource,
651    },
652
653    #[error(
654        "Outer smoothing-parameter optimization did not certify a stationary optimum \
655         ({context}): {reason} after {iterations} outer iteration(s); final objective \
656         {final_value:.6e}, projected gradient norm {} {stationarity_standard}. A fit is \
657         only minted from a converged optimization; the best iterate is carried as a \
658         checkpoint — resume by seeding the outer search at rho_checkpoint = \
659         {rho_checkpoint:?}.",
660        .projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}")),
661    )]
662    RemlDidNotConverge {
663        /// Fit context label (the same string the outer runner logs under).
664        context: String,
665        /// Which certificate failed: budget exhaustion, line-search collapse,
666        /// non-stationary cost stall, or a failed post-solve stationarity
667        /// certificate.
668        reason: String,
669        /// Outer iterations executed across all solver restarts.
670        iterations: usize,
671        /// Objective value at the abandoned best iterate.
672        final_value: f64,
673        /// KKT-projected gradient norm at the best iterate, when the solver
674        /// measured a gradient there (`None` for gradient-free exits).
675        projected_grad_norm: Option<f64>,
676        /// The standard this refusal was decided against: the bound together
677        /// with the rung that produced it, or an explicit statement that no
678        /// stationarity comparison was made (#2458/#2465). They are ONE field
679        /// precisely so neither can be reported without the other, and so that
680        /// a route which never formed a bound cannot print one.
681        stationarity_standard: StationarityStandard,
682        /// Best (lowest-objective feasible) outer iterate at exhaustion. This
683        /// is work-preservation evidence for resume — it is NOT a fit and no
684        /// fitted-model API is reachable from it.
685        rho_checkpoint: Vec<f64>,
686    },
687
688    #[error(
689        "Fit assembly rejected a non-converged optimization state: inner status \
690         {inner_status}, outer status {outer_status}, after {outer_iterations} outer \
691         iteration(s); final objective {}; stationarity {stationarity}, \
692         step {step}. The best rho checkpoint is \
693         {rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
694         API was constructed.",
695        .final_value.map_or_else(
696            || "unavailable (this fit has no criterion value)".to_string(),
697            |value| format!("{value:.6e}"),
698        ),
699    )]
700    FitDidNotConverge {
701        /// Diagnostic inner-solver terminal status. This is deliberately a
702        /// string at the neutral problem layer; concrete solver status enums
703        /// live in downstream fitting crates.
704        inner_status: String,
705        /// Outer terminal/certificate verdict.
706        outer_status: String,
707        /// Completed outer iterations at the rejected checkpoint.
708        outer_iterations: usize,
709        /// Objective value at the best available checkpoint, or `None` when the
710        /// rejected fit has no criterion value at all (the exact-fit Gaussian
711        /// boundary). A refusal must not invent an objective it could not read.
712        final_value: Option<f64>,
713        /// The first-order residual together with the bound it was weighed
714        /// against, or an explicit statement that no comparison was made.
715        stationarity: FitStationarityEvidence,
716        /// The accepted-step residual and its bound, same rule. Currently
717        /// always `NoComparison` at the sole production site -- which is what
718        /// the type should say, rather than leaving two independent `Option`s
719        /// armed with the identical hazard for whoever wires them up.
720        step: FitStationarityEvidence,
721        /// Work-preserving smoothing checkpoint; this is not a fit.
722        rho_checkpoint: Vec<f64>,
723        /// Opaque durable-cache resume token, when checkpoint persistence was
724        /// enabled for the failed run.
725        resume_token: Option<String>,
726    },
727
728    #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
729    GradientUnavailable {
730        context: &'static str,
731        mode: &'static str,
732    },
733
734    #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
735    LayoutError(String),
736
737    #[error(
738        "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."
739    )]
740    ModelIsIllConditioned { condition_number: f64 },
741
742    #[error("Invalid input: {0}")]
743    InvalidInput(String),
744
745    #[error(
746        "Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
747         interval [{lower}, {upper}]"
748    )]
749    InverseLinkDomainViolation {
750        link: &'static str,
751        eta: f64,
752        lower: f64,
753        upper: f64,
754    },
755
756    #[error(
757        "PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
758         eta={eta:?} produced {value:?}"
759    )]
760    PirlsRowGeometryUnrepresentable {
761        row: usize,
762        quantity: &'static str,
763        eta: f64,
764        value: f64,
765    },
766
767    #[error(
768        "Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
769    )]
770    ExactTweedieSeriesWorkLimit {
771        row: usize,
772        required_terms_lower_bound: f64,
773        budget: usize,
774    },
775
776    #[error(
777        "Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
778         the supported interval [{lower}, {upper}]"
779    )]
780    LogStrengthDomainViolation {
781        coordinate: usize,
782        value: f64,
783        lower: f64,
784        upper: f64,
785    },
786
787    #[error("monotone root solve: {0}")]
788    MonotoneRoot(#[from] MonotoneRootError),
789
790    #[error("Calibrator training failed: {0}")]
791    CalibratorTrainingFailed(String),
792
793    #[error("Invalid specification: {0}")]
794    InvalidSpecification(String),
795
796    #[error("Prediction error")]
797    PredictionError,
798}
799
800// Ensure Debug prints with actual line breaks by delegating to Display
801impl core::fmt::Debug for EstimationError {
802    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
803        write!(f, "{}", self)
804    }
805}
806
807impl EstimationError {
808    /// A per-row exponential-family quantity that could not be represented in
809    /// `f64` at linear predictor `eta` (a mean that rounded to the boundary, a
810    /// weight that overflowed, a deviance cell that went non-finite).
811    ///
812    /// The one constructor for [`Self::PirlsRowGeometryUnrepresentable`]:
813    /// seven PIRLS and design-construction modules used to carry a private
814    /// four-argument shim building this variant (#2470), so the variant's
815    /// field set was restated in each of them.
816    #[must_use]
817    pub fn pirls_row_geometry_unrepresentable(
818        row: usize,
819        quantity: &'static str,
820        eta: f64,
821        value: f64,
822    ) -> Self {
823        Self::PirlsRowGeometryUnrepresentable {
824            row,
825            quantity,
826            eta,
827            value,
828        }
829    }
830
831    /// The remediation a user can act on, when the failure has one: the
832    /// single source of the `help:` line the CLI prints and the Python
833    /// exception carries. Keyed on the variant, never on the rendered text —
834    /// the CLI used to re-derive this by grepping its own error strings, and
835    /// the two front ends disagreed (#2470).
836    #[must_use]
837    pub fn advice(&self) -> Option<String> {
838        const SEPARATION: &str = "Enable Firth/Jeffreys bias reduction, remove or regularize \
839             the separating predictor, or switch link via link(type=...).";
840        const CONDITIONING: &str = "Check for collinear or constant predictors and overly \
841             complex smooth bases.";
842        match self {
843            Self::BasisError(inner) => inner.advice(),
844            Self::OuterObjectiveEvaluationFailed { source, .. } => {
845                source.estimation_error().and_then(Self::advice)
846            }
847            Self::PerfectSeparationDetected { .. }
848            | Self::MultinomialSeparationDetected { .. } => {
849                Some(format!("Detected (quasi-)separation. {SEPARATION}"))
850            }
851            Self::PrefitPerfectSeparationDetected { column_index, .. } => Some(format!(
852                "Detected separation driven by unpenalized column {column_index}. {SEPARATION}"
853            )),
854            Self::PrefitLinearSeparationDetected { column_indices, .. } => Some(format!(
855                "Detected separation driven by unpenalized columns {column_indices:?}. {SEPARATION}"
856            )),
857            Self::PrefitRankDeficientDesignDetected { column_indices, .. }
858            | Self::PrefitNearDegenerateDesignDetected { column_indices, .. } => Some(format!(
859                "Matrix conditioning issue in unpenalized columns {column_indices:?}. {CONDITIONING}"
860            )),
861            Self::ModelIsIllConditioned { .. }
862            | Self::HessianNotPositiveDefinite { .. }
863            | Self::LinearSystemSolveFailed(_)
864            | Self::EigendecompositionFailed(_) => {
865                Some(format!("Matrix conditioning issue detected. {CONDITIONING}"))
866            }
867            _ => None,
868        }
869    }
870
871    /// Whether this failure invalidates the whole outer run or only the
872    /// trial point it was produced at.
873    ///
874    /// The outer optimizer can survive an infeasible trial: it maps the
875    /// point to `OuterEval::infeasible`, backs off, and continues. It
876    /// cannot survive a structural failure. Deciding which is which is
877    /// the producer's job, and the answer must travel with the error
878    /// rather than be reconstructed downstream from its rendered text
879    /// (#2553).
880    ///
881    /// Only failures that are genuinely a property of *this theta* answer
882    /// `true`. Everything else stays fatal, which is the conservative
883    /// direction: misclassifying a structural failure as recoverable
884    /// would let the search grind through a problem that can never work.
885    /// The match is deliberately exhaustive with no wildcard arm, for the
886    /// same reason [`CustomFamilyError::is_trial_point_infeasible`] is: under
887    /// a `_ => false` a newly added variant is classified *fatal* by the
888    /// absence of a decision, and whoever adds it is never asked. That is how
889    /// a rho-local refusal reached this function as `RemlOptimizationFailed` —
890    /// a variant that carries only prose — and aborted a fit the outer search
891    /// was equipped to walk away from (#2590).
892    #[must_use]
893    pub fn is_trial_point_infeasible(&self) -> bool {
894        match self {
895            // The producer classified it; ask it (#2553).
896            Self::CustomFamily(err) => err.is_trial_point_infeasible(),
897            // The producer said so directly (#2531).
898            Self::TrialPointRefused { .. } => true,
899            // "The inner problem at THIS rho is too hard to evaluate, try a
900            // different rho" — [`Self::is_inner_solve_retreat`]'s own words for
901            // exactly these five, and verbatim this predicate's definition. The
902            // two used to disagree, so a P-IRLS budget exhaustion was a retreat
903            // at one layer and a fatal at this one; #2593 unified them, and
904            // `is_inner_solve_retreat` now reads this table rather than keeping
905            // a second one.
906            Self::ModelIsIllConditioned { .. }
907            | Self::PerfectSeparationDetected { .. }
908            | Self::MultinomialSeparationDetected { .. }
909            | Self::PirlsDidNotConverge { .. }
910            | Self::FixedLambdaNewtonDidNotConverge { .. } => true,
911            // A structural failure, an already-terminal outer verdict, or a
912            // statement about the configuration, the data, or the prediction
913            // request: none of these becomes true or false by moving rho.
914            Self::InvalidStabilization { .. }
915            | Self::BasisError { .. }
916            | Self::LinearSystemSolveFailed { .. }
917            | Self::EigendecompositionFailed { .. }
918            | Self::PenaltySpectrumNonFinite { .. }
919            | Self::PenaltySpectrumIndefinite { .. }
920            | Self::ParameterConstraintViolation { .. }
921            | Self::BlockOrthogonalRemlDidNotConverge { .. }
922            | Self::NegativeBinomialAlternationDidNotConverge { .. }
923            | Self::BetaPrecisionRefinementDidNotConverge { .. }
924            | Self::PrefitPerfectSeparationDetected { .. }
925            | Self::PrefitLinearSeparationDetected { .. }
926            | Self::PrefitRankDeficientDesignDetected { .. }
927            | Self::PrefitNearDegenerateDesignDetected { .. }
928            | Self::HessianNotPositiveDefinite { .. }
929            | Self::RemlOptimizationFailed { .. }
930            | Self::OuterObjectiveEvaluationFailed { .. }
931            | Self::RemlDidNotConverge { .. }
932            | Self::FitDidNotConverge { .. }
933            | Self::GradientUnavailable { .. }
934            | Self::LayoutError { .. }
935            | Self::InvalidInput { .. }
936            | Self::InverseLinkDomainViolation { .. }
937            | Self::PirlsRowGeometryUnrepresentable { .. }
938            | Self::ExactTweedieSeriesWorkLimit { .. }
939            | Self::LogStrengthDomainViolation { .. }
940            | Self::MonotoneRoot { .. }
941            | Self::CalibratorTrainingFailed { .. }
942            | Self::InvalidSpecification { .. }
943            | Self::PredictionError { .. } => false,
944        }
945    }
946
947    /// Preserve a thrown outer-objective failure across seed, solver, and
948    /// fallback-plan orchestration. Trial-domain refusals must be represented
949    /// as a finite API outcome (`+inf` / `OuterEval::infeasible`); an `Err`
950    /// means the evaluation artifact itself could not be constructed and must
951    /// never be retried as another numerical point.
952    pub fn fatal_outer_evaluation(context: impl Into<String>, source: EstimationError) -> Self {
953        if matches!(
954            &source,
955            EstimationError::OuterObjectiveEvaluationFailed { .. }
956        ) {
957            source
958        } else {
959            EstimationError::OuterObjectiveEvaluationFailed {
960                context: context.into(),
961                source: OuterObjectiveErrorSource::Estimation(Box::new(source)),
962            }
963        }
964    }
965
966    /// Preserve an optimizer-facing fatal evaluator failure without reminting
967    /// its message as an unrelated [`Self::RemlOptimizationFailed`].
968    ///
969    /// The caller must have already consumed recoverable failures as rejected
970    /// trial points. Requiring the producer's fatal verdict here makes an
971    /// accidental promotion fail at the boundary that attempted it instead of
972    /// silently changing control flow.
973    pub fn fatal_objective_evaluation(
974        context: impl Into<String>,
975        source: opt::ObjectiveEvalError,
976    ) -> Self {
977        assert!(
978            source.is_fatal(),
979            "fatal_objective_evaluation requires a producer-classified fatal error"
980        );
981        EstimationError::OuterObjectiveEvaluationFailed {
982            context: context.into(),
983            source: OuterObjectiveErrorSource::Objective(source),
984        }
985    }
986
987    pub fn is_fatal_outer_evaluation(&self) -> bool {
988        matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
989    }
990
991    /// Classifies inner-solve failures that the outer REML loop should
992    /// treat as a soft retreat (return +inf cost / infeasible outer-eval)
993    /// rather than propagate as a hard error.
994    ///
995    /// Why: when the penalised Hessian becomes effectively singular at the
996    /// current rho, when P-IRLS hits a perfect-separation diagnostic, or when
997    /// it exhausts its iteration budget, the outer optimiser's correct
998    /// response is to back away from this rho — not to terminate the fit.
999    /// All three variants encode "the inner problem at this rho is too hard
1000    /// to evaluate, try a different rho".
1001    /// Re-report this failure with more context WITHOUT changing whether it
1002    /// is a trial-point refusal.
1003    ///
1004    /// A wrapper that renders its source into a string and then picks a fresh
1005    /// variant silently overwrites the producer's verdict. That is how a
1006    /// per-rho survival-LAML stationarity refusal reached the outer boundary
1007    /// as `InvalidInput` and killed the fit (#2531), and how a typed
1008    /// `InnerSolveNotConverged` reached it as `RemlOptimizationFailed` and did
1009    /// the same (#2590). Any site that adds context to an error it did not
1010    /// produce should use this instead of choosing a variant for it.
1011    #[must_use]
1012    pub fn wrap_preserving_trial_point(self, context: &str) -> Self {
1013        let infeasible = self.is_trial_point_infeasible();
1014        let reason = format!("{context}: {self}");
1015        if infeasible {
1016            Self::TrialPointRefused { reason }
1017        } else {
1018            Self::InvalidInput(reason)
1019        }
1020    }
1021
1022    pub fn is_inner_solve_retreat(&self) -> bool {
1023        // ONE table. This method and `is_trial_point_infeasible` ask the same
1024        // question -- "is this a statement about this rho, or about the
1025        // problem?" -- and used to answer it from two separate variant lists
1026        // that had drifted apart. Keeping a second list here is what let them
1027        // drift, so there is no longer a second list (#2593).
1028        //
1029        // The relation is delegation, not equality: `is_trial_point_infeasible`
1030        // is strictly wider, because it also asks the producer through
1031        // `CustomFamily(..)` and honours the typed `TrialPointRefused`. Every
1032        // retreat is an infeasibility; not every infeasibility arrives as one
1033        // of the five inner-solve shapes.
1034        self.is_trial_point_infeasible()
1035    }
1036}
1037
1038#[cfg(test)]
1039mod advice_policy_tests {
1040    use super::*;
1041
1042    #[test]
1043    fn advice_is_keyed_on_the_variant_and_flows_through_the_basis_wrapper() {
1044        let separation = EstimationError::PrefitPerfectSeparationDetected {
1045            column_index: 3,
1046            threshold: 0.5,
1047            positive_above_threshold: true,
1048        };
1049        let advice = separation.advice().expect("separation advice");
1050        assert!(advice.contains("column 3"), "{advice}");
1051        assert!(advice.contains("Firth"), "{advice}");
1052
1053        let conditioning = EstimationError::ModelIsIllConditioned {
1054            condition_number: 1e18,
1055        };
1056        let advice = conditioning.advice().expect("conditioning advice");
1057        assert!(advice.contains("collinear"), "{advice}");
1058
1059        let basis = EstimationError::BasisError(BasisError::duchon_smoothness_insufficient(
1060            "hybrid diagonal",
1061            0,
1062            3,
1063            1,
1064            0.5,
1065        ));
1066        let advice = basis.advice().expect("basis advice");
1067        assert!(advice.contains("power"), "{advice}");
1068
1069        assert!(EstimationError::InvalidInput("dimension=16".into()).advice().is_none());
1070    }
1071}
1072
1073#[cfg(test)]
1074mod trial_point_classification_tests {
1075    use super::*;
1076
1077    /// The two classifiers now agree by construction, and this is what pins
1078    /// that: every inner-solve retreat is a trial-point infeasibility. The test
1079    /// it replaces pinned the opposite, deliberately, so that unifying them had
1080    /// to be a decision rather than a drift (#2593).
1081    #[test]
1082    fn every_inner_solve_retreat_is_a_trial_point_infeasibility() {
1083        let retreats = [
1084            EstimationError::ModelIsIllConditioned {
1085                condition_number: 1.0e18,
1086            },
1087            EstimationError::PerfectSeparationDetected {
1088                iteration: 3,
1089                max_abs_eta: 1.0e3,
1090            },
1091            EstimationError::MultinomialSeparationDetected {
1092                iteration: 3,
1093                max_abs_eta: 1.0e3,
1094                active_class_index: 1,
1095                row_index: 2,
1096            },
1097            EstimationError::PirlsDidNotConverge {
1098                max_iterations: 40,
1099                last_change: 1.0e-2,
1100            },
1101            // The fifth shape. The fixture carried four while the table it
1102            // covers -- and this test's own doc, five lines up -- says five, so
1103            // the fixed-lambda Newton stall was the one arm nothing pinned.
1104            // That is the retreat the multinomial and independent-binomial
1105            // vector-GLM lanes actually emit, and it is the arm a regression
1106            // flipping to `false` would have slipped past unnoticed.
1107            EstimationError::FixedLambdaNewtonDidNotConverge {
1108                context: "trial-point classification fixture".to_string(),
1109                reason: FixedLambdaStallReason::IterationBudgetExhausted,
1110                objective_value: 12.5,
1111                stationarity: FixedLambdaStationarityEvidence {
1112                    kind: FixedLambdaResidualKind::PenalizedGradientNorm,
1113                    residual: 1.0e-3,
1114                    bound: 1.0e-8,
1115                },
1116                checkpoint: FixedLambdaCheckpoint::new(
1117                    FixedLambdaSolverStage::MultinomialNewton,
1118                    vec![0.0, 0.0],
1119                    2,
1120                    1,
1121                    40,
1122                )
1123                .expect("fixture checkpoint geometry is valid"),
1124            },
1125        ];
1126        for error in retreats {
1127            assert!(
1128                error.is_inner_solve_retreat(),
1129                "fixture must be a retreat: {error}"
1130            );
1131            assert!(
1132                error.is_trial_point_infeasible(),
1133                "a retreat is by its own definition a trial-point infeasibility: {error}"
1134            );
1135        }
1136    }
1137
1138    /// The failure #2590 is about: a refusal produced at one rho must not be
1139    /// graded fatal because it crossed a boundary that kept only its text.
1140    #[test]
1141    fn a_custom_family_trial_point_refusal_stays_recoverable() {
1142        let reason = "joint Newton returned an indefinite mode at this rho";
1143        assert!(
1144            EstimationError::CustomFamily(CustomFamilyError::trial_point(reason))
1145                .is_trial_point_infeasible()
1146        );
1147        assert!(
1148            !EstimationError::RemlOptimizationFailed(reason.to_string())
1149                .is_trial_point_infeasible(),
1150            "the prose-only variant is exactly what must NOT carry a rho-local refusal"
1151        );
1152    }
1153}
1154
1155impl From<LinalgError> for EstimationError {
1156    fn from(error: LinalgError) -> Self {
1157        match error {
1158            LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
1159            LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
1160                EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
1161            }
1162            LinalgError::ModelIsIllConditioned { condition_number } => {
1163                EstimationError::ModelIsIllConditioned { condition_number }
1164            }
1165        }
1166    }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172
1173    // ── stationarity rung provenance (#2458) ─────────────────────────────────
1174
1175    fn reml_refusal(standard: StationarityStandard) -> EstimationError {
1176        EstimationError::RemlDidNotConverge {
1177            context: "unit".to_string(),
1178            reason: "budget exhausted".to_string(),
1179            iterations: 7,
1180            final_value: -1.25,
1181            projected_grad_norm: Some(7.5e-1),
1182            stationarity_standard: standard,
1183            rho_checkpoint: vec![0.5],
1184        }
1185    }
1186
1187    fn measured(label: &'static str, derived_standard: bool) -> StationarityStandard {
1188        StationarityStandard::Measured {
1189            bound: 1.0e-2,
1190            rung: StationarityRung {
1191                label,
1192                derived_standard,
1193            },
1194        }
1195    }
1196
1197    /// The whole point of the increment: a red states which standard it was
1198    /// held to, so a reader does not have to infer the rung from the numbers.
1199    #[test]
1200    fn refusal_message_carries_the_rung_and_whether_it_is_derived() {
1201        let derived = reml_refusal(measured("curvature-resolvability", true)).to_string();
1202        assert!(
1203            derived.contains("rung=curvature-resolvability"),
1204            "refusal must name its rung: {derived}"
1205        );
1206        assert!(
1207            derived.contains("derived_standard=true"),
1208            "refusal must say whether the rung is the derived standard: {derived}"
1209        );
1210
1211        let substitute = reml_refusal(measured("solver-band", false)).to_string();
1212        assert!(substitute.contains("rung=solver-band"), "{substitute}");
1213        assert!(
1214            substitute.contains("derived_standard=false"),
1215            "a gradient-magnitude substitute must not read as the derived standard: {substitute}"
1216        );
1217    }
1218
1219    /// A refusal reached before any stationarity comparison must not print a
1220    /// bound. The old shape filled the field with the raw configured tolerance
1221    /// and rendered "projected gradient norm … against stationarity bound …",
1222    /// which reads as a comparison that never happened — and naming the
1223    /// constant with a rung only made the false sentence more confident.
1224    #[test]
1225    fn a_refusal_without_a_comparison_reports_no_bound() {
1226        let message = reml_refusal(StationarityStandard::NoComparison).to_string();
1227        assert!(
1228            message.contains("against no stationarity bound"),
1229            "a refusal that applied no bound must say so: {message}"
1230        );
1231        assert!(
1232            !message.contains("rung="),
1233            "no rung may be claimed where no bound was applied: {message}"
1234        );
1235        assert!(
1236            !message.contains("1.000e-2"),
1237            "no bound value may appear where none was applied: {message}"
1238        );
1239    }
1240
1241    /// The carrier's whole purpose: a bound cannot be read without the rung that
1242    /// produced it, and a route with neither reports neither.
1243    #[test]
1244    fn the_bound_and_its_rung_are_one_field() {
1245        let standard = measured("probe-noise-floor", false);
1246        assert_eq!(standard.bound(), Some(1.0e-2));
1247        assert_eq!(
1248            standard.rung().map(|rung| rung.label),
1249            Some("probe-noise-floor")
1250        );
1251        assert_eq!(StationarityStandard::NoComparison.bound(), None);
1252        assert_eq!(StationarityStandard::NoComparison.rung(), None);
1253    }
1254
1255    /// The bound itself still reaches the message unchanged — the rung rides
1256    /// alongside it, it does not replace it.
1257    #[test]
1258    fn rung_rides_beside_the_bound_without_displacing_it() {
1259        let message = reml_refusal(measured("solver-band", false)).to_string();
1260        assert!(
1261            message.contains("1.000e-2"),
1262            "bound must survive: {message}"
1263        );
1264        assert!(
1265            message.contains("7.500e-1"),
1266            "projected gradient norm must survive: {message}"
1267        );
1268    }
1269
1270    // ── is_inner_solve_retreat ────────────────────────────────────────────────
1271
1272    #[test]
1273    fn model_ill_conditioned_is_retreat() {
1274        assert!(
1275            EstimationError::ModelIsIllConditioned {
1276                condition_number: 1e15
1277            }
1278            .is_inner_solve_retreat()
1279        );
1280    }
1281
1282    #[test]
1283    fn perfect_separation_is_retreat() {
1284        assert!(
1285            EstimationError::PerfectSeparationDetected {
1286                iteration: 3,
1287                max_abs_eta: 50.0
1288            }
1289            .is_inner_solve_retreat()
1290        );
1291    }
1292
1293    #[test]
1294    fn multinomial_separation_is_retreat() {
1295        assert!(
1296            EstimationError::MultinomialSeparationDetected {
1297                iteration: 1,
1298                max_abs_eta: 100.0,
1299                active_class_index: 2,
1300                row_index: 7
1301            }
1302            .is_inner_solve_retreat()
1303        );
1304    }
1305
1306    #[test]
1307    fn pirls_did_not_converge_is_retreat() {
1308        assert!(
1309            EstimationError::PirlsDidNotConverge {
1310                max_iterations: 100,
1311                last_change: 1e-3
1312            }
1313            .is_inner_solve_retreat()
1314        );
1315    }
1316
1317    #[test]
1318    fn invalid_input_is_not_retreat() {
1319        assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
1320    }
1321
1322    #[test]
1323    fn reml_optimization_failed_is_not_retreat() {
1324        assert!(
1325            !EstimationError::RemlOptimizationFailed("outer fail".to_string())
1326                .is_inner_solve_retreat()
1327        );
1328    }
1329
1330    #[test]
1331    fn fatal_outer_evaluation_is_typed_and_idempotent() {
1332        let error = EstimationError::fatal_outer_evaluation(
1333            "seed screening",
1334            EstimationError::InvalidInput("frame mismatch".to_string()),
1335        );
1336        assert!(error.is_fatal_outer_evaluation());
1337        assert!(error.to_string().contains("frame mismatch"));
1338
1339        let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
1340        assert!(nested.is_fatal_outer_evaluation());
1341        assert_eq!(
1342            nested.to_string().matches("Fatal outer-objective").count(),
1343            1,
1344            "fatal provenance must not be re-wrapped at every orchestration layer"
1345        );
1346    }
1347
1348    // ── error message content ─────────────────────────────────────────────────
1349
1350    #[test]
1351    fn invalid_input_message_appears_in_display() {
1352        let err = EstimationError::InvalidInput("test_message".to_string());
1353        assert!(err.to_string().contains("test_message"));
1354    }
1355
1356    #[test]
1357    fn pirls_did_not_converge_mentions_max_iterations() {
1358        let err = EstimationError::PirlsDidNotConverge {
1359            max_iterations: 42,
1360            last_change: 0.001,
1361        };
1362        assert!(err.to_string().contains("42"));
1363    }
1364
1365    #[test]
1366    fn fixed_lambda_checkpoint_validates_shape_and_values() {
1367        let checkpoint = FixedLambdaCheckpoint::new(
1368            FixedLambdaSolverStage::MultinomialNewton,
1369            vec![1.0, 2.0, 3.0, 4.0],
1370            2,
1371            2,
1372            7,
1373        )
1374        .expect("well-shaped finite checkpoint");
1375        assert_eq!(
1376            checkpoint.stage(),
1377            FixedLambdaSolverStage::MultinomialNewton
1378        );
1379        assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
1380        assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
1381        assert_eq!(checkpoint.completed_iterations(), 7);
1382
1383        assert!(
1384            FixedLambdaCheckpoint::new(
1385                FixedLambdaSolverStage::BinomialMultiNewton,
1386                vec![1.0],
1387                2,
1388                1,
1389                0,
1390            )
1391            .is_err(),
1392            "coefficient length must match rows * cols"
1393        );
1394        assert!(
1395            FixedLambdaCheckpoint::new(
1396                FixedLambdaSolverStage::BinomialMultiNewton,
1397                vec![f64::NAN],
1398                1,
1399                1,
1400                0,
1401            )
1402            .is_err(),
1403            "checkpoint coefficients must be finite"
1404        );
1405        assert!(
1406            FixedLambdaCheckpoint::new(
1407                FixedLambdaSolverStage::MultinomialFirth,
1408                Vec::new(),
1409                usize::MAX,
1410                2,
1411                0,
1412            )
1413            .is_err(),
1414            "checkpoint shape multiplication must not overflow"
1415        );
1416    }
1417
1418    #[test]
1419    fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
1420        let checkpoint = FixedLambdaCheckpoint::new(
1421            FixedLambdaSolverStage::MultinomialFirth,
1422            vec![12_345.678_9, -98_765.432_1],
1423            2,
1424            1,
1425            11,
1426        )
1427        .expect("valid checkpoint");
1428        let checkpoint_debug = format!("{checkpoint:?}");
1429        assert!(!checkpoint_debug.contains("12345.6789"));
1430        assert!(!checkpoint_debug.contains("98765.4321"));
1431        let err = EstimationError::FixedLambdaNewtonDidNotConverge {
1432            context: "test Firth solve".to_string(),
1433            reason: FixedLambdaStallReason::LineSearchExhausted,
1434            objective_value: 3.25,
1435            stationarity: FixedLambdaStationarityEvidence {
1436                kind: FixedLambdaResidualKind::NewtonDecrement,
1437                residual: 0.125,
1438                bound: 1.0e-7,
1439            },
1440            checkpoint,
1441        };
1442
1443        let display = err.to_string();
1444        assert!(display.contains("test Firth solve"));
1445        assert!(display.contains("line search exhausted"));
1446        assert!(display.contains("Newton decrement"));
1447        assert!(display.contains("2x1"));
1448        assert!(display.contains("11 iteration"));
1449        assert!(!display.contains("12345.6789"));
1450        assert!(!display.contains("98765.4321"));
1451        assert_eq!(
1452            format!("{err:?}"),
1453            display,
1454            "Debug delegates to safe Display"
1455        );
1456        assert!(err.is_inner_solve_retreat());
1457    }
1458
1459    // ── From<LinalgError> ─────────────────────────────────────────────────────
1460
1461    #[test]
1462    fn from_linalg_invalid_input_maps_to_invalid_input() {
1463        let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
1464        let err = EstimationError::from(linalg_err);
1465        assert!(matches!(err, EstimationError::InvalidInput(_)));
1466        assert!(err.to_string().contains("linalg msg"));
1467    }
1468
1469    #[test]
1470    fn from_linalg_hessian_not_spd_maps_correctly() {
1471        let linalg_err = LinalgError::HessianNotPositiveDefinite {
1472            min_eigenvalue: -1.0,
1473        };
1474        let err = EstimationError::from(linalg_err);
1475        assert!(matches!(
1476            err,
1477            EstimationError::HessianNotPositiveDefinite { .. }
1478        ));
1479    }
1480}
1481
1482/// Honest failure text for [`EstimationError::HessianNotPositiveDefinite`].
1483///
1484/// A failed Cholesky with a strictly POSITIVE reported minimum eigenvalue is
1485/// not an indefinite matrix — it is a positive spectrum whose condition
1486/// number exceeds float precision (the pivots collapse under roundoff), or a
1487/// non-finite assembly. Saying "not positive definite (minimum eigenvalue:
1488/// 4.1e1)" sent debugging at the wrong defect class (#2316 triage), so the
1489/// message now names the regime the eigenvalue actually indicates.
1490fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
1491    if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
1492        format!(
1493            "Hessian factorization failed although the (lower-triangle) spectrum is positive \
1494             (minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
1495             precision or the assembled matrix is asymmetric/non-finite outside the factored \
1496             triangle. This indicates a numerical instability in the Hessian assembly or scaling."
1497        )
1498    } else {
1499        format!(
1500            "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
1501             This indicates a numerical instability."
1502        )
1503    }
1504}