use thiserror::Error;
use crate::{IdentifiabilityAudit, MapUniquenessError};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum JointNewtonTerminalReason {
CycleBudget,
FullyRejectedExactFixedPoint {
consecutive_cycles: usize,
joint_trust_radius: f64,
rejection_counts: [usize; 4],
},
FullyRejectedAtTrustRegionFloor {
consecutive_cycles: usize,
joint_trust_radius: f64,
rejection_counts: [usize; 4],
},
SlowGeometricRate {
rate_per_cycle: f64,
window_cycles: usize,
projected_cycles_to_tolerance: usize,
residual: f64,
residual_tol: f64,
ray: Option<RayRestoration>,
},
StalledOnDescendingRay {
residual: f64,
residual_tol: f64,
cycles: usize,
ray: RayRestoration,
},
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RayRestoration {
pub block: usize,
pub rho_first: usize,
pub rho_count: usize,
pub log_strength_ratio: f64,
pub likelihood_slope: f64,
pub penalty_slope: f64,
pub block_step_inf: f64,
}
impl RayRestoration {
pub fn rho_indices(&self) -> std::ops::Range<usize> {
self.rho_first..self.rho_first + self.rho_count
}
}
impl std::fmt::Display for RayRestoration {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"block {} is under-penalized along the accepted step (likelihood slope \
{:.3e}, penalty slope {:.3e}, block step {:.3e}): the ray closes at \
{:.4}x its penalty strength, i.e. rho[{}..{}] += {:.4}",
self.block,
self.likelihood_slope,
self.penalty_slope,
self.block_step_inf,
self.log_strength_ratio.exp(),
self.rho_first,
self.rho_first + self.rho_count,
self.log_strength_ratio,
)
}
}
impl std::fmt::Display for JointNewtonTerminalReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CycleBudget => write!(f, "cycle budget"),
Self::FullyRejectedExactFixedPoint {
consecutive_cycles,
joint_trust_radius,
rejection_counts,
} => write!(
f,
"complete rejected-cycle state repeated {consecutive_cycles} times at \
trust radius {joint_trust_radius:.6e}; rejects \
[model,likelihood,objective,feasibility]={rejection_counts:?}"
),
Self::FullyRejectedAtTrustRegionFloor {
consecutive_cycles,
joint_trust_radius,
rejection_counts,
} => write!(
f,
"all attempts rejected for {consecutive_cycles} cycles at the absolute \
trust-region floor {joint_trust_radius:.6e}; rejects \
[model,likelihood,objective,feasibility]={rejection_counts:?}"
),
Self::SlowGeometricRate {
rate_per_cycle,
window_cycles,
projected_cycles_to_tolerance,
residual,
residual_tol,
ray,
} => {
if *rate_per_cycle < 1.0 {
write!(
f,
"residual {residual:.6e} still contracting at {rate_per_cycle:.4}x per \
cycle over the last {window_cycles} cycles, projected more than \
{projected_cycles_to_tolerance} further cycles to reach \
{residual_tol:.6e}: the solve was descending along a direction with \
no finite minimizer in reach, not stuck"
)?;
} else {
write!(
f,
"residual {residual:.6e} is not contracting ({rate_per_cycle:.4}x per \
cycle over the last {window_cycles} cycles, every step accepted) and \
cannot reach {residual_tol:.6e}: the solve was descending along a \
direction with no finite minimizer in reach, not stuck"
)?;
}
match ray {
Some(ray) => write!(f, "; {ray}"),
None => write!(
f,
"; no block's penalty opposes the accepted step, so no penalty \
strength closes this ray"
),
}
}
Self::StalledOnDescendingRay {
residual,
residual_tol,
cycles,
ray,
} => write!(
f,
"residual {residual:.6e} stalled or grew against {residual_tol:.6e} over \
{cycles} cycles while the accepted steps kept descending a direction with no \
finite minimizer in reach, so the seed is under-penalized rather than \
failed; {ray}"
),
}
}
}
#[must_use]
pub fn relative_stationarity(stationarity_residual: f64, stationarity_scale: f64) -> f64 {
stationarity_residual / (1.0 + stationarity_scale)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InnerConvergenceTerminalState {
Blockwise {
cycle: usize,
max_accepted_step: f64,
max_proposed_step: f64,
step_tol: f64,
objective_change: f64,
objective_tol: f64,
joint_stationarity_ok: bool,
},
JointNewton {
cycle: usize,
stationarity_residual: f64,
residual_tol: f64,
stationarity_scale: f64,
step_inf: f64,
step_tol: f64,
resolvable_negative_curvature: bool,
best_stationarity_residual: f64,
cycles_since_best_residual: usize,
termination_reason: JointNewtonTerminalReason,
},
}
impl std::fmt::Display for InnerConvergenceTerminalState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Blockwise {
cycle,
max_accepted_step,
max_proposed_step,
step_tol,
objective_change,
objective_tol,
joint_stationarity_ok,
} => write!(
f,
"blockwise terminal cycle {cycle}: max_accepted_step={max_accepted_step:.6e} \
(tol={step_tol:.6e}), max_proposed_step={max_proposed_step:.6e}, \
objective_change={objective_change:.6e} (tol={objective_tol:.6e}), \
joint_stationarity_ok={joint_stationarity_ok}"
),
Self::JointNewton {
cycle,
stationarity_residual,
residual_tol,
stationarity_scale,
step_inf,
step_tol,
resolvable_negative_curvature,
best_stationarity_residual,
cycles_since_best_residual,
termination_reason,
} => write!(
f,
"joint-Newton terminal cycle {cycle}: \
stationarity_residual={stationarity_residual:.6e} (tol={residual_tol:.6e}), \
relative_stationarity={:.6e} \
(= residual/(1+scale), scale={stationarity_scale:.6e}; \
THIS is the comparable column, not residual/tol), \
step_inf={step_inf:.6e} (tol={step_tol:.6e}), \
resolvable_negative_curvature={resolvable_negative_curvature}, \
best_stationarity_residual={best_stationarity_residual:.6e} \
(last improved {cycles_since_best_residual} cycle(s) before this one), \
termination={termination_reason}",
relative_stationarity(*stationarity_residual, *stationarity_scale),
),
}
}
}
fn render_projected_kkt_comparison(residual: Option<f64>, tol: Option<f64>) -> String {
match (residual, tol) {
(Some(residual), Some(tol)) => format!(
"projected KKT residual |r|_inf={residual:.6e} against tol={tol:.6e}"
),
(Some(residual), None) => format!(
"projected KKT residual |r|_inf={residual:.6e}; \
no stationarity tolerance was recorded to compare it against"
),
(None, Some(tol)) => format!(
"no projected KKT residual was recorded; the stationarity tolerance \
on this path was {tol:.6e}"
),
(None, None) => "this solver path emits no typed projected-KKT diagnostic, so \
neither a residual nor a tolerance was recorded — read the \
terminal decision variables above instead"
.to_string(),
}
}
#[derive(Debug, Clone, Error)]
pub enum CustomFamilyError {
#[error("custom-family invalid input in {context}: {reason}")]
InvalidInput {
context: &'static str,
reason: String,
},
#[error("custom-family optimization error in {context}: {reason}")]
Optimization {
context: &'static str,
reason: String,
},
#[error("{reason}")]
DimensionMismatch { reason: String },
#[error("{reason}")]
NumericalFailure { reason: String },
#[error("{reason}")]
ConstraintViolation { reason: String },
#[error("{reason}")]
UnsupportedConfiguration { reason: String },
#[error(
"custom-family inner solve did not converge after {cycles} cycle(s) [{}] \
({}); \
refusing to expose profile objective derivatives for theta_dim={theta_dim} \
(rho_dim={rho_dim}, psi_dim={psi_dim}). The analytic outer gradient/Hessian \
require the inner KKT equation F_beta(beta, theta)=0; returning a value with \
zero or shape-only derivatives is mathematically inconsistent. This trial \
point is infeasible; the outer search may step away from it.",
match terminal {
Some(state) => state.to_string(),
None => "no terminal convergence state was recorded".to_string(),
},
render_projected_kkt_comparison(*kkt_residual, *kkt_tol)
)]
InnerSolveNotConverged {
cycles: usize,
terminal: Option<InnerConvergenceTerminalState>,
kkt_residual: Option<f64>,
kkt_tol: Option<f64>,
theta_dim: usize,
rho_dim: usize,
psi_dim: usize,
},
#[error("{reason}")]
BasisDecompositionFailed { reason: String },
#[error("identifiability audit refused the fit: {}", audit.summary)]
IdentifiabilityFailure { audit: IdentifiabilityAudit },
#[error("MAP estimate non-unique: {}", error)]
MapUniquenessFailure { error: MapUniquenessError },
#[error("inner solve refused this trial point: {reason}")]
TrialPointRefused { reason: String },
}
impl CustomFamilyError {
pub fn trial_point(reason: impl Into<String>) -> Self {
Self::TrialPointRefused {
reason: reason.into(),
}
}
#[must_use]
pub fn into_trial_point(self) -> Self {
if self.is_trial_point_infeasible() {
self
} else {
Self::TrialPointRefused {
reason: self.to_string(),
}
}
}
}
impl From<String> for CustomFamilyError {
fn from(value: String) -> Self {
Self::TrialPointRefused { reason: value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regrading_a_trial_point_refusal_does_not_prefix_it_twice_2667() {
let inner = CustomFamilyError::trial_point(
"synthetic outer objective failure: block[0] evaluate()",
);
let round_tripped = CustomFamilyError::trial_point(inner.to_string());
assert_eq!(
round_tripped
.to_string()
.matches("inner solve refused this trial point:")
.count(),
2,
"fixture must reproduce the doubling this test is about"
);
let regraded = inner.clone().into_trial_point();
assert_eq!(
regraded
.to_string()
.matches("inner solve refused this trial point:")
.count(),
1,
"an error that already answers the question must not be re-wrapped: {regraded}"
);
assert_eq!(regraded.to_string(), inner.to_string());
assert!(regraded.is_trial_point_infeasible());
let structural = CustomFamilyError::DimensionMismatch {
reason: "log-lambda length mismatch: got 3, expected 4".to_string(),
};
let structural_text = structural.to_string();
let regraded = structural.into_trial_point();
assert!(regraded.is_trial_point_infeasible());
assert!(
regraded.to_string().contains(&structural_text),
"reclassification must not drop the original text: {regraded}"
);
}
#[test]
fn two_absences_are_not_reported_as_a_comparison_2600() {
let absent = CustomFamilyError::InnerSolveNotConverged {
cycles: 53,
terminal: None,
kkt_residual: None,
kkt_tol: None,
theta_dim: 3,
rho_dim: 3,
psi_dim: 0,
};
let msg = absent.to_string();
assert!(
!msg.contains("None against"),
"two absences must not be laid out as a comparison: {msg}"
);
assert!(
msg.contains("emits no typed projected-KKT diagnostic"),
"the message must name the absence as an absence: {msg}"
);
let measured = CustomFamilyError::InnerSolveNotConverged {
cycles: 53,
terminal: None,
kkt_residual: Some(1.906428e0),
kkt_tol: Some(8.307952e-4),
theta_dim: 3,
rho_dim: 3,
psi_dim: 0,
};
let msg = measured.to_string();
assert!(
msg.contains("|r|_inf=1.906428e0 against tol=8.307952e-4"),
"a real comparison must still render as one: {msg}"
);
let half = CustomFamilyError::InnerSolveNotConverged {
cycles: 7,
terminal: None,
kkt_residual: Some(4.069e3),
kkt_tol: None,
theta_dim: 1,
rho_dim: 1,
psi_dim: 0,
};
let msg = half.to_string();
assert!(
msg.contains("no stationarity tolerance was recorded"),
"a half-present pair must name the missing half: {msg}"
);
}
#[test]
fn joint_newton_terminal_state_reports_the_best_residual_not_only_the_last_2600() {
let state = InnerConvergenceTerminalState::JointNewton {
cycle: 52,
stationarity_residual: 1.906428e0,
residual_tol: 8.307952e-4,
stationarity_scale: 829.7952,
step_inf: 4.958893e0,
step_tol: 8.493315e-5,
resolvable_negative_curvature: true,
best_stationarity_residual: 1.578e-3,
cycles_since_best_residual: 27,
termination_reason: JointNewtonTerminalReason::CycleBudget,
};
let msg = state.to_string();
assert!(
msg.contains("stationarity_residual=1.906428e0"),
"message: {msg}"
);
assert!(
msg.contains("best_stationarity_residual=1.578000e-3"),
"message: {msg}"
);
assert!(
msg.contains("27 cycle(s) before this one"),
"message: {msg}"
);
}
#[test]
fn joint_newton_terminal_state_carries_the_column_that_ranks_correctly_2713() {
let converged = InnerConvergenceTerminalState::JointNewton {
cycle: 12,
stationarity_residual: 1.065281e-7,
residual_tol: 1e-11 * (1.0 + 43.807),
stationarity_scale: 43.807,
step_inf: 1.0e-9,
step_tol: 1.0e-10,
resolvable_negative_curvature: false,
best_stationarity_residual: 1.065281e-7,
cycles_since_best_residual: 0,
termination_reason: JointNewtonTerminalReason::CycleBudget,
};
let far = InnerConvergenceTerminalState::JointNewton {
cycle: 12,
stationarity_residual: 1.4e-3 * (1.0 + 3.3392),
residual_tol: 1e-6 * (1.0 + 3.3392),
stationarity_scale: 3.3392,
step_inf: 1.0e-3,
step_tol: 1.0e-6,
resolvable_negative_curvature: false,
best_stationarity_residual: 1.4e-3 * (1.0 + 3.3392),
cycles_since_best_residual: 0,
termination_reason: JointNewtonTerminalReason::CycleBudget,
};
let (r_a, t_a, s_a) = (1.065281e-7, 1e-11 * (1.0 + 43.807), 43.807);
let (r_b, t_b, s_b) = (1.4e-3 * (1.0 + 3.3392), 1e-6 * (1.0 + 3.3392), 3.3392);
assert!(
r_a / t_a > 200.0 && r_b / t_b > 1000.0,
"the two rows must reproduce the measured N x over tolerance values"
);
assert!(
r_a / t_a < r_b / t_b,
"sanity: both rows are 'over tolerance', and by that ratio they are \
only ~6x apart"
);
let rel_a = relative_stationarity(r_a, s_a);
let rel_b = relative_stationarity(r_b, s_b);
assert!(
rel_a < 1e-8 && rel_b > 1e-4,
"relative stationarity: A={rel_a:.3e} must be the converged row, \
B={rel_b:.3e} the unconverged one"
);
assert!(
rel_b / rel_a > 1e5,
"the two rows differ by five-plus orders in relative stationarity \
({rel_a:.3e} vs {rel_b:.3e}) while their printed R/T differ by ~6x"
);
for (state, expected) in [(converged, rel_a), (far, rel_b)] {
let msg = state.to_string();
assert!(
msg.contains(&format!("relative_stationarity={expected:.6e}")),
"message must print the comparable column: {msg}"
);
assert!(
msg.contains("scale="),
"message must print the denominator it used: {msg}"
);
}
}
#[test]
fn relative_stationarity_degrades_to_the_absolute_residual_at_zero_scale_2713() {
let absolute = relative_stationarity(3.7e-9, 0.0);
assert!(
absolute.to_bits() == 3.7e-9_f64.to_bits(),
"with no scale the criterion is the absolute residual, got {absolute:.6e}"
);
let (residual, scale) = (5.275447e0, 5.2754e6);
let mixed = relative_stationarity(residual, scale);
let bare = residual / scale;
assert!(
((mixed - bare) / bare).abs() < 1e-5,
"mixed={mixed:.6e} bare={bare:.6e}"
);
}
#[test]
fn invalid_input_display_contains_context_and_reason() {
let err = CustomFamilyError::InvalidInput {
context: "my_context",
reason: "something broke".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("my_context"), "message: {msg}");
assert!(msg.contains("something broke"), "message: {msg}");
}
#[test]
fn optimization_display_contains_context_and_reason() {
let err = CustomFamilyError::Optimization {
context: "outer_loop",
reason: "diverged".to_string(),
};
let msg = err.to_string();
assert!(
msg.contains("outer_loop") && msg.contains("diverged"),
"message: {msg}"
);
}
#[test]
fn dimension_mismatch_displays_reason() {
let err = CustomFamilyError::DimensionMismatch {
reason: "3 vs 4".to_string(),
};
assert_eq!(err.to_string(), "3 vs 4");
}
#[test]
fn numerical_failure_displays_reason() {
let err = CustomFamilyError::NumericalFailure {
reason: "NaN detected".to_string(),
};
assert_eq!(err.to_string(), "NaN detected");
}
#[test]
fn a_string_boundary_refusal_is_recoverable_not_invalid_input() {
let err = CustomFamilyError::from("no Laplace mode at this rho".to_string());
assert!(matches!(err, CustomFamilyError::TrialPointRefused { .. }));
assert!(err.is_trial_point_infeasible());
assert!(err.to_string().contains("no Laplace mode at this rho"));
assert_eq!(
CustomFamilyError::trial_point("x").to_string(),
CustomFamilyError::from("x".to_string()).to_string(),
"the named constructor and the blanket conversion must agree"
);
assert!(
!CustomFamilyError::InvalidInput {
context: "c",
reason: "r".to_string(),
}
.is_trial_point_infeasible(),
"`InvalidInput` must keep meaning what it says"
);
}
#[test]
fn rendering_a_custom_family_error_uses_display() {
let err = CustomFamilyError::NumericalFailure {
reason: "singular".to_string(),
};
assert_eq!(err.to_string(), "singular");
}
}
impl CustomFamilyError {
#[must_use]
pub fn is_trial_point_infeasible(&self) -> bool {
match self {
Self::InnerSolveNotConverged { .. } => true,
Self::TrialPointRefused { .. } => true,
Self::InvalidInput { .. }
| Self::Optimization { .. }
| Self::DimensionMismatch { .. }
| Self::NumericalFailure { .. }
| Self::ConstraintViolation { .. }
| Self::UnsupportedConfiguration { .. }
| Self::BasisDecompositionFailed { .. }
| Self::IdentifiabilityFailure { .. }
| Self::MapUniquenessFailure { .. } => false,
}
}
}