gam_models/survival/location_scale/error.rs
1/// Typed errors emitted by the survival location-scale family pipeline.
2///
3/// Each variant carries a pre-formatted `reason` string so `Display` is
4/// byte-equivalent to the original `format!(...)` outputs the module used
5/// before the typed-error migration. The category split lets callers
6/// pattern-match on the failure kind without dragging the string apart.
7#[derive(Debug, Clone)]
8pub enum SurvivalLocationScaleError {
9 /// Row/column/length disagreement between vectors, matrices, designs,
10 /// penalty blocks, or coefficient/parameter dimensions.
11 DimensionMismatch { reason: String },
12 /// Spec-level validation: tolerances, iteration caps, knot-vector
13 /// lengths, time intervals, weight values, or missing/contradictory
14 /// configuration fields the user supplied.
15 InvalidConfiguration { reason: String },
16 /// Structural constraint violated at runtime: monotonicity guards,
17 /// lower bounds on coefficients, nonnegativity, derivative-basis
18 /// sign, or values outside an allowed semantic range.
19 ConstraintViolation { reason: String },
20 /// A numerical step produced a non-finite or out-of-domain value
21 /// downstream code cannot consume (NaN products, invalid pdf,
22 /// survival probability out of (0,1], etc.).
23 NumericalFailure { reason: String },
24 /// Internal invariant about pipeline state (empty block markers,
25 /// unexpected ranks, schema/state inconsistencies surfaced from
26 /// inner helpers).
27 InternalInvariant { reason: String },
28}
29
30impl_reason_error_boilerplate! {
31 SurvivalLocationScaleError {
32 DimensionMismatch,
33 InvalidConfiguration,
34 ConstraintViolation,
35 NumericalFailure,
36 InternalInvariant,
37 }
38}
39
40impl From<crate::block_layout::block_count::BlockCountMismatch>
41 for SurvivalLocationScaleError
42{
43 fn from(
44 err: crate::block_layout::block_count::BlockCountMismatch,
45 ) -> SurvivalLocationScaleError {
46 SurvivalLocationScaleError::DimensionMismatch {
47 reason: err.message(),
48 }
49 }
50}
51
52impl From<String> for SurvivalLocationScaleError {
53 /// Inbound conversion from the many `Result<_, String>` helpers this
54 /// module still calls into. The text is preserved verbatim; we only
55 /// pick a generic category so external messages flow through `?`
56 /// without per-callsite `.map_err`.
57 fn from(reason: String) -> SurvivalLocationScaleError {
58 SurvivalLocationScaleError::InternalInvariant { reason }
59 }
60}
61
62// ---------------------------------------------------------------------------
63// Overflow-safe arithmetic for the survival exact-Newton chain
64// ---------------------------------------------------------------------------
65//
66// The survival location-scale model computes inv_sigma = exp(-eta_ls) and
67// multiplies it through many intermediate quantities (q0, qdot, g, ...).
68// When eta_ls is very negative (sigma → 0, distribution very concentrated),
69// exp(-eta_ls) can overflow to inf, poisoning downstream sums with NaN via
70// inf * 0 or inf - inf patterns.
71//
72// The protection strategy is layered:
73//
74// Layer 1 – `exp_neg_stable`: cap the exp argument at +500 (one-sided)
75// so inv_sigma ≤ exp(500) ≈ 1.4e217, preventing overflow at the
76// source. Underflow (exp(-x) → 0 for large positive x) is allowed
77// because it is the mathematically correct limit. Products like
78// inv_sigma * eta_t stay finite for any eta_t below ~1e91.
79//
80// Layer 2 – `survival_q0_from_eta`: uses log-space arithmetic to detect
81// when |eta_t * inv_sigma| would exceed the clamp ceiling and saturates
82// to ±MAX instead of overflowing.
83//
84// Layer 3 – factorized time-derivative algebra and compensated subtraction:
85// the base dq/dt chain is evaluated as exp(-eta_ls) * (eta_t*eta_ls' - eta_t')
86// so the shared exp(-eta_ls) factor is applied only once, and
87// d_eta/dt = d_raw + qdot is formed with a compensated sum that
88// carries an explicit roundoff bound into the monotonicity gate.
89//
90// Layer 4 – `safe_product` / `safe_sum2` plus `exact_row_kernel`: the generic
91// arithmetic guards still clamp inf products to MAX/MIN and map
92// inf + (-inf) → 0 as defense in depth, and the row kernel splits the old
93// `!g.is_finite()` hard error
94// into NaN (hard error for genuinely bad data) and ±inf (clamped to MAX
95// so the monotonicity guard can apply).
96//
97// The invariant: no NaN ever reaches the solver; all overflow paths saturate
98// to large finite values that the monotonicity floor and penalty then control.
99// ---------------------------------------------------------------------------
100
101// Layer 1 (one-sided overflow guard on the inverse-sigma link), its
102// helper `exp_neg_stable`, and `exp_sigma_inverse_from_eta_scalar` now
103// live in `crate::sigma_link` so every consumer — solver
104// internals here, `main.rs` callers, and any Rust↔Python boundary
105// code — picks up the same clamp. Keeping a local copy here previously
106// allowed silent semantic divergence between the canonical sigma_link
107// version (unclamped) and the survival-local clamped version.