Skip to main content

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> for SurvivalLocationScaleError {
41    fn from(
42        err: crate::block_layout::block_count::BlockCountMismatch,
43    ) -> SurvivalLocationScaleError {
44        SurvivalLocationScaleError::DimensionMismatch {
45            reason: err.message(),
46        }
47    }
48}
49
50impl From<String> for SurvivalLocationScaleError {
51    /// Inbound conversion from the many `Result<_, String>` helpers this
52    /// module still calls into. The text is preserved verbatim; we only
53    /// pick a generic category so external messages flow through `?`
54    /// without per-callsite `.map_err`.
55    fn from(reason: String) -> SurvivalLocationScaleError {
56        SurvivalLocationScaleError::InternalInvariant { reason }
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Overflow-safe arithmetic for the survival exact-Newton chain
62// ---------------------------------------------------------------------------
63//
64// The survival location-scale model computes inv_sigma = exp(-eta_ls) and
65// multiplies it through many intermediate quantities (q0, qdot, g, ...).
66// When eta_ls is very negative (sigma → 0, distribution very concentrated),
67// exp(-eta_ls) can overflow to inf, poisoning downstream sums with NaN via
68// inf * 0 or inf - inf patterns.
69//
70// The protection strategy is layered:
71//
72//   Layer 1 – `exp_neg_stable`: exact exp(-eta_ls) for every value that is
73//     representable in binary64, saturating near f64::MAX only past the
74//     representability boundary (`EXP_SATURATION_MAX_ARG` ≈ ln(f64::MAX))
75//     instead of overflowing to +inf.  Underflow (exp(-x) → 0 for large
76//     positive x) is allowed because it is the mathematically correct
77//     limit.  The saturation never rewrites a finite model: it engages
78//     only where the true value has no f64 representation.
79//
80//   Layer 2 – `survival_q0_from_eta`: uses exact log-space arithmetic to
81//     detect when |eta_t * inv_sigma| genuinely exceeds f64::MAX and
82//     saturates to ±MAX instead of overflowing; when exp(-eta_ls) alone is
83//     unrepresentable but the product is finite it evaluates the product in
84//     the log domain, so the value channel matches the mathematical
85//     function on the whole representable range.
86//
87//   Layer 3 – factorized time-derivative algebra and compensated subtraction:
88//     the base dq/dt chain is evaluated as exp(-eta_ls) * (eta_t*eta_ls' - eta_t')
89//     so the shared exp(-eta_ls) factor is applied only once, and
90//     d_eta/dt = d_raw + qdot is formed with a compensated sum that
91//     carries an explicit roundoff bound into the monotonicity gate.
92//
93//   Layer 4 – `safe_product` / `safe_sum2` plus `exact_row_kernel`: the generic
94//     arithmetic guards still clamp inf products to MAX/MIN and map
95//     inf + (-inf) → 0 as defense in depth, and the row kernel splits the old
96//     `!g.is_finite()` hard error
97//     into NaN (hard error for genuinely bad data) and ±inf (clamped to MAX
98//     so the monotonicity guard can apply).
99//
100// The invariant: no NaN ever reaches the solver; all overflow paths saturate
101// to large finite values that the monotonicity floor and penalty then control.
102// ---------------------------------------------------------------------------
103
104// Layer 1 (one-sided overflow guard on the inverse-sigma link), its
105// helper `exp_neg_stable`, and `exp_sigma_inverse_from_eta_scalar` now
106// live in `crate::sigma_link` so every consumer — solver
107// internals here, `main.rs` callers, and any Rust↔Python boundary
108// code — picks up the same clamp. Keeping a local copy here previously
109// allowed silent semantic divergence between the canonical sigma_link
110// version (unclamped) and the survival-local clamped version.