Skip to main content

gam_models/survival/
lognormal_kernel.rs

1//! Shared analytic kernel for latent-variable families with lognormal structure.
2//!
3//! The kernel object `K_{k,m}(μ, σ) := E[exp(k·U − m·exp(U))]`, where
4//! `U ~ N(μ, σ²)`, is the only special function required by all latent families.
5//!
6//! It satisfies exact μ-recurrences (see [`kernel_ratio_jet`]) and the
7//! corresponding heat-equation σ-identities, so fixed-σ latent families reduce
8//! to evaluating kernel bundles at shifted arguments.
9//!
10//! Row likelihoods for binary and survival models are small signed sums of
11//! kernel terms; [`LogKernelSumJet`] evaluates their log-derivatives from
12//! log-space kernel bundles and treats non-positive signed sums as invalid rows.
13
14use crate::model_types::EstimationError;
15use crate::probability::{log1mexp_positive, signed_log_sum_exp};
16use crate::quadrature::{
17    IntegratedExpectationMode, QuadratureContext, log_survival_jet,
18    lognormal_laplace_unit_log_term_shared,
19};
20use serde::{Deserialize, Serialize};
21use std::fmt;
22
23// ─── Typed errors ────────────────────────────────────────────────────────────
24
25/// Errors produced by the lognormal-kernel frailty/marginal-slope validators.
26///
27/// Public boundaries that historically returned `Result<_, String>` continue to
28/// do so via `.map_err(|e| e.to_string())`; the `Display` impl reproduces the
29/// original error strings byte-for-byte.
30#[derive(Debug, Clone)]
31pub enum LognormalKernelError {
32    /// The chosen frailty modifier is not finite-state exact with the
33    /// requested marginal-slope family.
34    InvalidSpec { reason: String },
35}
36
37impl_reason_error_boilerplate! {
38    LognormalKernelError {
39        InvalidSpec,
40    }
41}
42
43// ─── Frailty specification ───────────────────────────────────────────────────
44
45/// How the hazard multiplier frailty loads onto the hazard components.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum HazardLoading {
49    /// Frailty multiplies the entire hazard: h(t|U) = exp(U) · h_0(t).
50    Full,
51    /// Frailty multiplies only the disease-like component; an exogenous
52    /// background ("Makeham") component is unloaded:
53    ///   h(t|U) = exp(U) · h_loaded(t) + h_unloaded(t).
54    /// This is the faithful model for Gompertz-Makeham.
55    LoadedVsUnloaded,
56}
57
58/// Frailty modifier specification at the family level.
59///
60/// Two structurally different exact modifiers exist:
61///
62/// 1. **GaussianShift**: additive Gaussian on the final transformation index.
63///    Exact for probit families — the existing sextic microcell kernel survives
64///    unchanged (just scale denested cell coefficients by 1/√(1+σ²)).
65///
66/// 2. **HazardMultiplier**: lognormal multiplier on the loaded cumulative hazard.
67///    Exact for PH/cloglog families — row likelihoods are finite sums of
68///    K_{k,m}(μ, σ) kernel terms.
69///
70/// These are mathematically distinct families.  Do not mix them.
71#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
72#[serde(tag = "scale_kind", rename_all = "kebab-case")]
73pub enum FrailtyScale {
74    Fixed { sigma: f64 },
75    Learned { initial_sigma: f64 },
76}
77
78impl FrailtyScale {
79    fn validate(self, kind: &str) -> Result<(), LognormalKernelError> {
80        match self {
81            Self::Fixed { sigma } if sigma.is_finite() && sigma >= 0.0 => Ok(()),
82            Self::Fixed { sigma } => Err(LognormalKernelError::InvalidSpec {
83                reason: format!(
84                    "{kind} frailty Fixed scale requires finite sigma >= 0, got {sigma}"
85                ),
86            }),
87            Self::Learned { initial_sigma }
88                if initial_sigma.is_finite() && initial_sigma > 0.0 =>
89            {
90                Ok(())
91            }
92            Self::Learned { initial_sigma } => Err(LognormalKernelError::InvalidSpec {
93                reason: format!(
94                    "{kind} frailty Learned scale requires finite initial_sigma > 0, got {initial_sigma}"
95                ),
96            }),
97        }
98    }
99
100    /// Exact log-sigma coordinate and declared finite chart domain for a
101    /// learned scale. Fixed scales have no optimizer coordinate.
102    pub(crate) fn learned_log_sigma_coordinate(self) -> Option<(f64, f64, f64)> {
103        match self {
104            Self::Fixed { .. } => None,
105            Self::Learned { initial_sigma } => Some((initial_sigma.ln(), -12.0, 6.0)),
106        }
107    }
108}
109
110#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
111#[serde(tag = "frailty_kind", rename_all = "kebab-case")]
112pub enum FrailtySpec {
113    /// No frailty modifier.
114    #[default]
115    None,
116    /// Gaussian shift on the final scalar index: U ~ N(0, σ²) added to η.
117    /// Exact for probit: E[Φ(η + U)] = Φ(η / √(1+σ²)).
118    /// The existing sextic microcell kernel is preserved.
119    GaussianShift {
120        scale: FrailtyScale,
121    },
122    /// Lognormal hazard multiplier: conditional hazard h(t|U) involves exp(U).
123    /// Exact for PH/cloglog/survival via K_{k,m} kernel.
124    HazardMultiplier {
125        scale: FrailtyScale,
126        /// How the multiplier loads onto hazard components.
127        loading: HazardLoading,
128    },
129}
130
131impl FrailtySpec {
132    /// Whether this spec requests an actual frailty modifier.
133    ///
134    /// [`FrailtySpec::None`] is the sole "no frailty" value. Family/mode guards
135    /// that only support the no-frailty case must reject on this predicate, or they
136    /// misclassify every ordinary CLI fit as a frailty request.
137    #[inline]
138    pub fn is_active(&self) -> bool {
139        !matches!(self, Self::None)
140    }
141
142    /// Validate the frailty scale domain independently of a model family.
143    pub fn validate(&self) -> Result<(), LognormalKernelError> {
144        let (kind, scale) = match self {
145            Self::None => return Ok(()),
146            Self::GaussianShift { scale } => ("GaussianShift", *scale),
147            Self::HazardMultiplier { scale, .. } => ("HazardMultiplier", *scale),
148        };
149        scale.validate(kind)
150    }
151
152    /// Validate that this frailty spec is compatible with score_warp/linkwiggle
153    /// cubic marginal-slope families.
154    ///
155    /// - `GaussianShift` is exact: the sextic microcell kernel is preserved
156    ///   (probit scaling by 1/τ, τ = √(1+σ²)).
157    /// - `HazardMultiplier` is exact only for PH/cloglog rowwise families.
158    ///   It is NOT finite-state exact with score_warp/linkwiggle cubic
159    ///   marginal-slope, because the multiplicative frailty breaks the
160    ///   polynomial kernel closure that the cubic cell derivatives require.
161    ///
162    /// Returns an error if the combination is not exactly integrable.
163    pub fn validate_for_marginal_slope(&self) -> Result<(), String> {
164        self.validate_for_marginal_slope_typed()
165            .map_err(|e| e.to_string())
166    }
167
168    /// Typed variant of [`Self::validate_for_marginal_slope`] used internally;
169    /// the `String`-returning entry point above is preserved as a one-line
170    /// shim for external callers.
171    pub fn validate_for_marginal_slope_typed(&self) -> Result<(), LognormalKernelError> {
172        self.validate()?;
173        match self {
174            Self::None | Self::GaussianShift { .. } => Ok(()),
175            Self::HazardMultiplier { .. } => Err(LognormalKernelError::InvalidSpec {
176                reason:
177                    "HazardMultiplier frailty is not finite-state exact with score_warp/linkwiggle \
178                     cubic marginal-slope families. Use GaussianShift frailty (exact probit scaling) \
179                     or use the standalone latent-cloglog/latent-survival families instead."
180                        .to_string(),
181            }),
182        }
183    }
184}
185
186// ─── Probit frailty scaling ──────────────────────────────────────────────────
187
188#[inline]
189fn probit_frailty_scale_components(sigma: f64) -> (f64, f64) {
190    let abs_sigma = sigma.abs();
191    if abs_sigma > 1.0 {
192        let inv = 1.0 / abs_sigma;
193        let denom = 1.0 + inv * inv;
194        (inv / denom.sqrt(), 1.0 / denom)
195    } else {
196        let sigma2 = sigma * sigma;
197        let denom = 1.0 + sigma2;
198        (1.0 / denom.sqrt(), sigma2 / denom)
199    }
200}
201
202/// Probit frailty scaling factor **with** t-derivatives (t = log σ).
203///
204/// Provides exact closed-form derivatives of s = 1/√(1+σ²) with respect to
205/// t = log(σ) for learnable Gaussian-shift frailty in the marginal-slope
206/// families.  For Gaussian frailty on the final probit index
207/// E[Φ(η + U)] = Φ(η · s) with s = 1/√(1+σ²); writing α = σ²/(1+σ²) the
208/// derivatives are ∂_t s = −α·s and ∂_{tt} s = α(3α−2)·s.
209#[derive(Clone, Copy, Debug)]
210pub struct ProbitFrailtyScaleJet {
211    /// s = 1/√(1+σ²)
212    pub s: f64,
213    /// α = σ²/(1+σ²)  — shared auxiliary for all derivative levels.
214    pub alpha: f64,
215    /// ∂_t s = -α·s
216    pub ds: f64,
217    /// ∂_{tt} s = α(3α−2)·s
218    pub d2s: f64,
219}
220
221impl ProbitFrailtyScaleJet {
222    /// Build the jet from σ (not from t = log σ).
223    ///
224    /// At σ = 0 the jet degenerates to (s=1, α=0, ds=0, d2s=0), which is
225    /// correct: zero frailty means s ≡ 1 independent of t.
226    pub fn new(sigma: f64) -> Self {
227        let (s, alpha) = probit_frailty_scale_components(sigma);
228        Self {
229            s,
230            alpha,
231            ds: -alpha * s,
232            d2s: alpha * (3.0 * alpha - 2.0) * s,
233        }
234    }
235
236    /// Build the jet from t = log(σ) directly.
237    pub fn from_log_sigma(log_sigma: f64) -> Self {
238        Self::new(log_sigma.exp())
239    }
240}
241
242#[inline]
243fn worst_mode(
244    a: IntegratedExpectationMode,
245    b: IntegratedExpectationMode,
246) -> IntegratedExpectationMode {
247    if a.rank() >= b.rank() { a } else { b }
248}
249
250// ─── Log-space kernel infrastructure ──────────────────────────────────────────
251//
252// The runtime kernel path stays in log-space until the final ratios are formed,
253// avoiding the overflow/underflow and cancellation problems that come from
254// exponentiating individual terms too early.
255
256/// Returns `log K_{k,m}(μ,σ)` directly, without exponentiation.
257///
258/// The value is always finite (or `NEG_INFINITY` when the kernel is zero), so
259/// it cannot overflow or underflow.
260#[inline]
261fn validate_kernel_inputs(m: f64, mu: f64, sigma: f64) -> Result<(), EstimationError> {
262    if !m.is_finite() || m < 0.0 {
263        crate::bail_invalid_estim!("lognormal kernel requires finite m >= 0, got {m}");
264    }
265    if !mu.is_finite() || !sigma.is_finite() || sigma < 0.0 {
266        crate::bail_invalid_estim!(
267            "lognormal kernel requires finite mu and sigma >= 0, got mu={mu}, sigma={sigma}"
268        );
269    }
270    Ok::<(), _>(())
271}
272
273#[inline]
274pub fn log_kernel_term(
275    quadctx: &QuadratureContext,
276    k: usize,
277    m: f64,
278    mu: f64,
279    sigma: f64,
280) -> Result<(f64, IntegratedExpectationMode), EstimationError> {
281    validate_kernel_inputs(m, mu, sigma)?;
282    let kf = k as f64;
283    let sigma2 = sigma * sigma;
284    if !sigma2.is_finite() {
285        crate::bail_invalid_estim!(
286            "lognormal kernel sigma is outside the finite exact-derivative range: sigma={sigma}"
287        );
288    }
289    let prefix_bound = kf * mu.abs() + 0.5 * kf * kf * sigma2;
290    if !prefix_bound.is_finite() {
291        crate::bail_invalid_estim!(
292            "lognormal kernel prefix is outside the finite exact-derivative range: k={k}, mu={mu}, sigma={sigma}"
293        );
294    }
295    let prefix = kf * mu + 0.5 * kf * kf * sigma2;
296    if m == 0.0 {
297        return Ok((prefix, IntegratedExpectationMode::ExactClosedForm));
298    }
299    let log_m = m.ln();
300    let shifted_bound = mu.abs() + kf * sigma2 + log_m.abs();
301    if !shifted_bound.is_finite() {
302        crate::bail_invalid_estim!(
303            "lognormal kernel shifted location is outside the finite exact-derivative range: k={k}, m={m}, mu={mu}, sigma={sigma}"
304        );
305    }
306    let shifted_mu = mu + kf * sigma2 + log_m;
307    // Survival carried in log space: prefix + ln S(shifted_mu, σ). This keeps the
308    // kernel's true magnitude when S underflows in value space at large σ — the
309    // old `laplace <= 0.0 → −∞` collapse discarded a large-but-finite log-value
310    // (#798) and the value-space asymptotic was biased low at σ ≥ 8 (#799).
311    let (log_laplace, mode) = lognormal_laplace_unit_log_term_shared(quadctx, shifted_mu, sigma);
312    Ok((prefix + log_laplace, mode))
313}
314
315/// Kernel bundle storing `log K_{k,m}` values instead of `K_{k,m}`.
316#[derive(Clone, Debug)]
317pub struct LogLognormalKernelBundle {
318    pub log_values: Vec<f64>,
319    /// The Laplace half of each `log_values` entry, kept apart from the
320    /// analytic prefix (#2610).
321    ///
322    /// `log K_k = prefix_k + laplace_k` with `prefix_k = k·μ + σ²k²/2` known in
323    /// closed form. Storing the two halves summed is enough to READ a kernel
324    /// value but not enough to difference one accurately, because the prefix
325    /// grows quadratically in `k` and swamps the Laplace part it is added to.
326    /// Retaining `laplace_k` costs one `f64` per rung and is what lets
327    /// [`Self::second_cumulant_ratio`] recover the exact part of a second
328    /// difference instead of subtracting it away.
329    pub log_laplace: Vec<f64>,
330    /// `σ^j · ∂_a^j K_0` in signed-log coordinates for `j = 0..=max_k`, where
331    /// `a = μ + ln m` is the SINGLE location the `k = 0` kernel depends on
332    /// (#2610), or `None` when the analytic branch does not apply.
333    ///
334    /// `K_0(m,μ,σ) = S(μ + ln m, σ)` because `m` and `μ` enter only through the
335    /// product `m·e^U`. That makes `∂_a` the one derivative the rung ladder is
336    /// built out of:
337    ///
338    /// ```text
339    /// m^k K_k = (−1)^k · ∂_a(∂_a − 1)···(∂_a − k + 1) K_0,
340    /// ```
341    ///
342    /// so a term list in the `K_k` basis and one in the `∂_a^j K_0` basis carry
343    /// the SAME information — but not the same conditioning. Reaching
344    /// `∂_a^4 K_0` through the rungs means evaluating `−mK_1 + 7m²K_2 − 6m³K_3 +
345    /// m⁴K_4`, whose summands are `O(1/σ)` while the sum is `O(1/σ^5)`; reading
346    /// it from here is one quadrature over an integrand that is already small.
347    /// The scaling by `σ^j` is what keeps every entry inside the representable
348    /// range: `∂_a^4 K_0` itself is `~1e-17` at `log σ = 7`.
349    ///
350    /// Entry `j = 0` is `log_values[0]` verbatim, so a term list that only
351    /// reaches `k = 0` evaluates bit-identically on either basis.
352    pub log_scaled_a_derivatives: Option<Vec<KernelSignedLog>>,
353    pub mode: IntegratedExpectationMode,
354}
355
356/// One signed magnitude in logarithmic coordinates.
357///
358/// `sign` is exactly `-1.0`, `0.0`, or `1.0`; `log_abs` is `-∞` when and only
359/// when `sign` is zero.
360#[derive(Clone, Copy, Debug)]
361pub struct KernelSignedLog {
362    pub log_abs: f64,
363    pub sign: f64,
364}
365
366impl LogLognormalKernelBundle {
367    #[inline]
368    pub fn get(&self, k: usize) -> f64 {
369        self.log_values[k]
370    }
371
372    #[inline]
373    pub fn len(&self) -> usize {
374        self.log_values.len()
375    }
376
377    /// `K_{k+2}/K_k − (K_{k+1}/K_k)²`, formed without the cancelling
378    /// subtraction (#2610).
379    ///
380    /// The naive route evaluates both ratios and subtracts. In the large-σ
381    /// regime they agree to `~1/(120σ²)` of their own size, so the difference
382    /// keeps only the bits they do NOT share and the result is noise past
383    /// `log σ ≈ 5.4` — no working precision repairs that, because the cancelled
384    /// quantity keeps shrinking while the roundoff floor does not (#2566).
385    ///
386    /// Factoring the common ratio out first turns the subtraction into one
387    /// `expm1`:
388    ///
389    /// ```text
390    /// R₂ − R₁² = R₂ · (1 − e^Δ) = −R₂ · expm1(Δ),   Δ = 2L_{k+1} − L_{k+2} − L_k
391    /// ```
392    ///
393    /// `expm1` is exact to full relative precision as `Δ → 0`, which is
394    /// precisely where the difference form fails. That alone would only move
395    /// the problem into `Δ`, a second difference of large log-values — except
396    /// that the prefix's second difference is available in closed form:
397    ///
398    /// ```text
399    /// prefix_{k+2} − 2·prefix_{k+1} + prefix_k = σ²   exactly, for every k and μ
400    /// ```
401    ///
402    /// So `Δ = −(σ² + D²laplace)`: the dominant term is exact, and only the
403    /// slowly-varying Laplace half is differenced numerically. For `m = 0` the
404    /// Laplace half is identically zero and `Δ = −σ²` is exact outright.
405    ///
406    /// Returns `None` when the rung is missing or any input is non-finite —
407    /// a caller that cannot form this must fall back rather than receive a
408    /// silently degraded number.
409    pub fn second_cumulant_ratio(&self, k: usize, sigma: f64) -> Option<f64> {
410        if k + 2 >= self.log_values.len() || k + 2 >= self.log_laplace.len() {
411            return None;
412        }
413        let log_k0 = self.log_values[k];
414        let log_k2 = self.log_values[k + 2];
415        if !log_k0.is_finite() || !log_k2.is_finite() {
416            return None;
417        }
418        let (lap0, lap1, lap2) = (
419            self.log_laplace[k],
420            self.log_laplace[k + 1],
421            self.log_laplace[k + 2],
422        );
423        if !(lap0.is_finite() && lap1.is_finite() && lap2.is_finite()) {
424            return None;
425        }
426        let sigma2 = sigma * sigma;
427        if !sigma2.is_finite() {
428            return None;
429        }
430        // Differencing as (lap2 - lap1) - (lap1 - lap0) rather than
431        // lap2 - 2*lap1 + lap0: the two first differences are each small, so
432        // neither intermediate is a large quantity waiting to cancel.
433        let second_difference_laplace = (lap2 - lap1) - (lap1 - lap0);
434        let delta = -(sigma2 + second_difference_laplace);
435        let ratio2 = (log_k2 - log_k0).exp();
436        let value = -ratio2 * delta.exp_m1();
437        if value.is_finite() { Some(value) } else { None }
438    }
439}
440
441/// Builds a log-space kernel bundle for `k = 0, 1, …, max_k` at fixed
442/// `(m, μ, σ)`.
443pub fn log_kernel_bundle(
444    quadctx: &QuadratureContext,
445    m: f64,
446    mu: f64,
447    sigma: f64,
448    max_k: usize,
449) -> Result<LogLognormalKernelBundle, EstimationError> {
450    validate_kernel_inputs(m, mu, sigma)?;
451    let mut log_values = Vec::with_capacity(max_k + 1);
452    let sigma2 = sigma * sigma;
453    if !sigma2.is_finite() {
454        crate::bail_invalid_estim!(
455            "lognormal kernel sigma is outside the finite exact-derivative range: sigma={sigma}"
456        );
457    }
458    let max_kf = max_k as f64;
459    let prefix_bound = max_kf * mu.abs() + 0.5 * max_kf * max_kf * sigma2;
460    if !prefix_bound.is_finite() {
461        crate::bail_invalid_estim!(
462            "lognormal kernel bundle prefix is outside the finite exact-derivative range: max_k={max_k}, mu={mu}, sigma={sigma}"
463        );
464    }
465    if m == 0.0 {
466        let mut prefix = 0.0;
467        for k in 0..=max_k {
468            log_values.push(prefix);
469            prefix += mu + (k as f64 + 0.5) * sigma2;
470        }
471        // No Laplace factor on this branch: the kernel IS its prefix, so the
472        // second difference of the Laplace half is exactly zero (#2610).
473        let log_laplace = vec![0.0; max_k + 1];
474        return Ok(LogLognormalKernelBundle {
475            log_values,
476            log_laplace,
477            // `a = μ + ln m` does not exist at `m = 0`, and it is not needed:
478            // this branch IS closed form, so no rung difference can cancel.
479            log_scaled_a_derivatives: None,
480            mode: IntegratedExpectationMode::ExactClosedForm,
481        });
482    }
483
484    let log_m = m.ln();
485    let shifted_bound = mu.abs() + max_kf * sigma2 + log_m.abs();
486    if !shifted_bound.is_finite() {
487        crate::bail_invalid_estim!(
488            "lognormal kernel bundle shifted location is outside the finite exact-derivative range: max_k={max_k}, m={m}, mu={mu}, sigma={sigma}"
489        );
490    }
491    let mut shifted_mu = mu + log_m;
492    let mut prefix = 0.0;
493    let mut mode = IntegratedExpectationMode::ExactClosedForm;
494    let mut log_laplace_values = Vec::with_capacity(max_k + 1);
495    for k in 0..=max_k {
496        let (log_laplace, val_mode) =
497            lognormal_laplace_unit_log_term_shared(quadctx, shifted_mu, sigma);
498        log_values.push(if log_laplace.is_finite() {
499            prefix + log_laplace
500        } else {
501            f64::NEG_INFINITY
502        });
503        // Kept unclamped on purpose: the summed entry above collapses a
504        // non-finite Laplace term to −∞, which is the right reading for a
505        // kernel VALUE but would erase the information a second difference
506        // needs. `second_cumulant_ratio` refuses on non-finite instead (#2610).
507        log_laplace_values.push(log_laplace);
508        mode = worst_mode(mode, val_mode);
509        prefix += mu + (k as f64 + 0.5) * sigma2;
510        shifted_mu += sigma2;
511    }
512    // `log K_0` read out before the vector moves into the bundle; the loop above
513    // always pushes at least the `k = 0` rung.
514    let log_values_first = log_values[0];
515    Ok(LogLognormalKernelBundle {
516        log_values,
517        log_laplace: log_laplace_values,
518        log_scaled_a_derivatives: log_scaled_a_derivative_tower(
519            quadctx,
520            mu + log_m,
521            sigma,
522            max_k,
523            log_values_first,
524        ),
525        mode,
526    })
527}
528
529/// The `σ^j ∂_a^j K_0` tower for one bundle, truncated at the highest rung the
530/// quadrature certifies, or `None` when it certifies none.
531///
532/// The tower and the value `ln S(a,σ)` come off the same log-space survival
533/// panel by construction (#2714), so there is no longer any question of a
534/// derivative and a value living on two approximation surfaces — that used to
535/// be gated by "is the value routed through the Gumbel-mixing quadrature",
536/// i.e. by `σ ≥ 8`. What is left to decide is only whether the direct tower is
537/// better conditioned than the rung basis here, and the quadrature answers that
538/// itself: [`LogSurvivalJet::certified_prefix_order`] admits each entry exactly
539/// when its measured signed-sum cancellation says it is accurate.
540///
541/// **The length is the certified PREFIX, not `max_k`.** Refusing the whole
542/// tower because its last rung cancelled made the basis a function of how many
543/// rungs the CALLER asked for, and the callers differ: a value request, a
544/// gradient, a Hessian and a contracted third derive `max_k` as
545/// `base + 2·max_primary_increment + max_suffix_increment`, so they can reach
546/// `4`, `5`, `6` and `7` on one row. Two of them evaluating the same term list
547/// at the same point would then disagree about which basis to use, and their
548/// answers would differ — which is the same fault as the one #2714 is filed on,
549/// one level down.
550///
551/// This is not a partial mix: a term list that needs a rung past the truncation
552/// falls back to the rung basis WHOLE (see
553/// `latent_kernel_evaluate_terms_in_a_basis`, which refuses the list, not the
554/// term). `None` also covers a non-finite `log_k0`.
555fn log_scaled_a_derivative_tower(
556    quadctx: &QuadratureContext,
557    a: f64,
558    sigma: f64,
559    max_k: usize,
560    log_k0: f64,
561) -> Option<Vec<KernelSignedLog>> {
562    if !log_k0.is_finite() {
563        return None;
564    }
565    let jet = log_survival_jet(quadctx, a, sigma, max_k);
566    let certified_order = jet.certified_prefix_order(max_k)?;
567    let certified = jet.certified_scaled_mu_derivatives(certified_order)?;
568    let mut tower = Vec::with_capacity(certified_order + 1);
569    // Entry 0 is the kernel itself, taken verbatim from the value path so a
570    // `k = 0` term list evaluates identically on either basis.
571    tower.push(KernelSignedLog {
572        log_abs: log_k0,
573        sign: 1.0,
574    });
575    for entry in &certified[1..] {
576        tower.push(KernelSignedLog {
577            log_abs: entry.log_abs,
578            sign: entry.sign,
579        });
580    }
581    Some(tower)
582}
583
584/// Computes the value-space derivative ratios `∂ⁿ_μ K_{k,m} / K_{k,m}`
585/// from a log-space bundle.
586///
587/// Returns `[1, K'/K, K''/K, K'''/K, K''''/K]` where only the first
588/// `order + 1` entries are valid.
589///
590/// The recurrences are applied in ratio form, with each `K_{k+r}/K_k`
591/// computed as `exp(log K_{k+r} − log K_k)`, which remains finite even when
592/// the individual kernel values would overflow or underflow.
593pub fn kernel_ratio_jet(
594    log_bundle: &LogLognormalKernelBundle,
595    k: usize,
596    m: f64,
597    order: usize,
598) -> [f64; 5] {
599    let kf = k as f64;
600    let log_k0 = log_bundle.get(k);
601
602    // Precompute ratios K_{k+r}/K_k for r = 1..=order, each from a single
603    // log-difference.  This avoids redundant exp() calls when the same ratio
604    // appears in multiple derivative orders.
605    let mut rk = [0.0f64; 5]; // rk[0] unused; rk[r] = K_{k+r}/K_k
606    for r in 1..=order.min(4) {
607        let delta = log_bundle.get(k + r) - log_k0;
608        rk[r] = if delta.is_finite() {
609            delta.exp()
610        } else if delta > 0.0 {
611            f64::INFINITY
612        } else {
613            0.0
614        };
615    }
616
617    let mut jet = [0.0; 5];
618    jet[0] = 1.0;
619
620    if order >= 1 {
621        jet[1] = kf - m * rk[1];
622    }
623    if order >= 2 {
624        jet[2] = kf * kf - (2.0 * kf + 1.0) * m * rk[1] + m * m * rk[2];
625    }
626    if order >= 3 {
627        jet[3] = kf * kf * kf - (3.0 * kf * kf + 3.0 * kf + 1.0) * m * rk[1]
628            + 3.0 * (kf + 1.0) * m * m * rk[2]
629            - m * m * m * rk[3];
630    }
631    if order >= 4 {
632        let k2 = kf * kf;
633        let k3 = k2 * kf;
634        let k4 = k3 * kf;
635        let m2 = m * m;
636        let m3 = m2 * m;
637        let m4 = m3 * m;
638        jet[4] = k4 - (4.0 * k3 + 6.0 * k2 + 4.0 * kf + 1.0) * m * rk[1]
639            + (6.0 * k2 + 12.0 * kf + 7.0) * m2 * rk[2]
640            - (4.0 * kf + 6.0) * m3 * rk[3]
641            + m4 * rk[4];
642    }
643
644    jet
645}
646
647// `LatentCLogLogJet5` + `latent_cloglog_jet5` / `latent_cloglog_inverse_link_jet`
648// moved DOWN to `crate::quadrature` (#1135), co-located with their analytic
649// backend, so the `solver` link layer names them without importing up into
650// `families::survival`. Re-exported here so the in-family callers (e.g.
651// `family_runtime`) keep resolving.
652pub use crate::quadrature::{
653    LatentCLogLogJet5, latent_cloglog_inverse_link_jet, latent_cloglog_jet5,
654};
655
656// ─── LogKernelSumJet: log-sum derivatives from log-space bundles ─────────────
657
658/// A single signed term in a kernel sum: coefficient × K_{k,m}.
659#[derive(Clone, Copy, Debug)]
660pub struct KernelSumTerm {
661    /// Multiplicative coefficient (can be negative for difference terms).
662    pub coeff: f64,
663    /// Kernel order parameter k.
664    pub k: usize,
665    /// Kernel mass parameter m (≥ 0).
666    pub m: f64,
667}
668
669/// Log-mass separation below which a same-rung pair is differenced
670/// ANALYTICALLY rather than numerically (see
671/// [`LogKernelSumJet::analytic_log_mag_gap`]).
672///
673/// The two routes have opposite error behaviour in `dv`: the direct difference
674/// carries relative error `ε/|dv|`, the expansion `~dv³/24` from its first
675/// dropped order. They cross at `dv⁴ ≈ 24ε`, i.e. `dv ≈ 8e-4`. `1e-4` sits
676/// safely on the expansion's side of that crossing (`4e-14` expansion error
677/// against `2e-12` for the difference) and leaves every pair a runtime row
678/// actually forms — interval widths of order the observation scale — on the
679/// unchanged numerical path.
680const ANALYTIC_LOG_MASS_GAP_THRESHOLD: f64 = 1e-4;
681
682/// Derivatives of `log(Σ_j a_j · K_{k_j, m_j}(μ, σ))` with respect to μ.
683///
684/// This is the workhorse for row-level log-likelihood derivatives in all
685/// latent families.  The numerator and denominator of a row likelihood are
686/// each a small signed sum of kernel terms.
687///
688/// The value path is assembled from log-space kernel bundles and ratio jets,
689/// so individual kernel terms are never exponentiated before the final signed
690/// sum. That avoids the old overflow/underflow problems from value-space
691/// kernels. When the signed sum is zero or negative, this returns an invalid
692/// row (`value = -∞`) instead of trying to continue with a floored surrogate.
693/// Signed two-term differences (e.g. interval censoring `K_{0,M_L} − K_{0,M_R}`)
694/// are still combined through the shared sign-aware log-sum path.
695#[derive(Clone, Copy, Debug)]
696pub struct LogKernelSumJet {
697    /// log(Σ a_j K_j)
698    pub value: f64,
699    /// d/dμ log(Σ a_j K_j)
700    pub d1: f64,
701    /// d²/dμ² log(Σ a_j K_j)
702    pub d2: f64,
703    /// d³/dμ³ log(Σ a_j K_j)
704    pub d3: f64,
705    /// d⁴/dμ⁴ log(Σ a_j K_j)
706    pub d4: f64,
707    pub mode: IntegratedExpectationMode,
708}
709
710impl LogKernelSumJet {
711    #[inline]
712    fn non_positive(mode: IntegratedExpectationMode) -> Self {
713        Self {
714            value: f64::NEG_INFINITY,
715            d1: 0.0,
716            d2: 0.0,
717            d3: 0.0,
718            d4: 0.0,
719            mode,
720        }
721    }
722
723    #[inline]
724    fn from_log_value_and_ratios(
725        value: f64,
726        ratio: [f64; 5],
727        mode: IntegratedExpectationMode,
728    ) -> Self {
729        let r1 = ratio[1];
730        let r2 = ratio[2];
731        let r3 = ratio[3];
732        let r4 = ratio[4];
733        Self {
734            value,
735            d1: r1,
736            d2: r2 - r1 * r1,
737            d3: r3 - 3.0 * r1 * r2 + 2.0 * r1 * r1 * r1,
738            d4: r4 - 4.0 * r1 * r3 - 3.0 * r2 * r2 + 12.0 * r1 * r1 * r2 - 6.0 * r1.powi(4),
739            mode,
740        }
741    }
742
743    #[inline]
744    fn term_log_mag_and_ratio(
745        bundle: &LogLognormalKernelBundle,
746        term: KernelSumTerm,
747    ) -> (f64, [f64; 5]) {
748        (
749            term.coeff.abs().ln() + bundle.get(term.k),
750            // d4 is used by the exact log-sigma curvature, so this must carry
751            // ratios through order 4 rather than truncating at order 3.
752            kernel_ratio_jet(bundle, term.k, term.m, 4),
753        )
754    }
755
756    /// `log|a₁K₁| − log|a₀K₀|` for a same-rung pair, without differencing two
757    /// `O(1)` logs (#2277).
758    ///
759    /// `log K_{k,m} = kμ + σ²k²/2 + Λ(v)` with `v = μ + kσ² + ln m`, so a pair
760    /// sharing `k` differs only through `dv = ln(m₁/m₀)` and the analytic
761    /// prefix. Subtracting the two assembled logs costs an ABSOLUTE `ε ≈
762    /// 2.2e-16`, hence a RELATIVE error `ε/|dv|` in the separation — `2e-4` at
763    /// `dv = 1e-12`. That is the whole of the #2277 narrow-interval failure:
764    /// the interval value is `log|a₀K₀| + log1mexp(δ) ≈ log|a₀K₀| + ln|δ|`, so
765    /// a relative error in `δ` lands as an absolute error in the value.
766    ///
767    /// Expanding `Λ` instead makes the leading order analytic:
768    ///
769    /// ```text
770    /// Λ(v₀ + dv) − Λ(v₀) = Λ'·dv + Λ''·dv²/2 + Λ'''·dv³/6 + O(dv⁴)
771    /// ```
772    ///
773    /// `Λ', Λ'', Λ'''` are exactly the μ-log-derivatives the ratio jet already
774    /// carries, because `∂_μ` and `∂_{ln m}` act identically on `Λ`; only the
775    /// `kμ` prefix distinguishes them, which is the `− kf` on the first order.
776    /// `dv` is formed as `ln1p((m₁ − m₀)/m₀)`: for nearby masses `m₁ − m₀` is
777    /// exact by Sterbenz and `ln1p` is accurate at small argument, so `dv`
778    /// keeps full RELATIVE precision where `ln m₁ − ln m₀` would not.
779    ///
780    /// Returns `None` unless the pair shares its rung, both masses are
781    /// strictly positive, and `|dv|` is inside
782    /// [`ANALYTIC_LOG_MASS_GAP_THRESHOLD`]; outside that range the direct
783    /// difference is both valid and more accurate and the caller must use it.
784    ///
785    /// LIMIT: the coefficient half, `ln|a₁| − ln|a₀|`, is still a numerical
786    /// difference. Interval censoring passes `a = ±exp(−unloaded mass)`, so a
787    /// narrow interval whose UNLOADED masses also nearly coincide reintroduces
788    /// the same amplification through that half; removing it needs the caller
789    /// to pass the unloaded masses rather than their exponentials.
790    fn analytic_log_mag_gap(
791        t0: KernelSumTerm,
792        t1: KernelSumTerm,
793        ratio0: &[f64; 5],
794    ) -> Option<f64> {
795        if t0.k != t1.k || t0.m <= 0.0 || t1.m <= 0.0 {
796            return None;
797        }
798        let dv = ((t1.m - t0.m) / t0.m).ln_1p();
799        if !dv.is_finite() || dv.abs() > ANALYTIC_LOG_MASS_GAP_THRESHOLD {
800            return None;
801        }
802        let kf = t0.k as f64;
803        let (r1, r2, r3) = (ratio0[1], ratio0[2], ratio0[3]);
804        let lambda1 = r1 - kf;
805        let lambda2 = r2 - r1 * r1;
806        let lambda3 = r3 - 3.0 * r1 * r2 + 2.0 * r1 * r1 * r1;
807        let kernel_gap = dv * (lambda1 + dv * (0.5 * lambda2 + dv * (lambda3 / 6.0)));
808        let coeff_gap = t1.coeff.abs().ln() - t0.coeff.abs().ln();
809        let delta = coeff_gap + kernel_gap;
810        if delta.is_finite() { Some(delta) } else { None }
811    }
812
813    /// Reduces `sign₀·e^{L₀} + sign₁·e^{L₀+δ}` to `(log|u|, sign(u))` for
814    /// `u = sign₀ + sign₁·e^δ`, so the pair's magnitude is `L₀ + log|u|`.
815    ///
816    /// The opposite-sign branch is where cancellation lives and it is exactly
817    /// `log|1 − e^δ|`, i.e. Måchler's `log1mexp` on `|δ|`, which is accurate
818    /// across the whole range of `δ` PROVIDED `δ` is itself accurate. That
819    /// proviso is why [`Self::analytic_log_mag_gap`] exists.
820    ///
821    /// Returns `None` when either sign is zero (nothing cancels; the general
822    /// path already drops a zero term correctly) or when the sum vanishes.
823    fn reduce_signed_pair(sign0: f64, sign1: f64, delta: f64) -> Option<(f64, f64)> {
824        if sign0 == 0.0 || sign1 == 0.0 || !delta.is_finite() {
825            return None;
826        }
827        if sign0 * sign1 > 0.0 {
828            // No cancellation; factor out whichever exponential is larger.
829            let log_u = if delta > 0.0 {
830                delta + (-delta).exp().ln_1p()
831            } else {
832                delta.exp().ln_1p()
833            };
834            Some((log_u, sign0.signum()))
835        } else if delta == 0.0 {
836            // Exact cancellation: the signed sum is zero, not a small positive.
837            None
838        } else {
839            // |1 − e^δ| = e^{max(δ,0)}·(1 − e^{−|δ|}).
840            let log_u = delta.max(0.0) + log1mexp_positive(delta.abs());
841            Some((log_u, sign0.signum() * -delta.signum()))
842        }
843    }
844
845    fn evaluate_two_terms(
846        quadctx: &QuadratureContext,
847        t0: KernelSumTerm,
848        t1: KernelSumTerm,
849        mu: f64,
850        sigma: f64,
851    ) -> Result<Self, EstimationError> {
852        let max_k_needed = t0.k.max(t1.k) + 4;
853        let bundle0 = log_kernel_bundle(quadctx, t0.m, mu, sigma, max_k_needed)?;
854        let mut overall_mode = bundle0.mode;
855        let bundle1_owned = if t0.m == t1.m {
856            None
857        } else {
858            let bundle1 = log_kernel_bundle(quadctx, t1.m, mu, sigma, max_k_needed)?;
859            overall_mode = worst_mode(overall_mode, bundle1.mode);
860            Some(bundle1)
861        };
862        let bundle1 = bundle1_owned.as_ref().unwrap_or(&bundle0);
863
864        let (log_mag0, ratio0) = Self::term_log_mag_and_ratio(&bundle0, t0);
865        let (log_mag1, ratio1) = Self::term_log_mag_and_ratio(bundle1, t1);
866        let log_mags = [log_mag0, log_mag1];
867        let signs = [t0.coeff.signum(), t1.coeff.signum()];
868
869        // Narrow same-rung pairs go through the analytic separation; everything
870        // else keeps the general signed reduction, including the infinite
871        // log-magnitudes only `signed_log_sum_exp` resolves.
872        let analytic = if log_mag0.is_finite() && log_mag1.is_finite() {
873            Self::analytic_log_mag_gap(t0, t1, &ratio0).and_then(|delta| {
874                Self::reduce_signed_pair(signs[0], signs[1], delta)
875                    .map(|(log_u, sign_u)| (log_u, sign_u, delta))
876            })
877        } else {
878            None
879        };
880        let (log_s, sign_s, log_w0, log_w1) = match analytic {
881            Some((log_u, sign_u, delta)) => (log_mag0 + log_u, sign_u, -log_u, delta - log_u),
882            None => {
883                let (log_s, sign_s) = signed_log_sum_exp(&log_mags, &signs);
884                (log_s, sign_s, log_mag0 - log_s, log_mag1 - log_s)
885            }
886        };
887        if !log_s.is_finite() || sign_s <= 0.0 {
888            return Ok(Self::non_positive(overall_mode));
889        }
890
891        let w0 = sign_s * signs[0] * log_w0.exp();
892        let w1 = sign_s * signs[1] * log_w1.exp();
893        let wr1 = w0 * ratio0[1] + w1 * ratio1[1];
894        let wr2 = w0 * ratio0[2] + w1 * ratio1[2];
895        let wr3 = w0 * ratio0[3] + w1 * ratio1[3];
896        let wr4 = w0 * ratio0[4] + w1 * ratio1[4];
897
898        Ok(Self {
899            value: log_s,
900            d1: wr1,
901            d2: wr2 - wr1 * wr1,
902            d3: wr3 - 3.0 * wr1 * wr2 + 2.0 * wr1 * wr1 * wr1,
903            d4: wr4 - 4.0 * wr1 * wr3 - 3.0 * wr2 * wr2 + 12.0 * wr1 * wr1 * wr2
904                - 6.0 * wr1.powi(4),
905            mode: overall_mode,
906        })
907    }
908
909    /// Evaluate for a single positive kernel term (fast path).
910    ///
911    /// Computes `log(K_{k,m})` and its μ-derivatives from exact recurrences,
912    /// entirely in log-space.
913    pub fn single_term(
914        quadctx: &QuadratureContext,
915        k: usize,
916        m: f64,
917        mu: f64,
918        sigma: f64,
919    ) -> Result<Self, EstimationError> {
920        let max_k_needed = k + 4;
921        let lb = log_kernel_bundle(quadctx, m, mu, sigma, max_k_needed)?;
922        Ok(Self::from_log_value_and_ratios(
923            lb.get(k),
924            kernel_ratio_jet(&lb, k, m, 4),
925            lb.mode,
926        ))
927    }
928
929    /// Evaluate `log(Σ a_j K_j)` and its μ-derivatives for a small signed sum.
930    ///
931    /// All terms share the same `(μ, σ)`.  Both the value and derivative
932    /// ratios are computed entirely in log-space.  The runtime latent-survival
933    /// rows in this repo are almost always one-term or two-term sums, so those
934    /// cases stay on dedicated stack paths; the heap-backed logic below is only
935    /// for genuinely longer symbolic sums:
936    ///
937    /// 1. Per-term log-magnitudes `log|a_j| + log K_{k_j,m_j}` and signs.
938    /// 2. Sign-aware log-sum-exp to get `log|S|` and `sign(S)`.
939    /// 3. Importance weights `w_j = a_j K_j / S` formed in log-space.
940    /// 4. Weighted ratio sums `R_n = Σ w_j · (∂ⁿK_j / K_j)` for the
941    ///    final log-derivatives.
942    pub fn evaluate(
943        quadctx: &QuadratureContext,
944        terms: &[KernelSumTerm],
945        mu: f64,
946        sigma: f64,
947    ) -> Result<Self, EstimationError> {
948        if terms.is_empty() {
949            // Empty sums are a caller-contract violation, not a degenerate row.
950            // Return an input error so callers can report the malformed kernel sum.
951            crate::bail_invalid_estim!("KernelSumJet requires at least one term");
952        }
953
954        // Fast path for single term.
955        if terms.len() == 1 {
956            let t = &terms[0];
957            if t.coeff <= 0.0 {
958                // Negative or zero coefficient: the sum is non-positive, so
959                // log(sum) is undefined.  Return −∞ (impossible observation),
960                // matching the general path's sign_s ≤ 0 branch.
961                return Ok(Self::non_positive(
962                    IntegratedExpectationMode::ExactClosedForm,
963                ));
964            }
965            let jet = Self::single_term(quadctx, t.k, t.m, mu, sigma)?;
966            return Ok(Self {
967                value: t.coeff.ln() + jet.value,
968                d1: jet.d1,
969                d2: jet.d2,
970                d3: jet.d3,
971                d4: jet.d4,
972                mode: jet.mode,
973            });
974        }
975        if terms.len() == 2 {
976            return Self::evaluate_two_terms(quadctx, terms[0], terms[1], mu, sigma);
977        }
978
979        let max_k_needed = terms.iter().map(|t| t.k).max().unwrap_or(0) + 4;
980
981        // Build log-bundles for each unique mass.
982        let mut log_bundles: Vec<(f64, LogLognormalKernelBundle)> = Vec::with_capacity(2);
983        let mut overall_mode = IntegratedExpectationMode::ExactClosedForm;
984        for term in terms {
985            if !log_bundles.iter().any(|(m, _)| *m == term.m) {
986                let b = log_kernel_bundle(quadctx, term.m, mu, sigma, max_k_needed)?;
987                overall_mode = worst_mode(overall_mode, b.mode);
988                log_bundles.push((term.m, b));
989            }
990        }
991
992        let get_lb = |m: f64| -> &LogLognormalKernelBundle {
993            &log_bundles
994                .iter()
995                .find(|(bm, _)| *bm == m)
996                .expect("the loop above pushes a bundle for every distinct term.m before any lookup")
997                .1
998        };
999
1000        // Per-term: log magnitude, sign, and ratio jet.
1001        let mut log_mags: Vec<f64> = Vec::with_capacity(terms.len());
1002        let mut signs: Vec<f64> = Vec::with_capacity(terms.len());
1003        let mut ratios: Vec<[f64; 5]> = Vec::with_capacity(terms.len());
1004        for term in terms {
1005            let lb = get_lb(term.m);
1006            log_mags.push(term.coeff.abs().ln() + lb.get(term.k));
1007            signs.push(term.coeff.signum());
1008            ratios.push(kernel_ratio_jet(lb, term.k, term.m, 4));
1009        }
1010
1011        // Sign-aware log-sum-exp: compute log|S| and sign(S).
1012        let (log_s, sign_s) = signed_log_sum_exp(&log_mags, &signs);
1013
1014        if !log_s.is_finite() || sign_s <= 0.0 {
1015            // Sum is zero or negative — degenerate row.
1016            return Ok(Self::non_positive(overall_mode));
1017        }
1018
1019        // Importance weights w_j = sign(S) · sign(a_j) · exp(log|a_j K_j| − log|S|).
1020        // When S > 0 and all terms have well-defined kernels, Σ w_j = 1.
1021        let mut wr1 = 0.0;
1022        let mut wr2 = 0.0;
1023        let mut wr3 = 0.0;
1024        let mut wr4 = 0.0;
1025        for i in 0..terms.len() {
1026            let w = sign_s * signs[i] * (log_mags[i] - log_s).exp();
1027            wr1 += w * ratios[i][1];
1028            wr2 += w * ratios[i][2];
1029            wr3 += w * ratios[i][3];
1030            wr4 += w * ratios[i][4];
1031        }
1032
1033        Ok(Self {
1034            value: log_s,
1035            d1: wr1,
1036            d2: wr2 - wr1 * wr1,
1037            d3: wr3 - 3.0 * wr1 * wr2 + 2.0 * wr1 * wr1 * wr1,
1038            d4: wr4 - 4.0 * wr1 * wr3 - 3.0 * wr2 * wr2 + 12.0 * wr1 * wr1 * wr2
1039                - 6.0 * wr1.powi(4),
1040            mode: overall_mode,
1041        })
1042    }
1043}
1044
1045// ─── Latent survival sufficient statistics ───────────────────────────────────
1046
1047/// Event type for compiled survival sufficient statistics.
1048#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1049pub enum LatentSurvivalEventType {
1050    /// Right-censored: observed alive in the observation window.
1051    RightCensored,
1052    /// Exact event: event observed at a known time.
1053    ExactEvent,
1054    /// Interval-censored: event known to occur in (t_left, t_right].
1055    IntervalCensored,
1056}
1057
1058impl fmt::Display for LatentSurvivalEventType {
1059    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1060        match self {
1061            Self::RightCensored => write!(f, "right_censored"),
1062            Self::ExactEvent => write!(f, "exact_event"),
1063            Self::IntervalCensored => write!(f, "interval_censored"),
1064        }
1065    }
1066}
1067
1068/// Row-level sufficient statistics for one latent survival observation.
1069///
1070/// This is the canonical row representation used by both fitted-family
1071/// evaluation and saved-model prediction.
1072///
1073/// For the full-loading model (frailty multiplies entire hazard):
1074///   mass_loaded = total cumulative hazard mass
1075///   mass_unloaded = 0
1076///
1077/// For the loaded-vs-unloaded model (Gompertz-Makeham):
1078///   mass_loaded = integrated disease hazard component
1079///   mass_unloaded = integrated background hazard component (not frailty-modified)
1080///
1081/// The unloaded mass contributes a simple exp(-M_U) prefactor.
1082#[derive(Clone, Copy, Debug)]
1083pub struct LatentSurvivalRow {
1084    pub event_type: LatentSurvivalEventType,
1085    /// Cumulative nuisance mass at entry: B(a_in).
1086    /// Zero if there is no left truncation.
1087    pub mass_entry: f64,
1088    /// Cumulative nuisance mass at exit/event: B(a_out) or B(a_event).
1089    pub mass_exit: f64,
1090    /// For interval censoring: mass at left boundary B(a_L).
1091    pub mass_left: f64,
1092    /// For interval censoring: mass at right boundary B(a_R).
1093    pub mass_right: f64,
1094    /// For interval censoring: unloaded mass at left boundary.
1095    pub mass_unloaded_left: f64,
1096    /// For interval censoring: unloaded mass at right boundary.
1097    pub mass_unloaded_right: f64,
1098    /// Unloaded (background) cumulative mass at entry (0 for full loading).
1099    pub mass_unloaded_entry: f64,
1100    /// Unloaded (background) cumulative mass at exit.
1101    pub mass_unloaded_exit: f64,
1102    /// Loaded instantaneous hazard at event time (for exact events).
1103    pub hazard_loaded: f64,
1104    /// Unloaded instantaneous hazard at event time (for exact events).
1105    pub hazard_unloaded: f64,
1106}
1107
1108impl LatentSurvivalRow {
1109    /// Delayed-entry right-censored row with explicit loaded/unloaded masses.
1110    ///
1111    /// `mass_entry` and `mass_exit` are cumulative loaded masses `B_L(a_in)`
1112    /// and `B_L(a_out)` for this row object. They are not an increment over
1113    /// `(a_in, a_out]`.
1114    pub fn right_censored(
1115        mass_entry: f64,
1116        mass_exit: f64,
1117        mass_unloaded_entry: f64,
1118        mass_unloaded_exit: f64,
1119    ) -> Self {
1120        Self {
1121            event_type: LatentSurvivalEventType::RightCensored,
1122            mass_entry,
1123            mass_exit,
1124            mass_left: 0.0,
1125            mass_right: 0.0,
1126            mass_unloaded_left: 0.0,
1127            mass_unloaded_right: 0.0,
1128            mass_unloaded_entry,
1129            mass_unloaded_exit,
1130            hazard_loaded: 0.0,
1131            hazard_unloaded: 0.0,
1132        }
1133    }
1134
1135    /// Delayed-entry exact-event row with explicit loaded/unloaded hazard parts.
1136    pub fn exact_event(
1137        mass_entry: f64,
1138        mass_exit: f64,
1139        mass_unloaded_entry: f64,
1140        mass_unloaded_exit: f64,
1141        hazard_loaded: f64,
1142        hazard_unloaded: f64,
1143    ) -> Self {
1144        Self {
1145            event_type: LatentSurvivalEventType::ExactEvent,
1146            mass_entry,
1147            mass_exit,
1148            mass_left: 0.0,
1149            mass_right: 0.0,
1150            mass_unloaded_left: 0.0,
1151            mass_unloaded_right: 0.0,
1152            mass_unloaded_entry,
1153            mass_unloaded_exit,
1154            hazard_loaded,
1155            hazard_unloaded,
1156        }
1157    }
1158
1159    /// Delayed-entry interval-censored row with explicit loaded/unloaded masses.
1160    pub fn interval_censored(
1161        mass_entry: f64,
1162        mass_left: f64,
1163        mass_right: f64,
1164        mass_unloaded_entry: f64,
1165        mass_unloaded_left: f64,
1166        mass_unloaded_right: f64,
1167    ) -> Self {
1168        Self {
1169            event_type: LatentSurvivalEventType::IntervalCensored,
1170            mass_entry,
1171            mass_exit: 0.0,
1172            mass_left,
1173            mass_right,
1174            mass_unloaded_left,
1175            mass_unloaded_right,
1176            mass_unloaded_entry,
1177            mass_unloaded_exit: 0.0,
1178            hazard_loaded: 0.0,
1179            hazard_unloaded: 0.0,
1180        }
1181    }
1182
1183    pub fn validate(&self) -> Result<(), EstimationError> {
1184        let fields = [
1185            ("mass_entry", self.mass_entry),
1186            ("mass_exit", self.mass_exit),
1187            ("mass_left", self.mass_left),
1188            ("mass_right", self.mass_right),
1189            ("mass_unloaded_left", self.mass_unloaded_left),
1190            ("mass_unloaded_right", self.mass_unloaded_right),
1191            ("mass_unloaded_entry", self.mass_unloaded_entry),
1192            ("mass_unloaded_exit", self.mass_unloaded_exit),
1193            ("hazard_loaded", self.hazard_loaded),
1194            ("hazard_unloaded", self.hazard_unloaded),
1195        ];
1196        for (name, value) in fields {
1197            if !value.is_finite() || value < 0.0 {
1198                crate::bail_invalid_estim!(
1199                    "latent survival row has invalid {name}={value}; expected a finite non-negative value"
1200                );
1201            }
1202        }
1203
1204        match self.event_type {
1205            LatentSurvivalEventType::RightCensored => {
1206                if self.mass_exit < self.mass_entry {
1207                    crate::bail_invalid_estim!(
1208                        "latent survival right-censored row requires mass_exit >= mass_entry, got {} < {}",
1209                        self.mass_exit,
1210                        self.mass_entry
1211                    );
1212                }
1213                if self.mass_unloaded_exit < self.mass_unloaded_entry {
1214                    crate::bail_invalid_estim!(
1215                        "latent survival right-censored row requires unloaded exit mass >= unloaded entry mass, got {} < {}",
1216                        self.mass_unloaded_exit,
1217                        self.mass_unloaded_entry
1218                    );
1219                }
1220                if self.mass_left > 0.0
1221                    || self.mass_right > 0.0
1222                    || self.mass_unloaded_left > 0.0
1223                    || self.mass_unloaded_right > 0.0
1224                    || self.hazard_loaded > 0.0
1225                    || self.hazard_unloaded > 0.0
1226                {
1227                    crate::bail_invalid_estim!("latent survival right-censored row cannot carry interval masses or event hazards"
1228                            .to_string(),);
1229                }
1230            }
1231            LatentSurvivalEventType::ExactEvent => {
1232                if self.mass_exit < self.mass_entry {
1233                    crate::bail_invalid_estim!(
1234                        "latent survival exact-event row requires mass_exit >= mass_entry, got {} < {}",
1235                        self.mass_exit,
1236                        self.mass_entry
1237                    );
1238                }
1239                if self.mass_unloaded_exit < self.mass_unloaded_entry {
1240                    crate::bail_invalid_estim!(
1241                        "latent survival exact-event row requires unloaded exit mass >= unloaded entry mass, got {} < {}",
1242                        self.mass_unloaded_exit,
1243                        self.mass_unloaded_entry
1244                    );
1245                }
1246                if self.mass_left > 0.0
1247                    || self.mass_right > 0.0
1248                    || self.mass_unloaded_left > 0.0
1249                    || self.mass_unloaded_right > 0.0
1250                {
1251                    crate::bail_invalid_estim!(
1252                        "latent survival exact-event row cannot carry interval masses"
1253                    );
1254                }
1255                if self.hazard_loaded == 0.0 && self.hazard_unloaded == 0.0 {
1256                    crate::bail_invalid_estim!("latent survival exact-event row requires a positive loaded or unloaded hazard"
1257                            .to_string(),);
1258                }
1259            }
1260            LatentSurvivalEventType::IntervalCensored => {
1261                if self.mass_left < self.mass_entry || self.mass_right < self.mass_left {
1262                    crate::bail_invalid_estim!(
1263                        "latent survival interval row requires mass_entry <= mass_left <= mass_right, got entry={}, left={}, right={}",
1264                        self.mass_entry,
1265                        self.mass_left,
1266                        self.mass_right
1267                    );
1268                }
1269                if self.mass_unloaded_left < self.mass_unloaded_entry
1270                    || self.mass_unloaded_right < self.mass_unloaded_left
1271                {
1272                    crate::bail_invalid_estim!(
1273                        "latent survival interval row requires unloaded_entry <= unloaded_left <= unloaded_right, got entry={}, left={}, right={}",
1274                        self.mass_unloaded_entry,
1275                        self.mass_unloaded_left,
1276                        self.mass_unloaded_right
1277                    );
1278                }
1279                if self.mass_exit > 0.0
1280                    || self.mass_unloaded_exit > 0.0
1281                    || self.hazard_loaded > 0.0
1282                    || self.hazard_unloaded > 0.0
1283                {
1284                    crate::bail_invalid_estim!(
1285                        "latent survival interval row cannot carry exit masses or event hazards"
1286                            .to_string(),
1287                    );
1288                }
1289            }
1290        }
1291
1292        Ok(())
1293    }
1294}
1295
1296fn exact_event_kernel_jet(
1297    quadctx: &QuadratureContext,
1298    row: &LatentSurvivalRow,
1299    mu: f64,
1300    sigma: f64,
1301) -> Result<LogKernelSumJet, EstimationError> {
1302    if row.hazard_loaded < 0.0 || row.hazard_unloaded < 0.0 {
1303        crate::bail_invalid_estim!(
1304            "latent survival exact-event hazards must be non-negative, got loaded={} unloaded={}",
1305            row.hazard_loaded,
1306            row.hazard_unloaded
1307        );
1308    }
1309    match (row.hazard_unloaded > 0.0, row.hazard_loaded > 0.0) {
1310        (true, true) => {
1311            let terms = [
1312                KernelSumTerm {
1313                    coeff: row.hazard_unloaded,
1314                    k: 0,
1315                    m: row.mass_exit,
1316                },
1317                KernelSumTerm {
1318                    coeff: row.hazard_loaded,
1319                    k: 1,
1320                    m: row.mass_exit,
1321                },
1322            ];
1323            LogKernelSumJet::evaluate(quadctx, &terms, mu, sigma)
1324        }
1325        (true, false) => {
1326            let jet = LogKernelSumJet::single_term(quadctx, 0, row.mass_exit, mu, sigma)?;
1327            Ok(LogKernelSumJet {
1328                value: row.hazard_unloaded.ln() + jet.value,
1329                d1: jet.d1,
1330                d2: jet.d2,
1331                d3: jet.d3,
1332                d4: jet.d4,
1333                mode: jet.mode,
1334            })
1335        }
1336        (false, true) => {
1337            let jet = LogKernelSumJet::single_term(quadctx, 1, row.mass_exit, mu, sigma)?;
1338            Ok(LogKernelSumJet {
1339                value: row.hazard_loaded.ln() + jet.value,
1340                d1: jet.d1,
1341                d2: jet.d2,
1342                d3: jet.d3,
1343                d4: jet.d4,
1344                mode: jet.mode,
1345            })
1346        }
1347        (false, false) => Err(EstimationError::InvalidInput(
1348            "latent survival exact-event row requires a positive loaded or unloaded hazard"
1349                .to_string(),
1350        )),
1351    }
1352}
1353
1354/// Row-level log-likelihood and μ-derivatives for the latent survival model.
1355///
1356/// The conditional model is:
1357///   `Λ(a | U) = B(a) · exp(U)`,  `U ~ N(μ, σ²)`
1358///
1359/// All likelihoods reduce to algebra on `K_{k,m}(μ, σ)`.
1360#[derive(Clone, Copy, Debug)]
1361pub struct LatentSurvivalRowJet {
1362    pub log_lik: f64,
1363    pub score: f64,
1364    pub neg_hessian: f64,
1365    pub d3: f64,
1366    pub score_log_sigma: f64,
1367    pub neg_hessian_log_sigma: f64,
1368}
1369
1370#[inline]
1371fn log_sigma_score_from_log_sum(jet: &LogKernelSumJet, sigma: f64) -> f64 {
1372    let sigma2 = sigma * sigma;
1373    sigma2 * (jet.d2 + jet.d1 * jet.d1)
1374}
1375
1376#[inline]
1377fn log_sigma_neg_hessian_from_log_sum(jet: &LogKernelSumJet, sigma: f64) -> f64 {
1378    let sigma2 = sigma * sigma;
1379    let sigma4 = sigma2 * sigma2;
1380    let d1 = jet.d1;
1381    let d2 = jet.d2;
1382    let d3 = jet.d3;
1383    let d4 = jet.d4;
1384    let s2_over_s = d2 + d1 * d1;
1385    // For S = Σ a_j K_j, D = σ ∂_σ, and D S = σ² S_μμ:
1386    // D² log S = 2σ² (S''/S) + σ⁴ (S''''/S - (S''/S)²).
1387    // Express the final parenthesized term directly in log-derivatives to
1388    // avoid the larger cancellation in `r4 - r2²`.
1389    let s4_over_s_minus_s2_sq = d4 + 4.0 * d1 * d3 + 2.0 * d2 * d2 + 4.0 * d1 * d1 * d2;
1390    -(2.0 * sigma2 * s2_over_s + sigma4 * s4_over_s_minus_s2_sq)
1391}
1392
1393impl LatentSurvivalRowJet {
1394    pub fn evaluate(
1395        quadctx: &QuadratureContext,
1396        row: &LatentSurvivalRow,
1397        mu: f64,
1398        sigma: f64,
1399    ) -> Result<Self, EstimationError> {
1400        row.validate()?;
1401        match row.event_type {
1402            LatentSurvivalEventType::RightCensored => Self::right_censored(quadctx, mu, sigma, row),
1403            LatentSurvivalEventType::ExactEvent => Self::exact_event(quadctx, mu, sigma, row),
1404            LatentSurvivalEventType::IntervalCensored => {
1405                Self::interval_censored(quadctx, mu, sigma, row)
1406            }
1407        }
1408    }
1409
1410    /// Right-censoring with loaded/unloaded mass decomposition.
1411    ///
1412    /// Full formula:
1413    ///   `ℓ = -M_U_exit + log K_{0,M_L_exit} + M_U_entry - log K_{0,M_L_entry}`
1414    ///
1415    /// When `mass_unloaded_exit == 0` and `mass_unloaded_entry == 0`, this
1416    /// falls back to the original formula using `mass_exit` / `mass_entry`.
1417    fn right_censored(
1418        quadctx: &QuadratureContext,
1419        mu: f64,
1420        sigma: f64,
1421        row: &LatentSurvivalRow,
1422    ) -> Result<Self, EstimationError> {
1423        let has_unloaded = row.mass_unloaded_exit != 0.0 || row.mass_unloaded_entry != 0.0;
1424
1425        // Loaded mass for the kernel terms: when unloaded mass is present,
1426        // mass_exit contains only the loaded component; otherwise it is the
1427        // total mass.
1428        let mass_exit_loaded = row.mass_exit;
1429        let mass_entry_loaded = row.mass_entry;
1430
1431        // Unloaded mass contributes a simple additive constant to log-lik
1432        let unloaded_offset = if has_unloaded {
1433            -row.mass_unloaded_exit + row.mass_unloaded_entry
1434        } else {
1435            0.0
1436        };
1437
1438        let num = LogKernelSumJet::single_term(quadctx, 0, mass_exit_loaded, mu, sigma)?;
1439        if mass_entry_loaded > 0.0 {
1440            let den = LogKernelSumJet::single_term(quadctx, 0, mass_entry_loaded, mu, sigma)?;
1441            Ok(Self {
1442                log_lik: unloaded_offset + num.value - den.value,
1443                score: num.d1 - den.d1,
1444                neg_hessian: -(num.d2 - den.d2),
1445                d3: num.d3 - den.d3,
1446                score_log_sigma: log_sigma_score_from_log_sum(&num, sigma)
1447                    - log_sigma_score_from_log_sum(&den, sigma),
1448                neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma)
1449                    - log_sigma_neg_hessian_from_log_sum(&den, sigma),
1450            })
1451        } else {
1452            Ok(Self {
1453                log_lik: unloaded_offset + num.value,
1454                score: num.d1,
1455                neg_hessian: -num.d2,
1456                d3: num.d3,
1457                score_log_sigma: log_sigma_score_from_log_sum(&num, sigma),
1458                neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma),
1459            })
1460        }
1461    }
1462
1463    /// Exact event with loaded/unloaded hazard decomposition.
1464    ///
1465    /// `ℓ = log(h_U · K_{0,M_L} + h_L · K_{1,M_L}) - M_U_event + M_U_entry - log K_{0,M_L_entry}`
1466    fn exact_event(
1467        quadctx: &QuadratureContext,
1468        mu: f64,
1469        sigma: f64,
1470        row: &LatentSurvivalRow,
1471    ) -> Result<Self, EstimationError> {
1472        let unloaded_offset = if row.mass_unloaded_exit != 0.0 || row.mass_unloaded_entry != 0.0 {
1473            -row.mass_unloaded_exit + row.mass_unloaded_entry
1474        } else {
1475            0.0
1476        };
1477        let num = exact_event_kernel_jet(quadctx, row, mu, sigma)?;
1478
1479        if row.mass_entry > 0.0 {
1480            let den = LogKernelSumJet::single_term(quadctx, 0, row.mass_entry, mu, sigma)?;
1481            Ok(Self {
1482                log_lik: unloaded_offset + num.value - den.value,
1483                score: num.d1 - den.d1,
1484                neg_hessian: -(num.d2 - den.d2),
1485                d3: num.d3 - den.d3,
1486                score_log_sigma: log_sigma_score_from_log_sum(&num, sigma)
1487                    - log_sigma_score_from_log_sum(&den, sigma),
1488                neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma)
1489                    - log_sigma_neg_hessian_from_log_sum(&den, sigma),
1490            })
1491        } else {
1492            Ok(Self {
1493                log_lik: unloaded_offset + num.value,
1494                score: num.d1,
1495                neg_hessian: -num.d2,
1496                d3: num.d3,
1497                score_log_sigma: log_sigma_score_from_log_sum(&num, sigma),
1498                neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma),
1499            })
1500        }
1501    }
1502
1503    /// Interval event: `ℓ = log(K_{0,M_L} − K_{0,M_R}) − log K_{0,M_in}`.
1504    fn interval_censored(
1505        quadctx: &QuadratureContext,
1506        mu: f64,
1507        sigma: f64,
1508        row: &LatentSurvivalRow,
1509    ) -> Result<Self, EstimationError> {
1510        let num_terms = [
1511            KernelSumTerm {
1512                coeff: (-row.mass_unloaded_left).exp(),
1513                k: 0,
1514                m: row.mass_left,
1515            },
1516            KernelSumTerm {
1517                coeff: -(-row.mass_unloaded_right).exp(),
1518                k: 0,
1519                m: row.mass_right,
1520            },
1521        ];
1522        let num = LogKernelSumJet::evaluate(quadctx, &num_terms, mu, sigma)?;
1523
1524        if row.mass_entry > 0.0 {
1525            let den = LogKernelSumJet::single_term(quadctx, 0, row.mass_entry, mu, sigma)?;
1526            Ok(Self {
1527                log_lik: num.value + row.mass_unloaded_entry - den.value,
1528                score: num.d1 - den.d1,
1529                neg_hessian: -(num.d2 - den.d2),
1530                d3: num.d3 - den.d3,
1531                score_log_sigma: log_sigma_score_from_log_sum(&num, sigma)
1532                    - log_sigma_score_from_log_sum(&den, sigma),
1533                neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma)
1534                    - log_sigma_neg_hessian_from_log_sum(&den, sigma),
1535            })
1536        } else {
1537            Ok(Self {
1538                log_lik: num.value + row.mass_unloaded_entry,
1539                score: num.d1,
1540                neg_hessian: -num.d2,
1541                d3: num.d3,
1542                score_log_sigma: log_sigma_score_from_log_sum(&num, sigma),
1543                neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma),
1544            })
1545        }
1546    }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551    use super::*;
1552
1553    /// #2610: the reformulation is an IDENTITY, so where the differenced form is
1554    /// trustworthy the two must agree to near machine epsilon.
1555    ///
1556    /// #2566 measured the differenced channel's step-to-step relative jump
1557    /// growing `0.05, 0.07, 0.19, 0.47, 0.84, 2.89, 9.78` and changing SIGN
1558    /// between adjacent samples past `log σ ≈ 5.45`. Sign flips between
1559    /// neighbouring points of a smooth function are the signature of
1560    /// cancellation, not of a branch. This walks the same ladder and compares
1561    /// the two formations on identical bundles, so the only difference is the
1562    /// arithmetic.
1563    ///
1564    /// SCOPE: on this fixture (`m = 1`, `mu = 0`, rung `k = 0`) the differenced
1565    /// form does NOT degrade -- worst step jump `0.095`, no sign flips -- because
1566    /// `rk2/rk1^2` grows like `e^(sigma^2)` here, so the two moments diverge
1567    /// instead of colliding. The regime #2566 reported therefore lives in the
1568    /// jet combination or the two-term mixture, not in the raw ratios, and
1569    /// locating it in terms of `(m, mu, k)` is still open. What this test
1570    /// establishes is that the reformulation is the SAME NUMBER as the thing it
1571    /// replaces; that it also repairs the bad regime is not yet demonstrated.
1572    #[test]
1573    fn zz_measure_2610_reformulation_reproduces_the_differenced_value_it_replaces() {
1574        let quadctx = QuadratureContext::new();
1575        let (mass, mu) = (1.0_f64, 0.0_f64);
1576
1577        let mut rows: Vec<(f64, f64, f64)> = Vec::new();
1578        let mut log_sigma = 4.0_f64;
1579        while log_sigma <= 7.0001 {
1580            let sigma = log_sigma.exp();
1581            let bundle = log_kernel_bundle(&quadctx, mass, mu, sigma, 4)
1582                .expect("bundle over the measured range");
1583            // Differenced form: exactly what a consumer writes today.
1584            let ratio1 = (bundle.get(1) - bundle.get(0)).exp();
1585            let ratio2 = (bundle.get(2) - bundle.get(0)).exp();
1586            let differenced = ratio2 - ratio1 * ratio1;
1587            let stable = bundle
1588                .second_cumulant_ratio(0, sigma)
1589                .expect("cancellation-free form is defined on this range");
1590            println!(
1591                "[2610] log_sigma={log_sigma:.2} differenced={differenced:.12e} stable={stable:.12e}"
1592            );
1593            rows.push((log_sigma, differenced, stable));
1594            log_sigma += 0.1;
1595        }
1596
1597        // Step-to-step relative jump, the quantity #2566 reported.
1598        let jump = |a: f64, b: f64| -> f64 {
1599            let scale = a.abs().max(b.abs());
1600            if scale > 0.0 { (b - a).abs() / scale } else { 0.0 }
1601        };
1602        let mut worst_differenced = 0.0_f64;
1603        let mut sign_flips_differenced = 0usize;
1604        for pair in rows.windows(2) {
1605            let (d0, d1) = (pair[0].1, pair[1].1);
1606            let differenced_jump = jump(d0, d1);
1607            if differenced_jump > worst_differenced {
1608                worst_differenced = differenced_jump;
1609            }
1610            if d0 * d1 < 0.0 {
1611                sign_flips_differenced += 1;
1612            }
1613        }
1614
1615        let mut worst_disagreement = 0.0_f64;
1616        for row in &rows {
1617            let scale = row.1.abs().max(row.2.abs());
1618            if scale > 0.0 {
1619                let relative = (row.1 - row.2).abs() / scale;
1620                if relative > worst_disagreement {
1621                    worst_disagreement = relative;
1622                }
1623            }
1624        }
1625        println!(
1626            "[2610] worst differenced step jump={worst_differenced:.6} \
1627             sign flips={sign_flips_differenced} worst disagreement={worst_disagreement:.3e}"
1628        );
1629
1630        // Measured, not assumed: this ladder is WELL CONDITIONED. The differenced
1631        // form is smooth here and never changes sign, which is exactly what makes
1632        // the equivalence check below a statement about the algebra rather than
1633        // about which of two noisy channels is noisier.
1634        //
1635        // It also bounds what this test may be read as saying. It does NOT reach
1636        // the regime #2566 reported. An earlier revision asserted only that the
1637        // reformulated channel behaved, and passed -- on a ladder where the
1638        // channel it replaces was already fine. That green meant nothing, and
1639        // this assertion exists so the same mistake cannot be made silently.
1640        assert!(
1641            worst_differenced < 1.0 && sign_flips_differenced == 0,
1642            "#2610: this ladder is supposed to be well conditioned, so that agreement \
1643             between the two formations means something (worst jump {worst_differenced}, \
1644             {sign_flips_differenced} sign flips)"
1645        );
1646        assert!(
1647            rows.iter().all(|row| row.2.is_finite()),
1648            "#2610: the cancellation-free second cumulant must be finite across the ladder"
1649        );
1650        // `-R2 * expm1(D)` is an IDENTITY for `R2 - R1^2`, so where the
1651        // subtraction is trustworthy the two must agree to near machine epsilon.
1652        // The tightness is the point: a sign slip, or a wrong closed form for the
1653        // prefix's second difference, would miss this by orders rather than
1654        // slightly.
1655        assert!(
1656            worst_disagreement < 1.0e-12,
1657            "#2610: the reformulation must reproduce the differenced value wherever that \
1658             value is trustworthy; worst relative disagreement {worst_disagreement:e}"
1659        );
1660    }
1661
1662    #[test]
1663    fn frailty_scale_validation_distinguishes_fixed_and_learned_domains() {
1664        assert!(FrailtySpec::None.validate().is_ok());
1665        assert!(
1666            FrailtySpec::GaussianShift {
1667                scale: FrailtyScale::Fixed { sigma: 0.75 },
1668            }
1669            .validate()
1670            .is_ok()
1671        );
1672        assert!(
1673            FrailtySpec::HazardMultiplier {
1674                scale: FrailtyScale::Learned { initial_sigma: 0.5 },
1675                loading: HazardLoading::Full,
1676            }
1677            .validate()
1678            .is_ok()
1679        );
1680        assert!(
1681            FrailtySpec::GaussianShift {
1682                scale: FrailtyScale::Fixed { sigma: -0.1 },
1683            }
1684            .validate()
1685            .is_err()
1686        );
1687        assert!(
1688            FrailtySpec::GaussianShift {
1689                scale: FrailtyScale::Fixed { sigma: f64::NAN },
1690            }
1691            .validate()
1692            .is_err()
1693        );
1694        assert!(
1695            FrailtySpec::GaussianShift {
1696                scale: FrailtyScale::Learned { initial_sigma: 0.0 },
1697            }
1698            .validate()
1699            .is_err()
1700        );
1701        assert!(
1702            FrailtySpec::GaussianShift {
1703                scale: FrailtyScale::Learned {
1704                    initial_sigma: f64::INFINITY,
1705                },
1706            }
1707            .validate()
1708            .is_err()
1709        );
1710    }
1711
1712    fn latent_binomial_row_log_lik(
1713        ctx: &QuadratureContext,
1714        eta: f64,
1715        sigma: f64,
1716        y: f64,
1717        weight: f64,
1718    ) -> f64 {
1719        let mu = latent_cloglog_jet5(ctx, eta, sigma)
1720            .expect("latent jet")
1721            .mean;
1722        let mu = mu.clamp(1e-12, 1.0 - 1e-12);
1723        weight * (y * mu.ln() + (1.0 - y) * (1.0 - mu).ln())
1724    }
1725
1726    #[test]
1727    fn kernel_ratio_jet_d1_fd_check() {
1728        let ctx = QuadratureContext::new();
1729        let mu = 0.3;
1730        let sigma = 0.5;
1731        let m = 1.0;
1732        let k = 0usize;
1733        let h = 1e-5;
1734
1735        let bundle = log_kernel_bundle(&ctx, m, mu, sigma, k + 4).unwrap();
1736        let log_k = bundle.get(k);
1737        let ratios = kernel_ratio_jet(&bundle, k, m, 2);
1738        let kc = log_k.exp();
1739        let d1 = kc * ratios[1];
1740        let d2 = kc * ratios[2];
1741
1742        let kp = log_kernel_term(&ctx, k, m, mu + h, sigma).unwrap().0.exp();
1743        let km = log_kernel_term(&ctx, k, m, mu - h, sigma).unwrap().0.exp();
1744        let fd_d1 = (kp - km) / (2.0 * h);
1745        assert!(
1746            (d1 - fd_d1).abs() / fd_d1.abs().max(1e-15) < 1e-4,
1747            "d1: jet={d1}, fd={fd_d1}",
1748        );
1749
1750        let fd_d2 = (kp - 2.0 * kc + km) / (h * h);
1751        assert!(
1752            (d2 - fd_d2).abs() / fd_d2.abs().max(1e-15) < 1e-3,
1753            "d2: jet={d2}, fd={fd_d2}",
1754        );
1755    }
1756
1757    #[test]
1758    fn survival_right_censored_score_fd() {
1759        let ctx = QuadratureContext::new();
1760        let mu = -0.5;
1761        let sigma = 0.3;
1762        let h = 1e-6;
1763        let row = LatentSurvivalRow::right_censored(0.0, 2.0, 0.0, 0.0);
1764        let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1765            .unwrap()
1766            .log_lik;
1767        let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1768            .unwrap()
1769            .log_lik;
1770        let fd_score = (ll_p - ll_m) / (2.0 * h);
1771        let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1772        assert!(
1773            (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1774            "score={}, fd={fd_score}",
1775            jet.score
1776        );
1777    }
1778
1779    #[test]
1780    fn survival_exact_event_score_fd() {
1781        let ctx = QuadratureContext::new();
1782        let mu = 0.2;
1783        let sigma = 0.5;
1784        let h = 1e-6;
1785        let row = LatentSurvivalRow::exact_event(0.0, 1.5, 0.0, 0.0, (-0.3f64).exp(), 0.0);
1786        let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1787            .unwrap()
1788            .log_lik;
1789        let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1790            .unwrap()
1791            .log_lik;
1792        let fd_score = (ll_p - ll_m) / (2.0 * h);
1793        let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1794        assert!(
1795            (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1796            "score={}, fd={fd_score}",
1797            jet.score
1798        );
1799    }
1800
1801    #[test]
1802    fn survival_exact_event_loaded_vs_unloaded_score_fd() {
1803        let ctx = QuadratureContext::new();
1804        let mu = -0.1;
1805        let sigma = 0.4;
1806        let h = 1e-6;
1807        let row = LatentSurvivalRow::exact_event(0.3, 1.2, 0.2, 0.6, 0.9, 0.15);
1808        let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1809            .unwrap()
1810            .log_lik;
1811        let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1812            .unwrap()
1813            .log_lik;
1814        let fd_score = (ll_p - ll_m) / (2.0 * h);
1815        let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1816        assert!(
1817            (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1818            "score={}, fd={fd_score}",
1819            jet.score
1820        );
1821    }
1822
1823    #[test]
1824    fn survival_right_censored_loaded_vs_unloaded_score_fd() {
1825        let ctx = QuadratureContext::new();
1826        let mu = 0.15;
1827        let sigma: f64 = 0.35;
1828        let h = 1e-6;
1829        let row = LatentSurvivalRow::right_censored(0.4, 1.7, 0.1, 0.5);
1830        let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1831            .unwrap()
1832            .log_lik;
1833        let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1834            .unwrap()
1835            .log_lik;
1836        let fd_score = (ll_p - ll_m) / (2.0 * h);
1837        let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1838        assert!(
1839            (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1840            "score={}, fd={fd_score}",
1841            jet.score
1842        );
1843    }
1844
1845    #[test]
1846    fn survival_interval_censored_score_fd() {
1847        let ctx = QuadratureContext::new();
1848        let mu = 0.0;
1849        let sigma = 0.6;
1850        let h = 1e-6;
1851        let row = LatentSurvivalRow::interval_censored(0.0, 1.0, 2.0, 0.0, 0.0, 0.0);
1852        let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1853            .unwrap()
1854            .log_lik;
1855        let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1856            .unwrap()
1857            .log_lik;
1858        let fd_score = (ll_p - ll_m) / (2.0 * h);
1859        let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1860        assert!(
1861            (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1862            "score={}, fd={fd_score}",
1863            jet.score
1864        );
1865    }
1866
1867    /// #2277 hardening: a NARROW interval-censored window (S(L) ≈ S(R)) must
1868    /// stay numerically stable. The interval contribution `log[S(L) − S(R)]` is
1869    /// evaluated in the log domain (sign-aware log-sum-exp + `log1mexp`), never
1870    /// as a probability-space `S(L) − S(R)` subtraction, so for a small gap
1871    /// `Δ = M_R − M_L` the interval mass is `≈ |S'(M_L)|·Δ` and the
1872    /// log-likelihood behaves as `const + log Δ` — finite and accurate down to
1873    /// gaps where subtracting two nearly-equal survival probabilities would
1874    /// catastrophically cancel. Pin the log-linear-in-Δ law: the shared,
1875    /// cancellation-prone `const` drops out of the difference, leaving exactly
1876    /// `log(Δ₁/Δ₂)`.
1877    #[test]
1878    fn survival_narrow_interval_is_log_domain_stable_issue_2277() {
1879        let ctx = QuadratureContext::new();
1880        let (mu, sigma, m_l) = (0.0_f64, 0.6_f64, 1.0_f64);
1881        // Returns the row log-likelihood together with the interval width the
1882        // row ACTUALLY holds.
1883        //
1884        // A nominal gap this narrow is not representable as `m_l + gap`: the
1885        // double spacing at 1.0 is 2.22e-16, so `1e-12` is 4504.5 ulps and the
1886        // constructed right mass carries the ROUNDED width — up to 1.1e-4
1887        // relative away from the nominal one, and 1.1e-2 at `1e-14`. Asserting
1888        // `ll(g1) − ll(g2) = ln(g1_nominal/g2_nominal)` therefore charges the
1889        // kernel arithmetic for the fixture's own quantization. The law is
1890        // `ll = const + log Δ` in the width the row was built with, so that is
1891        // what it is asserted against.
1892        let ll_and_width = |gap: f64| -> (f64, f64) {
1893            let m_r = m_l + gap;
1894            let row = LatentSurvivalRow::interval_censored(0.0, m_l, m_r, 0.0, 0.0, 0.0);
1895            let log_lik = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma)
1896                .unwrap()
1897                .log_lik;
1898            (log_lik, m_r - m_l)
1899        };
1900        // REPRESENTABILITY and ACCURACY are different claims, and a previous
1901        // revision of this fixture correctly observed that the differenced
1902        // two-term path delivered only the first at an arbitrarily narrow gap:
1903        // `evaluate_two_terms` subtracted two INDEPENDENTLY evaluated
1904        // log-kernels, cancelling as many digits as a probability-space
1905        // `S(L) − S(R)` would, so at `Δ = 1e-12` the separation carried ~2e-4
1906        // RELATIVE error. It therefore narrowed the law to `(1e-6, 1e-9)` and
1907        // kept only finiteness at `1e-12`.
1908        //
1909        // `analytic_log_mag_gap` removes that subtraction — the separation is now
1910        // a Taylor expansion of `Λ` in `dv = ln(M_R/M_L)`, whose leading order is
1911        // analytic — so both claims hold at the extreme gap and the original
1912        // `(1e-8, 1e-12)` pair is restored below. Finiteness is still asserted
1913        // separately at `1e-12`, because it is a different property from the law
1914        // and is the one the log domain buys on its own.
1915        assert!(
1916            ll_and_width(1e-12).0.is_finite(),
1917            "narrow-interval log-lik must stay finite at the extreme gap: {}",
1918            ll_and_width(1e-12).0
1919        );
1920        let (ll1, width1) = ll_and_width(1e-8);
1921        let (ll2, width2) = ll_and_width(1e-12);
1922        assert!(
1923            ll1.is_finite() && ll2.is_finite(),
1924            "narrow-interval log-lik must stay finite: ll1={ll1}, ll2={ll2}"
1925        );
1926        // const + log Δ ⇒ ll(Δ₁) − ll(Δ₂) = log(Δ₁/Δ₂), independent of the
1927        // shared const a probability-space subtraction would destroy here.
1928        let expected = (width1 / width2).ln();
1929        assert!(
1930            (ll1 - ll2 - expected).abs() < 1e-5,
1931            "narrow interval must follow the log-domain log(Δ) law: \
1932             ll(Δ₁)-ll(Δ₂)={}, expected {expected} (Δ₁={width1:e}, Δ₂={width2:e})",
1933            ll1 - ll2
1934        );
1935
1936        // The law is asserted over a LADDER, not one pair, because the failure
1937        // mode it guards is an accuracy loss `∝ 1/Δ`: a single pair cannot tell
1938        // "accurate everywhere" from "accurate at one point". The differenced
1939        // form missed the pair above by 2.2557e-4 — 22× the tolerance — and the
1940        // miss GREW as the gap shrank.
1941        //
1942        // `ll(Δ) = const + log Δ + O(Δ)`; the `O(Δ)` remainder is real, from the
1943        // curvature of `log1mexp` and of `ln1p(Δ/M_L)`, with a coefficient set
1944        // by `Λ''/Λ'` at this `(μ, σ, m)`. The bound is therefore an `O(Δ)` term
1945        // with a deliberately generous constant PLUS a floor. The floor is the
1946        // claim that matters: at `Δ = 1e-14` the bound is `1e-9`, five orders
1947        // below what the differenced formulation delivered, so it asserts that
1948        // the accuracy does not degrade as `1/Δ`.
1949        let mut gap = 1e-6_f64;
1950        let (mut previous, mut previous_width) = ll_and_width(gap);
1951        while gap > 1e-13 {
1952            let next_gap = gap * 1e-2;
1953            let (next, next_width) = ll_and_width(next_gap);
1954            let step_expected = (previous_width / next_width).ln();
1955            let residual = previous - next - step_expected;
1956            assert!(
1957                residual.abs() < 1e-9 + 100.0 * gap,
1958                "log(Δ) law must hold at every rung: Δ {previous_width:e}->{next_width:e} \
1959                 gave {}, expected {step_expected}, residual {residual:e}",
1960                previous - next
1961            );
1962            gap = next_gap;
1963            previous = next;
1964            previous_width = next_width;
1965        }
1966    }
1967    #[test]
1968    fn survival_interval_censored_neg_hessian_fd() {
1969        // Second μ-derivative of ℓ = log[S(L) − S(R)] for the interval kernel,
1970        // FD-checked. `neg_hessian` stores −d²ℓ/dμ², so compare against the
1971        // negated central second difference.
1972        let ctx = QuadratureContext::new();
1973        let mu = -0.2;
1974        let sigma = 0.55;
1975        let h = 2e-4;
1976        let row = LatentSurvivalRow::interval_censored(0.0, 0.7, 1.9, 0.0, 0.0, 0.0);
1977        let ll = |m: f64| {
1978            LatentSurvivalRowJet::evaluate(&ctx, &row, m, sigma)
1979                .unwrap()
1980                .log_lik
1981        };
1982        let fd_d2 = (ll(mu + h) - 2.0 * ll(mu) + ll(mu - h)) / (h * h);
1983        let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1984        assert!(
1985            (jet.neg_hessian - (-fd_d2)).abs() / fd_d2.abs().max(1e-12) < 1e-2,
1986            "interval neg_hessian={}, fd(-d2)={}",
1987            jet.neg_hessian,
1988            -fd_d2
1989        );
1990    }
1991
1992    #[test]
1993    fn survival_interval_censored_log_sigma_score_fd() {
1994        // σ-recovery for interval data is driven by `score_log_sigma`, the
1995        // derivative of ℓ = log[S(L) − S(R)] w.r.t. log σ. FD-check it directly
1996        // against the row log-likelihood (this is the channel the interval fit's
1997        // latent_sd estimate moves along, the test's primary metric).
1998        let ctx = QuadratureContext::new();
1999        let mu = 0.1;
2000        let sigma: f64 = 0.6;
2001        let h = 1e-5;
2002        let row = LatentSurvivalRow::interval_censored(0.0, 0.8, 2.1, 0.0, 0.0, 0.0);
2003        let ll_at = |s: f64| {
2004            LatentSurvivalRowJet::evaluate(&ctx, &row, mu, s)
2005                .unwrap()
2006                .log_lik
2007        };
2008        // d/d(log σ) = σ · d/dσ, so FD over log σ directly.
2009        let fd_dlogsigma =
2010            (ll_at((sigma.ln() + h).exp()) - ll_at((sigma.ln() - h).exp())) / (2.0 * h);
2011        let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
2012        assert!(
2013            (jet.score_log_sigma - fd_dlogsigma).abs() / fd_dlogsigma.abs().max(1e-12) < 1e-3,
2014            "interval score_log_sigma={}, fd={fd_dlogsigma}",
2015            jet.score_log_sigma
2016        );
2017    }
2018
2019    #[test]
2020    fn log_kernel_single_term_log_sigma_derivatives_match_ghq_reference() {
2021        let ctx = QuadratureContext::new();
2022        let mu = 0.2;
2023        let sigma = 1.0;
2024        let jet = LogKernelSumJet::single_term(&ctx, 0, 1.0, mu, sigma).unwrap();
2025        let ghq = crate::inference::quadrature::cloglog_ghq_derivatives_adaptive(&ctx, mu, sigma);
2026        let survival = (1.0 - ghq.l).max(1e-300);
2027        let survival_sigma_over_survival = -ghq.l_sigma / survival;
2028        let ref_score = sigma * survival_sigma_over_survival;
2029        let ref_neg_hessian = -(ref_score
2030            + sigma
2031                * sigma
2032                * (-ghq.l_sigmasigma / survival - survival_sigma_over_survival.powi(2)));
2033
2034        assert!(
2035            (log_sigma_score_from_log_sum(&jet, sigma) - ref_score).abs()
2036                / ref_score.abs().max(1e-12)
2037                < 1e-4,
2038            "log-sigma score={}, ref={ref_score}",
2039            log_sigma_score_from_log_sum(&jet, sigma)
2040        );
2041        assert!(
2042            (log_sigma_neg_hessian_from_log_sum(&jet, sigma) - ref_neg_hessian).abs()
2043                / ref_neg_hessian.abs().max(1e-12)
2044                < 1e-3,
2045            "log-sigma neg_hessian={}, ref={ref_neg_hessian}",
2046            log_sigma_neg_hessian_from_log_sum(&jet, sigma)
2047        );
2048    }
2049
2050    #[test]
2051    fn log_kernel_sum_jet_single_term_d1_fd() {
2052        let ctx = QuadratureContext::new();
2053        let mu = 0.5;
2054        let sigma = 0.4;
2055        let m = 1.0;
2056        let k = 0usize;
2057        let h = 1e-6;
2058
2059        let jet = LogKernelSumJet::single_term(&ctx, k, m, mu, sigma).unwrap();
2060        let val_p = log_kernel_term(&ctx, k, m, mu + h, sigma).unwrap().0;
2061        let val_m = log_kernel_term(&ctx, k, m, mu - h, sigma).unwrap().0;
2062        let fd_d1 = (val_p - val_m) / (2.0 * h);
2063        assert!(
2064            (jet.d1 - fd_d1).abs() / fd_d1.abs().max(1e-15) < 1e-3,
2065            "d1={}, fd={fd_d1}",
2066            jet.d1
2067        );
2068    }
2069
2070    #[test]
2071    fn log_kernel_sum_jet_single_term_d4_fd() {
2072        let ctx = QuadratureContext::new();
2073        let mu = 0.35;
2074        let sigma = 0.45;
2075        let m = 1.2;
2076        let k = 1usize;
2077        let h = 2e-3;
2078
2079        let jet = LogKernelSumJet::single_term(&ctx, k, m, mu, sigma).unwrap();
2080        let v_pp = log_kernel_term(&ctx, k, m, mu + 2.0 * h, sigma).unwrap().0;
2081        let v_p = log_kernel_term(&ctx, k, m, mu + h, sigma).unwrap().0;
2082        let v_0 = log_kernel_term(&ctx, k, m, mu, sigma).unwrap().0;
2083        let v_m = log_kernel_term(&ctx, k, m, mu - h, sigma).unwrap().0;
2084        let v_mm = log_kernel_term(&ctx, k, m, mu - 2.0 * h, sigma).unwrap().0;
2085        let fd_d4 = (v_mm - 4.0 * v_m + 6.0 * v_0 - 4.0 * v_p + v_pp) / h.powi(4);
2086        assert!(
2087            (jet.d4 - fd_d4).abs() / jet.d4.abs().max(fd_d4.abs()).max(1e-8) < 2e-2,
2088            "d4={}, fd={fd_d4}",
2089            jet.d4
2090        );
2091    }
2092
2093    #[test]
2094    fn latent_cloglog_jet_matches_point_limit_at_zero_sigma() {
2095        let ctx = QuadratureContext::new();
2096        let eta = -0.4;
2097        let jet = latent_cloglog_jet5(&ctx, eta, 0.0).expect("latent jet");
2098        let t = eta.exp();
2099        let d1 = (eta - t).exp();
2100        let d2 = (1.0 - t) * d1;
2101        let d3 = (t * t - 3.0 * t + 1.0) * d1;
2102        let d4 = (-t * t * t + 6.0 * t * t - 7.0 * t + 1.0) * d1;
2103        let d5 = (t.powi(4) - 10.0 * t.powi(3) + 25.0 * t * t - 15.0 * t + 1.0) * d1;
2104        assert!((jet.mean - (1.0 - (-t).exp())).abs() < 1e-12);
2105        assert!((jet.d1 - d1).abs() < 1e-12);
2106        assert!((jet.d2 - d2).abs() < 1e-12);
2107        assert!((jet.d3 - d3).abs() < 1e-12);
2108        assert!((jet.d4 - d4).abs() < 1e-12);
2109        assert!((jet.d5 - d5).abs() < 1e-12);
2110    }
2111
2112    #[test]
2113    fn latent_cloglog_jet_matches_exact_kernel_recurrence() {
2114        let ctx = QuadratureContext::new();
2115        let cases = [(-4.0, 0.15), (-1.2, 0.35), (0.4, 0.6), (1.3, 0.9)];
2116
2117        for (eta, sigma) in cases {
2118            let jet = latent_cloglog_jet5(&ctx, eta, sigma).expect("latent jet");
2119            let bundle = log_kernel_bundle(&ctx, 1.0, eta, sigma, 5).expect("kernel bundle");
2120            let k0 = bundle.get(0);
2121            let k1 = bundle.get(1).exp();
2122            let k2 = bundle.get(2).exp();
2123            let k3 = bundle.get(3).exp();
2124            let k4 = bundle.get(4).exp();
2125            let k5 = bundle.get(5).exp();
2126
2127            let mean = if k0.is_finite() { -k0.exp_m1() } else { 1.0 };
2128            let d1 = k1;
2129            let d2 = k1 - k2;
2130            let d3 = k1 - 3.0 * k2 + k3;
2131            let d4 = k1 - 7.0 * k2 + 6.0 * k3 - k4;
2132            let d5 = k1 - 15.0 * k2 + 25.0 * k3 - 10.0 * k4 + k5;
2133
2134            assert!((jet.mean - mean).abs() < 1e-12);
2135            assert!((jet.d1 - d1).abs() < 1e-12);
2136            assert!((jet.d2 - d2).abs() < 1e-12);
2137            assert!((jet.d3 - d3).abs() < 1e-12);
2138            assert!((jet.d4 - d4).abs() < 1e-12);
2139            assert!((jet.d5 - d5).abs() < 1e-12);
2140        }
2141    }
2142
2143    #[test]
2144    fn latent_cloglog_binomial_row_neg_hessian_matches_fd() {
2145        let ctx = QuadratureContext::new();
2146        let eta = 0.4;
2147        let sigma = 0.6;
2148        let y = 0.35;
2149        let weight = 2.0;
2150        let h = 1e-4;
2151
2152        let jet = latent_cloglog_jet5(&ctx, eta, sigma).expect("latent jet");
2153        let mu = jet.mean.clamp(1e-12, 1.0 - 1e-12);
2154        let ellmu = y / mu - (1.0 - y) / (1.0 - mu);
2155        let ellmumu = -y / (mu * mu) - (1.0 - y) / ((1.0 - mu) * (1.0 - mu));
2156        let neg_hessian = -weight * (ellmumu * jet.d1 * jet.d1 + ellmu * jet.d2);
2157
2158        let ll_minus = latent_binomial_row_log_lik(&ctx, eta - h, sigma, y, weight);
2159        let ll0 = latent_binomial_row_log_lik(&ctx, eta, sigma, y, weight);
2160        let ll_plus = latent_binomial_row_log_lik(&ctx, eta + h, sigma, y, weight);
2161        let neg_hessian_fd = -(ll_plus - 2.0 * ll0 + ll_minus) / (h * h);
2162
2163        let err = (neg_hessian - neg_hessian_fd).abs();
2164        let tol = 2e-5_f64.max(3e-3 * neg_hessian_fd.abs());
2165        assert!(
2166            err <= tol,
2167            "latent cloglog Bernoulli row curvature mismatch: analytic={} fd={}",
2168            neg_hessian,
2169            neg_hessian_fd
2170        );
2171    }
2172}