use gam_linalg::LinalgError;
use gam_linalg::faer_ndarray::FaerLinalgError;
use serde::{Deserialize, Serialize};
use crate::{BasisError, CustomFamilyError, MonotoneRootError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StationarityRung {
pub label: &'static str,
pub derived_standard: bool,
}
impl StationarityRung {
pub const EMPTY_ESTIMAND: Self = Self {
label: "empty-estimand",
derived_standard: false,
};
}
impl std::fmt::Display for StationarityRung {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"rung={} derived_standard={}",
self.label, self.derived_standard
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(bound(deserialize = "'de: 'static"))]
pub enum StationarityStandard {
Measured {
bound: f64,
rung: StationarityRung,
},
NoComparison,
}
impl StationarityStandard {
pub fn bound(&self) -> Option<f64> {
match self {
Self::Measured { bound, .. } => Some(*bound),
Self::NoComparison => None,
}
}
pub fn rung(&self) -> Option<StationarityRung> {
match self {
Self::Measured { rung, .. } => Some(*rung),
Self::NoComparison => None,
}
}
}
impl std::fmt::Display for StationarityStandard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Measured { bound, rung } => {
write!(f, "against stationarity bound {bound:.3e} ({rung})")
}
Self::NoComparison => f.write_str(
"against no stationarity bound: this refusal was decided by the reason \
above, not by a stationarity comparison",
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum FitStationarityEvidence {
Certified { residual: f64, bound: f64 },
NoComparison,
}
impl std::fmt::Display for FitStationarityEvidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Certified { residual, bound } => {
write!(f, "residual {residual:.3e} against bound {bound:.3e}")
}
Self::NoComparison => f.write_str("not compared: no certificate was assembled"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FixedLambdaSolverStage {
BinomialMultiNewton,
MultinomialNewton,
MultinomialFirth,
}
impl core::fmt::Display for FixedLambdaSolverStage {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::BinomialMultiNewton => "binomial-multi Newton",
Self::MultinomialNewton => "multinomial Newton",
Self::MultinomialFirth => "multinomial Firth/Jeffreys Newton",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FixedLambdaStallReason {
IterationBudgetExhausted,
LineSearchExhausted,
StationarityCertificateFailed,
}
impl core::fmt::Display for FixedLambdaStallReason {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::IterationBudgetExhausted => "iteration budget exhausted",
Self::LineSearchExhausted => "line search exhausted without an accepted step",
Self::StationarityCertificateFailed => "stationarity certificate failed",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FixedLambdaResidualKind {
PenalizedGradientNorm,
NewtonDecrement,
}
impl core::fmt::Display for FixedLambdaResidualKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::PenalizedGradientNorm => "penalized gradient norm",
Self::NewtonDecrement => "Newton decrement",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FixedLambdaStationarityEvidence {
pub kind: FixedLambdaResidualKind,
pub residual: f64,
pub bound: f64,
}
impl core::fmt::Display for FixedLambdaStationarityEvidence {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{} {:.6e} against bound {:.6e}",
self.kind, self.residual, self.bound
)
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct FixedLambdaCheckpoint {
stage: FixedLambdaSolverStage,
coefficients_row_major: Vec<f64>,
rows: usize,
cols: usize,
completed_iterations: usize,
}
impl FixedLambdaCheckpoint {
pub fn new(
stage: FixedLambdaSolverStage,
coefficients_row_major: Vec<f64>,
rows: usize,
cols: usize,
completed_iterations: usize,
) -> Result<Self, String> {
let checkpoint = Self {
stage,
coefficients_row_major,
rows,
cols,
completed_iterations,
};
checkpoint.validate()?;
Ok(checkpoint)
}
pub fn validate(&self) -> Result<(), String> {
if self.rows == 0 || self.cols == 0 {
return Err(format!(
"fixed-lambda checkpoint shape must be nonempty, got {}x{}",
self.rows, self.cols
));
}
let expected = self.rows.checked_mul(self.cols).ok_or_else(|| {
format!(
"fixed-lambda checkpoint shape {}x{} overflows usize",
self.rows, self.cols
)
})?;
if self.coefficients_row_major.len() != expected {
return Err(format!(
"fixed-lambda checkpoint has {} coefficient values, expected {} for shape {}x{}",
self.coefficients_row_major.len(),
expected,
self.rows,
self.cols
));
}
if let Some((index, _)) = self
.coefficients_row_major
.iter()
.copied()
.enumerate()
.find(|(_, value)| !value.is_finite())
{
return Err(format!(
"fixed-lambda checkpoint coefficient {index} must be finite"
));
}
Ok(())
}
pub fn stage(&self) -> FixedLambdaSolverStage {
self.stage
}
pub fn values(&self) -> &[f64] {
&self.coefficients_row_major
}
pub fn rows(&self) -> usize {
self.rows
}
pub fn cols(&self) -> usize {
self.cols
}
pub fn completed_iterations(&self) -> usize {
self.completed_iterations
}
}
impl core::fmt::Display for FixedLambdaCheckpoint {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{} checkpoint {}x{} after {} iteration(s)",
self.stage, self.rows, self.cols, self.completed_iterations
)
}
}
impl core::fmt::Debug for FixedLambdaCheckpoint {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Display::fmt(self, f)
}
}
#[derive(Debug, thiserror::Error)]
pub enum OuterObjectiveErrorSource {
#[error(transparent)]
Estimation(Box<EstimationError>),
#[error(transparent)]
Objective(opt::ObjectiveEvalError),
}
impl OuterObjectiveErrorSource {
#[must_use]
pub fn estimation_error(&self) -> Option<&EstimationError> {
match self {
Self::Estimation(source) => Some(source),
Self::Objective(source) => source.downcast_ref::<EstimationError>(),
}
}
}
#[derive(thiserror::Error)]
pub enum EstimationError {
#[error(transparent)]
InvalidStabilization(#[from] crate::InvalidStabilization),
#[error("Underlying basis function generation failed: {0}")]
BasisError(#[from] BasisError),
#[error("Custom-family fit failed: {0}")]
CustomFamily(#[from] CustomFamilyError),
#[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
LinearSystemSolveFailed(FaerLinalgError),
#[error("Eigendecomposition failed: {0}")]
EigendecompositionFailed(FaerLinalgError),
#[error(
"Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
)]
PenaltySpectrumNonFinite {
context: String,
index: usize,
value: f64,
},
#[error(
"Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
)]
PenaltySpectrumIndefinite {
context: String,
index: usize,
value: f64,
tolerance: f64,
scale: f64,
},
#[error("Parameter constraint violation: {0}")]
ParameterConstraintViolation(String),
#[error(
"The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
)]
PirlsDidNotConverge {
max_iterations: usize,
last_change: f64,
},
#[error(
"{context} did not certify a stationary fixed-lambda optimum after {} iteration(s): \
{reason}; final minimized objective {objective_value:.6e}; {stationarity}. A fit is \
only minted from a converged optimization; resume by passing the carried checkpoint \
through the fixed-lambda input's `resume_from` field ({checkpoint}).",
.checkpoint.completed_iterations()
)]
FixedLambdaNewtonDidNotConverge {
context: String,
reason: FixedLambdaStallReason,
objective_value: f64,
stationarity: FixedLambdaStationarityEvidence,
checkpoint: FixedLambdaCheckpoint,
},
#[error(
"Block-orthogonal Gaussian REML did not converge within {iterations} outer passes: \
max relative rho-score residual {max_score_residual:.6e}/{score_tol:.3e}, \
minimum profiled curvature {min_profile_curvature:.6e} (negative allowance \
{profile_curvature_roundoff:.3e}; last scale fixed-point step \
{last_scale_step:.6e}{}). \
A fit is only minted from a converged optimization; resume from the \
checkpoint by passing `init_rhos` = {rho_checkpoint:?}.",
if *cycle_detected { ", deterministic limit cycle detected" } else { "" }
)]
BlockOrthogonalRemlDidNotConverge {
iterations: usize,
max_score_residual: f64,
score_tol: f64,
min_profile_curvature: f64,
profile_curvature_roundoff: f64,
last_scale_step: f64,
cycle_detected: bool,
rho_checkpoint: Vec<f64>,
},
#[error(
"Negative-binomial (theta, rho) optimization did not certify a joint optimum within \
{rounds} round(s): projected rho-gradient {rho_projected_grad_norm:.3e} against \
{rho_stationarity_bound:.3e}, theta-score Newton residual {theta_score_residual:.3e} \
against {theta_stationarity_bound:.3e}. A fit is only minted when both analytic \
partials are stationary at one identical point; resume from theta={theta_checkpoint:.6e} \
and rho={rho_checkpoint:?}."
)]
NegativeBinomialAlternationDidNotConverge {
rounds: usize,
theta_checkpoint: f64,
rho_projected_grad_norm: f64,
rho_stationarity_bound: f64,
theta_score_residual: f64,
theta_stationarity_bound: f64,
rho_checkpoint: Vec<f64>,
},
#[error(
"Beta precision refinement did not converge: after {passes} alternation pass(es) at the \
selected smoothing the moment estimate moved from phi={prior_phi:.6e} to \
phi={refreshed_phi:.6e} and the mean re-solve at the refreshed precision ended \
'{inner_status}' (deviance {deviance:.6e}). The (beta, phi) alternation is only minted at \
a fixed point where both the mean and the precision are stationary; a precision that \
keeps growing means the response carries no dispersion around the fitted mean at this \
smoothing, so no finite beta precision exists to certify."
)]
BetaPrecisionRefinementDidNotConverge {
passes: usize,
prior_phi: f64,
refreshed_phi: f64,
deviance: f64,
inner_status: String,
},
#[error(
"Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
The model cannot converge because a predictor perfectly separates the binary outcomes. \
(Diagnostic: max|eta| = {max_abs_eta:.2e})."
)]
PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
#[error(
"Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
has a threshold {threshold:.6e} that separates the binary outcomes \
(positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
)]
PrefitPerfectSeparationDetected {
column_index: usize,
threshold: f64,
positive_above_threshold: bool,
},
#[error(
"Pre-fit linear separation detected in the realized binomial inverse-link design: \
{num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
remove/reparameterize/penalize the separating columns."
)]
PrefitLinearSeparationDetected {
min_signed_margin: f64,
num_unpenalized_columns: usize,
column_indices: Vec<usize>,
},
#[error(
"Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
)]
PrefitRankDeficientDesignDetected {
rank: usize,
num_unpenalized_columns: usize,
min_eigenvalue: f64,
tolerance: f64,
column_indices: Vec<usize>,
},
#[error(
"Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
penalty/constraint before fitting."
)]
PrefitNearDegenerateDesignDetected {
num_unpenalized_columns: usize,
condition_number: f64,
min_eigenvalue: f64,
max_eigenvalue: f64,
tolerance: f64,
column_indices: Vec<usize>,
},
#[error(
"Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
so the unpenalized softmax MLE is not finite in that direction. \
(Diagnostic: max|eta| = {max_abs_eta:.2e})."
)]
MultinomialSeparationDetected {
iteration: usize,
max_abs_eta: f64,
active_class_index: usize,
row_index: usize,
},
#[error("{}", hessian_not_positive_definite_message(*min_eigenvalue))]
HessianNotPositiveDefinite { min_eigenvalue: f64 },
#[error("REML smoothing optimization failed to converge: {0}")]
RemlOptimizationFailed(String),
#[error("{reason}")]
TrialPointRefused { reason: String },
#[error("Fatal outer-objective evaluation failure ({context}): {source}")]
OuterObjectiveEvaluationFailed {
context: String,
#[source]
source: OuterObjectiveErrorSource,
},
#[error(
"Outer smoothing-parameter optimization did not certify a stationary optimum \
({context}): {reason} after {iterations} outer iteration(s); final objective \
{final_value:.6e}, projected gradient norm {} {stationarity_standard}. A fit is \
only minted from a converged optimization; the best iterate is carried as a \
checkpoint — resume by seeding the outer search at rho_checkpoint = \
{rho_checkpoint:?}.",
.projected_grad_norm.map_or_else(|| "unmeasured".to_string(), |g| format!("{g:.3e}")),
)]
RemlDidNotConverge {
context: String,
reason: String,
iterations: usize,
final_value: f64,
projected_grad_norm: Option<f64>,
stationarity_standard: StationarityStandard,
rho_checkpoint: Vec<f64>,
},
#[error(
"Fit assembly rejected a non-converged optimization state: inner status \
{inner_status}, outer status {outer_status}, after {outer_iterations} outer \
iteration(s); final objective {}; stationarity {stationarity}, \
step {step}. The best rho checkpoint is \
{rho_checkpoint:?} and the resume token is {resume_token:?}; no fitted-model \
API was constructed.",
.final_value.map_or_else(
|| "unavailable (this fit has no criterion value)".to_string(),
|value| format!("{value:.6e}"),
),
)]
FitDidNotConverge {
inner_status: String,
outer_status: String,
outer_iterations: usize,
final_value: Option<f64>,
stationarity: FitStationarityEvidence,
step: FitStationarityEvidence,
rho_checkpoint: Vec<f64>,
resume_token: Option<String>,
},
#[error("{context}: unified evaluator returned no gradient in {mode} mode")]
GradientUnavailable {
context: &'static str,
mode: &'static str,
},
#[error("An internal error occurred during model layout or coefficient mapping: {0}")]
LayoutError(String),
#[error(
"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."
)]
ModelIsIllConditioned { condition_number: f64 },
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error(
"Inverse-link domain violation for {link}: eta={eta:?} is outside the supported \
interval [{lower}, {upper}]"
)]
InverseLinkDomainViolation {
link: &'static str,
eta: f64,
lower: f64,
upper: f64,
},
#[error(
"PIRLS row geometry is not representable at row {row}: {quantity} evaluated from \
eta={eta:?} produced {value:?}"
)]
PirlsRowGeometryUnrepresentable {
row: usize,
quantity: &'static str,
eta: f64,
value: f64,
},
#[error(
"Exact Tweedie series work limit at row {row}: at least {required_terms_lower_bound:?} terms are required, budget is {budget}"
)]
ExactTweedieSeriesWorkLimit {
row: usize,
required_terms_lower_bound: f64,
budget: usize,
},
#[error(
"Log-strength domain violation at coordinate {coordinate}: value={value:?} is outside \
the supported interval [{lower}, {upper}]"
)]
LogStrengthDomainViolation {
coordinate: usize,
value: f64,
lower: f64,
upper: f64,
},
#[error("monotone root solve: {0}")]
MonotoneRoot(#[from] MonotoneRootError),
#[error("Calibrator training failed: {0}")]
CalibratorTrainingFailed(String),
#[error("Invalid specification: {0}")]
InvalidSpecification(String),
#[error("Prediction error")]
PredictionError,
}
impl core::fmt::Debug for EstimationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self)
}
}
impl EstimationError {
#[must_use]
pub fn pirls_row_geometry_unrepresentable(
row: usize,
quantity: &'static str,
eta: f64,
value: f64,
) -> Self {
Self::PirlsRowGeometryUnrepresentable {
row,
quantity,
eta,
value,
}
}
#[must_use]
pub fn advice(&self) -> Option<String> {
const SEPARATION: &str = "Enable Firth/Jeffreys bias reduction, remove or regularize \
the separating predictor, or switch link via link(type=...).";
const CONDITIONING: &str = "Check for collinear or constant predictors and overly \
complex smooth bases.";
match self {
Self::BasisError(inner) => inner.advice(),
Self::OuterObjectiveEvaluationFailed { source, .. } => {
source.estimation_error().and_then(Self::advice)
}
Self::PerfectSeparationDetected { .. }
| Self::MultinomialSeparationDetected { .. } => {
Some(format!("Detected (quasi-)separation. {SEPARATION}"))
}
Self::PrefitPerfectSeparationDetected { column_index, .. } => Some(format!(
"Detected separation driven by unpenalized column {column_index}. {SEPARATION}"
)),
Self::PrefitLinearSeparationDetected { column_indices, .. } => Some(format!(
"Detected separation driven by unpenalized columns {column_indices:?}. {SEPARATION}"
)),
Self::PrefitRankDeficientDesignDetected { column_indices, .. }
| Self::PrefitNearDegenerateDesignDetected { column_indices, .. } => Some(format!(
"Matrix conditioning issue in unpenalized columns {column_indices:?}. {CONDITIONING}"
)),
Self::ModelIsIllConditioned { .. }
| Self::HessianNotPositiveDefinite { .. }
| Self::LinearSystemSolveFailed(_)
| Self::EigendecompositionFailed(_) => {
Some(format!("Matrix conditioning issue detected. {CONDITIONING}"))
}
_ => None,
}
}
#[must_use]
pub fn is_trial_point_infeasible(&self) -> bool {
match self {
Self::CustomFamily(err) => err.is_trial_point_infeasible(),
Self::TrialPointRefused { .. } => true,
Self::ModelIsIllConditioned { .. }
| Self::PerfectSeparationDetected { .. }
| Self::MultinomialSeparationDetected { .. }
| Self::PirlsDidNotConverge { .. }
| Self::FixedLambdaNewtonDidNotConverge { .. } => true,
Self::InvalidStabilization { .. }
| Self::BasisError { .. }
| Self::LinearSystemSolveFailed { .. }
| Self::EigendecompositionFailed { .. }
| Self::PenaltySpectrumNonFinite { .. }
| Self::PenaltySpectrumIndefinite { .. }
| Self::ParameterConstraintViolation { .. }
| Self::BlockOrthogonalRemlDidNotConverge { .. }
| Self::NegativeBinomialAlternationDidNotConverge { .. }
| Self::BetaPrecisionRefinementDidNotConverge { .. }
| Self::PrefitPerfectSeparationDetected { .. }
| Self::PrefitLinearSeparationDetected { .. }
| Self::PrefitRankDeficientDesignDetected { .. }
| Self::PrefitNearDegenerateDesignDetected { .. }
| Self::HessianNotPositiveDefinite { .. }
| Self::RemlOptimizationFailed { .. }
| Self::OuterObjectiveEvaluationFailed { .. }
| Self::RemlDidNotConverge { .. }
| Self::FitDidNotConverge { .. }
| Self::GradientUnavailable { .. }
| Self::LayoutError { .. }
| Self::InvalidInput { .. }
| Self::InverseLinkDomainViolation { .. }
| Self::PirlsRowGeometryUnrepresentable { .. }
| Self::ExactTweedieSeriesWorkLimit { .. }
| Self::LogStrengthDomainViolation { .. }
| Self::MonotoneRoot { .. }
| Self::CalibratorTrainingFailed { .. }
| Self::InvalidSpecification { .. }
| Self::PredictionError { .. } => false,
}
}
pub fn fatal_outer_evaluation(context: impl Into<String>, source: EstimationError) -> Self {
if matches!(
&source,
EstimationError::OuterObjectiveEvaluationFailed { .. }
) {
source
} else {
EstimationError::OuterObjectiveEvaluationFailed {
context: context.into(),
source: OuterObjectiveErrorSource::Estimation(Box::new(source)),
}
}
}
pub fn fatal_objective_evaluation(
context: impl Into<String>,
source: opt::ObjectiveEvalError,
) -> Self {
assert!(
source.is_fatal(),
"fatal_objective_evaluation requires a producer-classified fatal error"
);
EstimationError::OuterObjectiveEvaluationFailed {
context: context.into(),
source: OuterObjectiveErrorSource::Objective(source),
}
}
pub fn is_fatal_outer_evaluation(&self) -> bool {
matches!(self, EstimationError::OuterObjectiveEvaluationFailed { .. })
}
#[must_use]
pub fn wrap_preserving_trial_point(self, context: &str) -> Self {
let infeasible = self.is_trial_point_infeasible();
let reason = format!("{context}: {self}");
if infeasible {
Self::TrialPointRefused { reason }
} else {
Self::InvalidInput(reason)
}
}
pub fn is_inner_solve_retreat(&self) -> bool {
self.is_trial_point_infeasible()
}
}
#[cfg(test)]
mod advice_policy_tests {
use super::*;
#[test]
fn advice_is_keyed_on_the_variant_and_flows_through_the_basis_wrapper() {
let separation = EstimationError::PrefitPerfectSeparationDetected {
column_index: 3,
threshold: 0.5,
positive_above_threshold: true,
};
let advice = separation.advice().expect("separation advice");
assert!(advice.contains("column 3"), "{advice}");
assert!(advice.contains("Firth"), "{advice}");
let conditioning = EstimationError::ModelIsIllConditioned {
condition_number: 1e18,
};
let advice = conditioning.advice().expect("conditioning advice");
assert!(advice.contains("collinear"), "{advice}");
let basis = EstimationError::BasisError(BasisError::duchon_smoothness_insufficient(
"hybrid diagonal",
0,
3,
1,
0.5,
));
let advice = basis.advice().expect("basis advice");
assert!(advice.contains("power"), "{advice}");
assert!(EstimationError::InvalidInput("dimension=16".into()).advice().is_none());
}
}
#[cfg(test)]
mod trial_point_classification_tests {
use super::*;
#[test]
fn every_inner_solve_retreat_is_a_trial_point_infeasibility() {
let retreats = [
EstimationError::ModelIsIllConditioned {
condition_number: 1.0e18,
},
EstimationError::PerfectSeparationDetected {
iteration: 3,
max_abs_eta: 1.0e3,
},
EstimationError::MultinomialSeparationDetected {
iteration: 3,
max_abs_eta: 1.0e3,
active_class_index: 1,
row_index: 2,
},
EstimationError::PirlsDidNotConverge {
max_iterations: 40,
last_change: 1.0e-2,
},
EstimationError::FixedLambdaNewtonDidNotConverge {
context: "trial-point classification fixture".to_string(),
reason: FixedLambdaStallReason::IterationBudgetExhausted,
objective_value: 12.5,
stationarity: FixedLambdaStationarityEvidence {
kind: FixedLambdaResidualKind::PenalizedGradientNorm,
residual: 1.0e-3,
bound: 1.0e-8,
},
checkpoint: FixedLambdaCheckpoint::new(
FixedLambdaSolverStage::MultinomialNewton,
vec![0.0, 0.0],
2,
1,
40,
)
.expect("fixture checkpoint geometry is valid"),
},
];
for error in retreats {
assert!(
error.is_inner_solve_retreat(),
"fixture must be a retreat: {error}"
);
assert!(
error.is_trial_point_infeasible(),
"a retreat is by its own definition a trial-point infeasibility: {error}"
);
}
}
#[test]
fn a_custom_family_trial_point_refusal_stays_recoverable() {
let reason = "joint Newton returned an indefinite mode at this rho";
assert!(
EstimationError::CustomFamily(CustomFamilyError::trial_point(reason))
.is_trial_point_infeasible()
);
assert!(
!EstimationError::RemlOptimizationFailed(reason.to_string())
.is_trial_point_infeasible(),
"the prose-only variant is exactly what must NOT carry a rho-local refusal"
);
}
}
impl From<LinalgError> for EstimationError {
fn from(error: LinalgError) -> Self {
match error {
LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
}
LinalgError::ModelIsIllConditioned { condition_number } => {
EstimationError::ModelIsIllConditioned { condition_number }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reml_refusal(standard: StationarityStandard) -> EstimationError {
EstimationError::RemlDidNotConverge {
context: "unit".to_string(),
reason: "budget exhausted".to_string(),
iterations: 7,
final_value: -1.25,
projected_grad_norm: Some(7.5e-1),
stationarity_standard: standard,
rho_checkpoint: vec![0.5],
}
}
fn measured(label: &'static str, derived_standard: bool) -> StationarityStandard {
StationarityStandard::Measured {
bound: 1.0e-2,
rung: StationarityRung {
label,
derived_standard,
},
}
}
#[test]
fn refusal_message_carries_the_rung_and_whether_it_is_derived() {
let derived = reml_refusal(measured("curvature-resolvability", true)).to_string();
assert!(
derived.contains("rung=curvature-resolvability"),
"refusal must name its rung: {derived}"
);
assert!(
derived.contains("derived_standard=true"),
"refusal must say whether the rung is the derived standard: {derived}"
);
let substitute = reml_refusal(measured("solver-band", false)).to_string();
assert!(substitute.contains("rung=solver-band"), "{substitute}");
assert!(
substitute.contains("derived_standard=false"),
"a gradient-magnitude substitute must not read as the derived standard: {substitute}"
);
}
#[test]
fn a_refusal_without_a_comparison_reports_no_bound() {
let message = reml_refusal(StationarityStandard::NoComparison).to_string();
assert!(
message.contains("against no stationarity bound"),
"a refusal that applied no bound must say so: {message}"
);
assert!(
!message.contains("rung="),
"no rung may be claimed where no bound was applied: {message}"
);
assert!(
!message.contains("1.000e-2"),
"no bound value may appear where none was applied: {message}"
);
}
#[test]
fn the_bound_and_its_rung_are_one_field() {
let standard = measured("probe-noise-floor", false);
assert_eq!(standard.bound(), Some(1.0e-2));
assert_eq!(
standard.rung().map(|rung| rung.label),
Some("probe-noise-floor")
);
assert_eq!(StationarityStandard::NoComparison.bound(), None);
assert_eq!(StationarityStandard::NoComparison.rung(), None);
}
#[test]
fn rung_rides_beside_the_bound_without_displacing_it() {
let message = reml_refusal(measured("solver-band", false)).to_string();
assert!(
message.contains("1.000e-2"),
"bound must survive: {message}"
);
assert!(
message.contains("7.500e-1"),
"projected gradient norm must survive: {message}"
);
}
#[test]
fn model_ill_conditioned_is_retreat() {
assert!(
EstimationError::ModelIsIllConditioned {
condition_number: 1e15
}
.is_inner_solve_retreat()
);
}
#[test]
fn perfect_separation_is_retreat() {
assert!(
EstimationError::PerfectSeparationDetected {
iteration: 3,
max_abs_eta: 50.0
}
.is_inner_solve_retreat()
);
}
#[test]
fn multinomial_separation_is_retreat() {
assert!(
EstimationError::MultinomialSeparationDetected {
iteration: 1,
max_abs_eta: 100.0,
active_class_index: 2,
row_index: 7
}
.is_inner_solve_retreat()
);
}
#[test]
fn pirls_did_not_converge_is_retreat() {
assert!(
EstimationError::PirlsDidNotConverge {
max_iterations: 100,
last_change: 1e-3
}
.is_inner_solve_retreat()
);
}
#[test]
fn invalid_input_is_not_retreat() {
assert!(!EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat());
}
#[test]
fn reml_optimization_failed_is_not_retreat() {
assert!(
!EstimationError::RemlOptimizationFailed("outer fail".to_string())
.is_inner_solve_retreat()
);
}
#[test]
fn fatal_outer_evaluation_is_typed_and_idempotent() {
let error = EstimationError::fatal_outer_evaluation(
"seed screening",
EstimationError::InvalidInput("frame mismatch".to_string()),
);
assert!(error.is_fatal_outer_evaluation());
assert!(error.to_string().contains("frame mismatch"));
let nested = EstimationError::fatal_outer_evaluation("fallback plan", error);
assert!(nested.is_fatal_outer_evaluation());
assert_eq!(
nested.to_string().matches("Fatal outer-objective").count(),
1,
"fatal provenance must not be re-wrapped at every orchestration layer"
);
}
#[test]
fn invalid_input_message_appears_in_display() {
let err = EstimationError::InvalidInput("test_message".to_string());
assert!(err.to_string().contains("test_message"));
}
#[test]
fn pirls_did_not_converge_mentions_max_iterations() {
let err = EstimationError::PirlsDidNotConverge {
max_iterations: 42,
last_change: 0.001,
};
assert!(err.to_string().contains("42"));
}
#[test]
fn fixed_lambda_checkpoint_validates_shape_and_values() {
let checkpoint = FixedLambdaCheckpoint::new(
FixedLambdaSolverStage::MultinomialNewton,
vec![1.0, 2.0, 3.0, 4.0],
2,
2,
7,
)
.expect("well-shaped finite checkpoint");
assert_eq!(
checkpoint.stage(),
FixedLambdaSolverStage::MultinomialNewton
);
assert_eq!(checkpoint.values(), &[1.0, 2.0, 3.0, 4.0]);
assert_eq!((checkpoint.rows(), checkpoint.cols()), (2, 2));
assert_eq!(checkpoint.completed_iterations(), 7);
assert!(
FixedLambdaCheckpoint::new(
FixedLambdaSolverStage::BinomialMultiNewton,
vec![1.0],
2,
1,
0,
)
.is_err(),
"coefficient length must match rows * cols"
);
assert!(
FixedLambdaCheckpoint::new(
FixedLambdaSolverStage::BinomialMultiNewton,
vec![f64::NAN],
1,
1,
0,
)
.is_err(),
"checkpoint coefficients must be finite"
);
assert!(
FixedLambdaCheckpoint::new(
FixedLambdaSolverStage::MultinomialFirth,
Vec::new(),
usize::MAX,
2,
0,
)
.is_err(),
"checkpoint shape multiplication must not overflow"
);
}
#[test]
fn fixed_lambda_error_displays_evidence_but_never_coefficients() {
let checkpoint = FixedLambdaCheckpoint::new(
FixedLambdaSolverStage::MultinomialFirth,
vec![12_345.678_9, -98_765.432_1],
2,
1,
11,
)
.expect("valid checkpoint");
let checkpoint_debug = format!("{checkpoint:?}");
assert!(!checkpoint_debug.contains("12345.6789"));
assert!(!checkpoint_debug.contains("98765.4321"));
let err = EstimationError::FixedLambdaNewtonDidNotConverge {
context: "test Firth solve".to_string(),
reason: FixedLambdaStallReason::LineSearchExhausted,
objective_value: 3.25,
stationarity: FixedLambdaStationarityEvidence {
kind: FixedLambdaResidualKind::NewtonDecrement,
residual: 0.125,
bound: 1.0e-7,
},
checkpoint,
};
let display = err.to_string();
assert!(display.contains("test Firth solve"));
assert!(display.contains("line search exhausted"));
assert!(display.contains("Newton decrement"));
assert!(display.contains("2x1"));
assert!(display.contains("11 iteration"));
assert!(!display.contains("12345.6789"));
assert!(!display.contains("98765.4321"));
assert_eq!(
format!("{err:?}"),
display,
"Debug delegates to safe Display"
);
assert!(err.is_inner_solve_retreat());
}
#[test]
fn from_linalg_invalid_input_maps_to_invalid_input() {
let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
let err = EstimationError::from(linalg_err);
assert!(matches!(err, EstimationError::InvalidInput(_)));
assert!(err.to_string().contains("linalg msg"));
}
#[test]
fn from_linalg_hessian_not_spd_maps_correctly() {
let linalg_err = LinalgError::HessianNotPositiveDefinite {
min_eigenvalue: -1.0,
};
let err = EstimationError::from(linalg_err);
assert!(matches!(
err,
EstimationError::HessianNotPositiveDefinite { .. }
));
}
}
fn hessian_not_positive_definite_message(min_eigenvalue: f64) -> String {
if min_eigenvalue.is_finite() && min_eigenvalue > 0.0 {
format!(
"Hessian factorization failed although the (lower-triangle) spectrum is positive \
(minimum eigenvalue: {min_eigenvalue:.4e}): the condition number exceeds float \
precision or the assembled matrix is asymmetric/non-finite outside the factored \
triangle. This indicates a numerical instability in the Hessian assembly or scaling."
)
} else {
format!(
"Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). \
This indicates a numerical instability."
)
}
}