Skip to main content

gam_models/survival/latent/survival/
mod.rs

1//! Jointly learned latent-frailty survival and binary deployment families with
2//! a live time/baseline block.
3//!
4//! Model:
5//!   H_0(a) = exp(q(a)),
6//!   h_0(a) = dq(a)/da,
7//!   H(a | U) = H_0(a) * exp(U),
8//!   U ~ N(mu, sigma^2),
9//!   mu = X beta + offset.
10//!
11//! Unlike the old compiled-row path, the cumulative masses and baseline hazard
12//! are rebuilt inside the optimizer from the current time-basis coefficients.
13//! The family-level fit surface supports exact events, right censoring, and
14//! interval censoring `T ∈ (L, R]` (contribution `log[S(L) − S(R)]`). Interval
15//! rows carry the reserved [`LATENT_SURVIVAL_EVENT_INTERVAL`] event code and a
16//! dedicated upper-bound time channel (`time_design_right` / `q_right`); the
17//! 3-way event dispatch is [`latent_survival_event_type_for`]. Reached from the
18//! formula DSL via `SurvInterval(L, R, event) ~ ...`.
19
20use crate::custom_family::{
21    BlockWorkingSet, BlockwiseFitOptions, ConstraintSet, CustomFamily,
22    ExactNewtonJointGradientEvaluation, ExactNewtonJointHessianWorkspace, FamilyEvaluation,
23    ParameterBlockSpec, ParameterBlockState, PenaltyMatrix, fit_custom_family,
24    fit_custom_family_fixed_log_lambdas,
25};
26use crate::fit_orchestration::drivers::freeze_term_collection_from_design;
27use crate::gamlss::{FamilyMetadata, ParameterLink};
28use crate::model_types::UnifiedFitResult;
29use crate::probability::{
30    exact_binary64_sum_sign, log1mexp_positive, signed_log_sum_exp,
31};
32use crate::quadrature::{IntegratedExpectationMode, QuadratureContext};
33use crate::sigma_link::{exp_sigma_eta_for_sigma_scalar, exp_sigma_from_eta_scalar};
34use crate::survival::latent::interval::{
35    LatentFrailtyResolution, LatentIntervalModel, LatentIntervalRowView,
36    validate_latent_interval_inputs,
37};
38use crate::survival::location_scale::{
39    TimeBlockInput, project_onto_linear_constraints, structural_time_coefficient_constraints,
40};
41use crate::survival::lognormal_kernel::{
42    FrailtyScale, FrailtySpec, HazardLoading, LatentSurvivalEventType, LatentSurvivalRow,
43    LatentSurvivalRowJet, LogLognormalKernelBundle, log_kernel_bundle,
44};
45use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix, LinearOperator, SymmetricMatrix};
46use gam_math::jet_scalar::{JetScalar, OneSeed, Order2, TwoSeed};
47// `value`/`compose_unary`/… now live on the shared `JetField` base (JetScalar: JetField);
48// the concrete `row_jet.base.value()` reads below need it in scope.
49use gam_math::nested_dual::JetField;
50use gam_solve::pirls::LinearInequalityConstraints;
51use gam_terms::smooth::{TermCollectionDesign, TermCollectionSpec, build_term_collection_design};
52use ndarray::{Array1, Array2, ArrayView1, ArrayView2, s};
53use smallvec::SmallVec;
54use std::sync::Arc;
55
56/// Typed error for the latent-survival / latent-binary family kernels and
57/// their fit-time and per-row validation helpers. Variants pick the semantic
58/// bucket while the inner `reason` carries the original byte-equivalent
59/// message so external callers that previously consumed `String` errors keep
60/// the same diagnostic text via `Display`.
61#[derive(Debug, Clone)]
62pub enum LatentSurvivalError {
63    /// The frailty spec supplied to a latent-survival or latent-binary
64    /// helper is incompatible (wrong variant, missing fixed sigma, non-finite
65    /// or negative fixed sigma).
66    InvalidFrailty { reason: String },
67    /// Per-row dataset validation failed: empty input, size mismatch across
68    /// the spec vectors, or invalid age / event / weight / unloaded-mass
69    /// values for an individual row.
70    InvalidDataset { reason: String },
71    /// A parameter-block state, eta vector, or directional-derivative
72    /// argument supplied to a family entry point has the wrong length.
73    BlockMismatch { reason: String },
74    /// A runtime numerical value (sigma, baseline hazard derivative, kernel
75    /// sum, event probability) became non-finite or out-of-domain.
76    NumericalFailure { reason: String },
77    /// A derivative could not be certified as the unique binary64 rounding of
78    /// the exact cumulant over its rounded finite moment inputs.
79    DerivativeAccuracyUnresolved { reason: String },
80    /// The requested combination of time-block structure or event type is
81    /// not implemented (non-structural monotonicity, interval-censored rows
82    /// on the dynamic-derivative path).
83    UnsupportedConfiguration { reason: String },
84}
85
86impl_reason_error_boilerplate! {
87    LatentSurvivalError {
88        InvalidFrailty,
89        InvalidDataset,
90        BlockMismatch,
91        NumericalFailure,
92        DerivativeAccuracyUnresolved,
93        UnsupportedConfiguration,
94    }
95}
96
97impl From<crate::block_layout::block_count::BlockCountMismatch> for LatentSurvivalError {
98    fn from(err: crate::block_layout::block_count::BlockCountMismatch) -> LatentSurvivalError {
99        LatentSurvivalError::BlockMismatch {
100            reason: err.message(),
101        }
102    }
103}
104
105impl From<String> for LatentSurvivalError {
106    /// Inbound conversion for the many `Result<_, String>` helpers this
107    /// module still calls into (term-collection design assembly, dense
108    /// chunk conversion, sparse linear constraints). The text is preserved
109    /// verbatim; we only pick a category so external messages flow through
110    /// `?` without per-callsite `.map_err`.
111    fn from(reason: String) -> LatentSurvivalError {
112        LatentSurvivalError::InvalidDataset { reason }
113    }
114}
115
116/// Reserved [`LatentSurvivalTermSpec::event_target`] code marking an
117/// interval-censored row `(L, R]`. Exact-event codes are `>= 1` and right
118/// censoring is `0`; the interval code is the sentinel `u8::MAX` so it never
119/// collides with an exact-event count and the dispatch is an explicit 3-way map
120/// `{0 → RightCensored, INTERVAL → IntervalCensored, k ≥ 1 → ExactEvent}`.
121pub const LATENT_SURVIVAL_EVENT_INTERVAL: u8 = u8::MAX;
122
123#[inline]
124fn latent_survival_event_type_for(code: u8) -> LatentSurvivalEventType {
125    match code {
126        0 => LatentSurvivalEventType::RightCensored,
127        LATENT_SURVIVAL_EVENT_INTERVAL => LatentSurvivalEventType::IntervalCensored,
128        _ => LatentSurvivalEventType::ExactEvent,
129    }
130}
131
132/// Whole-input proof that likelihood row weights are finite and non-negative.
133///
134/// Construct this before evaluating any response or predictor row. Once
135/// constructed, `weight == 0` is the only dormant-row case; every positive
136/// value, including subnormals, remains part of the likelihood.
137#[derive(Clone, Copy)]
138struct ValidatedLikelihoodWeights<'a> {
139    values: &'a Array1<f64>,
140}
141
142impl<'a> ValidatedLikelihoodWeights<'a> {
143    fn new(values: &'a Array1<f64>, context: &str) -> Result<Self, LatentSurvivalError> {
144        if let Some((row, &weight)) = values
145            .iter()
146            .enumerate()
147            .find(|(_, weight)| !weight.is_finite() || **weight < 0.0)
148        {
149            return Err(LatentSurvivalError::InvalidDataset {
150                reason: format!(
151                    "{context} row {} has invalid likelihood weight {weight:?}; expected finite weight >= 0",
152                    row + 1
153                ),
154            });
155        }
156        Ok(Self { values })
157    }
158
159    #[inline]
160    fn at(self, row: usize) -> f64 {
161        self.values[row]
162    }
163}
164
165/// Multiply one already-finite row quantity by a validated positive weight.
166/// Overflow and non-zero underflow are explicit errors rather than silently
167/// changing the row's contribution. Exact zero row quantities remain zero.
168fn checked_weighted_row_value(
169    weight: f64,
170    value: f64,
171    row: usize,
172    quantity: &str,
173) -> Result<f64, String> {
174    assert!(weight.is_finite() && weight > 0.0);
175    if !value.is_finite() {
176        return Err(format!(
177            "latent likelihood row {} has non-finite unweighted {quantity}: {value:?}",
178            row + 1
179        ));
180    }
181    let weighted = weight * value;
182    if !weighted.is_finite() {
183        return Err(format!(
184            "latent likelihood row {} weighted {quantity} is not representable: {weight:?} * {value:?}",
185            row + 1
186        ));
187    }
188    if value != 0.0 && weighted == 0.0 {
189        return Err(format!(
190            "latent likelihood row {} weighted {quantity} underflowed and is not representable: {weight:?} * {value:?}",
191            row + 1
192        ));
193    }
194    Ok(weighted)
195}
196
197fn checked_weighted_row_matrix(
198    weight: f64,
199    values: &Array2<f64>,
200    row: usize,
201    quantity: &str,
202) -> Result<Array2<f64>, String> {
203    let mut weighted = Array2::<f64>::zeros(values.dim());
204    for ((left, right), &value) in values.indexed_iter() {
205        if !value.is_finite() {
206            return Err(format!(
207                "latent likelihood row {} has non-finite unweighted {quantity}[{left},{right}]: {value:?}",
208                row + 1
209            ));
210        }
211        let product = weight * value;
212        if !product.is_finite() || (value != 0.0 && product == 0.0) {
213            return Err(format!(
214                "latent likelihood row {} weighted {quantity}[{left},{right}] is not representable: {weight:?} * {value:?}",
215                row + 1
216            ));
217        }
218        weighted[[left, right]] = product;
219    }
220    Ok(weighted)
221}
222
223fn require_finite_likelihood_scalar(value: f64, quantity: &str) -> Result<f64, String> {
224    if value.is_finite() {
225        Ok(value)
226    } else {
227        Err(format!(
228            "latent likelihood accumulated {quantity} is not representable: {value:?}"
229        ))
230    }
231}
232
233fn require_finite_likelihood_vector(values: &Array1<f64>, quantity: &str) -> Result<(), String> {
234    if let Some((index, &value)) = values
235        .iter()
236        .enumerate()
237        .find(|(_, value)| !value.is_finite())
238    {
239        return Err(format!(
240            "latent likelihood accumulated {quantity}[{index}] is not representable: {value:?}"
241        ));
242    }
243    Ok(())
244}
245
246fn require_finite_likelihood_matrix(values: &Array2<f64>, quantity: &str) -> Result<(), String> {
247    if let Some(((row, col), &value)) = values.indexed_iter().find(|(_, value)| !value.is_finite())
248    {
249        return Err(format!(
250            "latent likelihood accumulated {quantity}[{row},{col}] is not representable: {value:?}"
251        ));
252    }
253    Ok(())
254}
255
256#[derive(Clone)]
257pub struct LatentSurvivalTermSpec {
258    pub age_entry: Array1<f64>,
259    pub age_exit: Array1<f64>,
260    pub event_target: Array1<u8>,
261    pub weights: Array1<f64>,
262    pub derivative_guard: f64,
263    pub time_block: TimeBlockInput,
264    /// Time-basis design evaluated at the interval upper bound `R` (so
265    /// `q_right = design_right · β_time + offset_right`). `None` when the data
266    /// carries no interval-censored rows; the family then reuses the exit design
267    /// for the unused `q_right` channel. When `Some`, rows whose
268    /// `event_target == LATENT_SURVIVAL_EVENT_INTERVAL` contribute the interval
269    /// likelihood `log[S(L) − S(R)]`.
270    pub time_design_right: Option<DesignMatrix>,
271    pub time_offset_right: Option<Array1<f64>>,
272    pub unloaded_mass_entry: Array1<f64>,
273    pub unloaded_mass_exit: Array1<f64>,
274    /// Unloaded (background) cumulative mass at the interval upper bound `R`.
275    /// Length-`n`; entries for non-interval rows are ignored. Empty/`None`
276    /// folds to zero (full-loading interval rows).
277    pub unloaded_mass_right: Array1<f64>,
278    pub unloaded_hazard_exit: Array1<f64>,
279    pub meanspec: TermCollectionSpec,
280    pub mean_offset: Array1<f64>,
281}
282
283pub struct LatentSurvivalTermFitResult {
284    pub fit: UnifiedFitResult,
285    pub design: TermCollectionDesign,
286    pub resolvedspec: TermCollectionSpec,
287    pub latent_sd: f64,
288    /// Per-row residuals of the unpenalized NLL w.r.t. the additive baseline
289    /// time-block offsets `(entry, exit, derivative)` at the converged β̂.
290    /// Contracted against `baseline_offset_theta_partials` by
291    /// `baseline_chain_rule_gradient` to give the exact θ-gradient of the
292    /// profile penalized NLL for the outer baseline-config optimizer.
293    pub baseline_offset_residuals: crate::survival::OffsetChannelResiduals,
294}
295
296#[derive(Clone)]
297pub struct LatentBinaryTermSpec {
298    pub age_entry: Array1<f64>,
299    pub age_exit: Array1<f64>,
300    pub event_target: Array1<u8>,
301    pub weights: Array1<f64>,
302    pub derivative_guard: f64,
303    pub time_block: TimeBlockInput,
304    pub unloaded_mass_entry: Array1<f64>,
305    pub unloaded_mass_exit: Array1<f64>,
306    pub meanspec: TermCollectionSpec,
307    pub mean_offset: Array1<f64>,
308}
309
310pub struct LatentBinaryTermFitResult {
311    pub fit: UnifiedFitResult,
312    pub design: TermCollectionDesign,
313    pub resolvedspec: TermCollectionSpec,
314    /// Per-row residuals of the unpenalized NLL w.r.t. the additive baseline
315    /// time-block offsets `(entry, exit)` at the converged β̂ (the derivative
316    /// channel is identically zero for the binary deployment likelihood).
317    pub baseline_offset_residuals: crate::survival::OffsetChannelResiduals,
318}
319
320#[derive(Clone)]
321struct PreparedLatentTimeBlock {
322    design_entry: Array2<f64>,
323    design_exit: Array2<f64>,
324    design_derivative_exit: Array2<f64>,
325    /// Dense time-basis design at the interval upper bound `R`. Falls back to a
326    /// clone of `design_exit` when the spec supplies no interval design, so the
327    /// `q_right` channel is always well-defined (and unused for non-interval
328    /// rows).
329    design_right: Array2<f64>,
330    linear_constraints: Option<LinearInequalityConstraints>,
331    penalties: Vec<Array2<f64>>,
332    initial_beta: Option<Array1<f64>>,
333}
334
335#[derive(Clone)]
336pub struct LatentSurvivalFamily {
337    pub event_target: Array1<u8>,
338    pub weights: Array1<f64>,
339    pub latent_sd_fixed: Option<f64>,
340    pub hazard_loading: HazardLoading,
341    pub unloaded_mass_entry: Array1<f64>,
342    pub unloaded_mass_exit: Array1<f64>,
343    pub unloaded_hazard_exit: Array1<f64>,
344    pub x_time_entry: Array2<f64>,
345    pub x_time_exit: Array2<f64>,
346    pub x_time_derivative_exit: Array2<f64>,
347    /// Time-basis design evaluated at the interval upper bound `R` (so
348    /// `q_right = x_time_right · β_time + time_offset_right`). For non-interval
349    /// rows this row equals `x_time_exit`'s row (`q_right` is then unused by the
350    /// likelihood), so the matrix always has `n` rows and the same column count
351    /// as the other time designs.
352    pub x_time_right: Array2<f64>,
353    /// Time-block offset at the interval upper bound `R` (length `n`).
354    pub time_offset_right: Array1<f64>,
355    /// Unloaded (background) cumulative mass at the interval upper bound `R`
356    /// (length `n`). Ignored for non-interval rows.
357    pub unloaded_mass_right: Array1<f64>,
358    pub x_mean: DesignMatrix,
359    pub time_linear_constraints: Option<LinearInequalityConstraints>,
360    pub quadctx: Arc<QuadratureContext>,
361}
362
363#[derive(Clone)]
364pub struct LatentBinaryFamily {
365    pub event_target: Array1<u8>,
366    pub weights: Array1<f64>,
367    pub latent_sd: f64,
368    pub hazard_loading: HazardLoading,
369    pub unloaded_mass_entry: Array1<f64>,
370    pub unloaded_mass_exit: Array1<f64>,
371    pub x_time_entry: Array2<f64>,
372    pub x_time_exit: Array2<f64>,
373    pub x_mean: DesignMatrix,
374    pub time_linear_constraints: Option<LinearInequalityConstraints>,
375    pub quadctx: Arc<QuadratureContext>,
376}
377
378impl LatentSurvivalFamily {
379    pub const BLOCK_TIME: usize = 0;
380    pub const BLOCK_MEAN: usize = 1;
381    pub const BLOCK_LOG_SIGMA: usize = 2;
382
383    pub fn parameter_names() -> &'static [&'static str] {
384        &["time_transform", "mean"]
385    }
386
387    pub fn parameter_links() -> &'static [ParameterLink] {
388        &[ParameterLink::Identity, ParameterLink::Identity]
389    }
390
391    pub fn metadata() -> FamilyMetadata {
392        FamilyMetadata {
393            name: "latent_survival",
394            parameternames: Self::parameter_names(),
395            parameter_links: Self::parameter_links(),
396        }
397    }
398
399    fn split_time_eta<'a>(
400        &self,
401        block_states: &'a [ParameterBlockState],
402    ) -> Result<
403        (
404            ArrayView1<'a, f64>,
405            ArrayView1<'a, f64>,
406            ArrayView1<'a, f64>,
407            &'a Array1<f64>,
408        ),
409        LatentSurvivalError,
410    > {
411        let expected_blocks = if self.latent_sd_fixed.is_some() { 2 } else { 3 };
412        crate::block_layout::block_count::validate_block_count::<LatentSurvivalError>(
413            "LatentSurvivalFamily",
414            expected_blocks,
415            block_states.len(),
416        )?;
417        let n = self.event_target.len();
418        let eta_time = &block_states[Self::BLOCK_TIME].eta;
419        let eta_mean = &block_states[Self::BLOCK_MEAN].eta;
420        if eta_time.len() != 3 * n {
421            return Err(LatentSurvivalError::BlockMismatch {
422                reason: format!(
423                    "latent survival time eta length mismatch: got {}, expected {}",
424                    eta_time.len(),
425                    3 * n
426                ),
427            });
428        }
429        if eta_mean.len() != n || self.weights.len() != n {
430            return Err(LatentSurvivalError::BlockMismatch {
431                reason: "latent survival mean eta dimension mismatch".to_string(),
432            });
433        }
434        Ok((
435            eta_time.slice(s![0..n]),
436            eta_time.slice(s![n..2 * n]),
437            eta_time.slice(s![2 * n..3 * n]),
438            eta_mean,
439        ))
440    }
441
442    /// Per-row interval upper-bound time transform `q_right = x_time_right · β_time
443    /// + time_offset_right`. Shares the time-block coefficients with `q_exit`
444    /// (same monotone basis, evaluated at `R`), so it is read off the time
445    /// block's `beta` rather than carried as an extra eta channel. For
446    /// non-interval rows `x_time_right` equals `x_time_exit`, so the (unused)
447    /// value is simply `q_exit`.
448    fn time_q_right(
449        &self,
450        block_states: &[ParameterBlockState],
451    ) -> Result<Array1<f64>, LatentSurvivalError> {
452        let n = self.event_target.len();
453        let beta_time = &block_states[Self::BLOCK_TIME].beta;
454        if self.x_time_right.ncols() != beta_time.len() {
455            return Err(LatentSurvivalError::BlockMismatch {
456                reason: format!(
457                    "latent survival interval right design has {} columns but time beta has {}",
458                    self.x_time_right.ncols(),
459                    beta_time.len()
460                ),
461            });
462        }
463        if self.x_time_right.nrows() != n || self.time_offset_right.len() != n {
464            return Err(LatentSurvivalError::BlockMismatch {
465                reason: "latent survival interval right design/offset row count mismatch"
466                    .to_string(),
467            });
468        }
469        let mut q_right = self.x_time_right.dot(beta_time);
470        q_right += &self.time_offset_right;
471        Ok(q_right)
472    }
473
474    fn latent_sd(&self, block_states: &[ParameterBlockState]) -> Result<f64, LatentSurvivalError> {
475        if let Some(sigma) = self.latent_sd_fixed {
476            return Ok(sigma);
477        }
478        let eta = *block_states
479            .get(Self::BLOCK_LOG_SIGMA)
480            .and_then(|state| state.eta.get(0))
481            .ok_or_else(|| LatentSurvivalError::BlockMismatch {
482                reason: "latent survival learnable log_sigma block is missing".to_string(),
483            })?;
484        let sigma = exp_sigma_from_eta_scalar(eta);
485        if !(sigma.is_finite() && sigma > 0.0) {
486            return Err(LatentSurvivalError::NumericalFailure {
487                reason: format!(
488                    "latent survival learnable sigma became invalid: log_sigma={eta}, sigma={sigma}"
489                ),
490            });
491        }
492        Ok(sigma)
493    }
494}
495
496impl LatentBinaryFamily {
497    pub const BLOCK_TIME: usize = 0;
498    pub const BLOCK_MEAN: usize = 1;
499
500    fn split_time_eta<'a>(
501        &self,
502        block_states: &'a [ParameterBlockState],
503    ) -> Result<(ArrayView1<'a, f64>, ArrayView1<'a, f64>, &'a Array1<f64>), LatentSurvivalError>
504    {
505        crate::block_layout::block_count::validate_block_count::<LatentSurvivalError>(
506            "LatentBinaryFamily",
507            2,
508            block_states.len(),
509        )?;
510        let n = self.event_target.len();
511        let eta_time = &block_states[Self::BLOCK_TIME].eta;
512        let eta_mean = &block_states[Self::BLOCK_MEAN].eta;
513        if eta_time.len() != 3 * n {
514            return Err(LatentSurvivalError::BlockMismatch {
515                reason: format!(
516                    "latent binary time eta length mismatch: got {}, expected {}",
517                    eta_time.len(),
518                    3 * n
519                ),
520            });
521        }
522        if eta_mean.len() != n || self.weights.len() != n {
523            return Err(LatentSurvivalError::BlockMismatch {
524                reason: "latent binary mean eta dimension mismatch".to_string(),
525            });
526        }
527        Ok((
528            eta_time.slice(s![0..n]),
529            eta_time.slice(s![n..2 * n]),
530            eta_mean,
531        ))
532    }
533}
534
535pub fn fixed_latent_hazard_frailty(
536    frailty: &FrailtySpec,
537    context: &str,
538) -> Result<(f64, HazardLoading), String> {
539    fixed_latent_hazard_frailty_typed(frailty, context).map_err(Into::into)
540}
541
542fn fixed_latent_hazard_frailty_typed(
543    frailty: &FrailtySpec,
544    context: &str,
545) -> Result<(f64, HazardLoading), LatentSurvivalError> {
546    frailty
547        .validate()
548        .map_err(|err| LatentSurvivalError::InvalidFrailty {
549            reason: err.to_string(),
550        })?;
551    match frailty {
552        FrailtySpec::HazardMultiplier {
553            scale: FrailtyScale::Fixed { sigma },
554            loading,
555        } => Ok((*sigma, *loading)),
556        FrailtySpec::HazardMultiplier {
557            scale: FrailtyScale::Learned { .. },
558            ..
559        } => Err(LatentSurvivalError::InvalidFrailty {
560            reason: format!("{context} requires a fixed hazard-multiplier sigma"),
561        }),
562        FrailtySpec::GaussianShift { .. } => Err(LatentSurvivalError::InvalidFrailty {
563            reason: format!("{context} requires HazardMultiplier frailty, not GaussianShift"),
564        }),
565        FrailtySpec::None => Err(LatentSurvivalError::InvalidFrailty {
566            reason: format!("{context} requires a fixed HazardMultiplier frailty specification"),
567        }),
568    }
569}
570
571pub fn latent_hazard_loading(
572    frailty: &FrailtySpec,
573    context: &str,
574) -> Result<HazardLoading, String> {
575    latent_hazard_loading_typed(frailty, context).map_err(Into::into)
576}
577
578fn latent_hazard_loading_typed(
579    frailty: &FrailtySpec,
580    context: &str,
581) -> Result<HazardLoading, LatentSurvivalError> {
582    match frailty {
583        FrailtySpec::HazardMultiplier { loading, .. } => Ok(*loading),
584        FrailtySpec::GaussianShift { .. } => Err(LatentSurvivalError::InvalidFrailty {
585            reason: format!("{context} requires HazardMultiplier frailty, not GaussianShift"),
586        }),
587        FrailtySpec::None => Err(LatentSurvivalError::InvalidFrailty {
588            reason: format!("{context} requires a HazardMultiplier frailty specification"),
589        }),
590    }
591}
592
593#[derive(Clone, Copy)]
594struct LatentSurvivalTimeJet {
595    grad_entry: f64,
596    grad_exit: f64,
597    neg_hess_entry: f64,
598    neg_hess_exit: f64,
599}
600
601pub fn fit_latent_survival_terms(
602    data: ArrayView2<'_, f64>,
603    mut spec: LatentSurvivalTermSpec,
604    frailty: FrailtySpec,
605    options: &BlockwiseFitOptions,
606) -> Result<LatentSurvivalTermFitResult, String> {
607    let frailty_scale = validate_latent_survival_inputs(data, &spec, &frailty)?;
608    // Cover the monotone I-spline baseline's unpenalized affine null direction
609    // with a REML-selected function-space shrinkage ridge, so the interval
610    // warm-start surrogates (whose likelihood has no curvature along that
611    // direction) have a unique MAP instead of refusing. No-op on blocks whose
612    // penalties already span their column space. See the installer's doc.
613    install_latent_time_nullspace_shrinkage_penalty(&mut spec.time_block)?;
614    let (latent_sd, learned_initial_sigma) = match frailty_scale {
615        FrailtyScale::Fixed { sigma } => (Some(sigma), None),
616        FrailtyScale::Learned { initial_sigma } => (None, Some(initial_sigma)),
617    };
618    let hazard_loading = latent_hazard_loading(&frailty, "latent-survival")?;
619    let mean_design =
620        build_term_collection_design(data, &spec.meanspec).map_err(|e| e.to_string())?;
621    let mean_offset = mean_design
622        .compose_offset(spec.mean_offset.view(), "latent-survival mean block")
623        .map_err(|e| e.to_string())?;
624    let resolvedspec = freeze_term_collection_from_design(&spec.meanspec, &mean_design)
625        .map_err(|e| e.to_string())?;
626    let time_prepared = prepare_latent_time_block(
627        &spec.time_block,
628        spec.time_design_right.as_ref(),
629        spec.derivative_guard,
630    )?;
631
632    let n = spec.event_target.len();
633    let time_offset_right = match spec.time_offset_right.as_ref() {
634        Some(offset) => {
635            if offset.len() != n {
636                return Err(format!(
637                    "latent survival interval right time offset must have length {n}, got {}",
638                    offset.len()
639                ));
640            }
641            offset.clone()
642        }
643        None => Array1::zeros(n),
644    };
645    let unloaded_mass_right = if spec.unloaded_mass_right.is_empty() {
646        Array1::zeros(n)
647    } else {
648        if spec.unloaded_mass_right.len() != n {
649            return Err(format!(
650                "latent survival interval right unloaded mass must have length {n}, got {}",
651                spec.unloaded_mass_right.len()
652            ));
653        }
654        spec.unloaded_mass_right.clone()
655    };
656
657    let family = LatentSurvivalFamily {
658        event_target: spec.event_target.clone(),
659        weights: spec.weights.clone(),
660        latent_sd_fixed: latent_sd,
661        hazard_loading,
662        unloaded_mass_entry: spec.unloaded_mass_entry.clone(),
663        unloaded_mass_exit: spec.unloaded_mass_exit.clone(),
664        unloaded_hazard_exit: spec.unloaded_hazard_exit.clone(),
665        x_time_entry: time_prepared.design_entry.clone(),
666        x_time_exit: time_prepared.design_exit.clone(),
667        x_time_derivative_exit: time_prepared.design_derivative_exit.clone(),
668        x_time_right: time_prepared.design_right.clone(),
669        time_offset_right,
670        unloaded_mass_right,
671        x_mean: mean_design.design.clone(),
672        time_linear_constraints: time_prepared.linear_constraints.clone(),
673        quadctx: Arc::new(QuadratureContext::new()),
674    };
675
676    let mut blocks = vec![
677        build_time_blockspec(&time_prepared, &spec.time_block),
678        build_mean_blockspec(&mean_design, mean_offset),
679    ];
680    if let Some(initial_sigma) = learned_initial_sigma {
681        blocks.push(build_log_sigma_blockspec(
682            initial_sigma,
683            mean_design.design.nrows(),
684        ));
685    }
686    // Interval warm start (issue #1108). Interval-censored rows contribute the
687    // NON-concave `ℓ = log[S(L) − S(R)]`; the coupled exact-joint inner Newton
688    // diverges from the cold seed (β_time = 1e-4, σ = 0.5) — the failure surfaces
689    // first as `fit_custom_family`'s outer ρ-seed startup validation rejecting
690    // every seed (`solver_started = 0`). We warm-start from a LOG-CONCAVE
691    // surrogate whose β/σ land in the interval basin, threaded via `initial_beta`
692    // (consumed by every inner solve, including each ρ-seed validation fit).
693    //
694    // Surrogate = right-censored at the bracket LOWER bound `L`. Its survival
695    // mass `S(L) = K_{0,B(L)}` is log-concave (PD Hessian) and — crucially —
696    // its time-block design is the SAME fixed-knot I-spline basis the interval
697    // fit uses, which is FULL RANK regardless of how heavily the inspection-grid
698    // `L` values are TIED (the basis columns are functions of the frozen knots,
699    // not of the observed time multiplicities). Unlike an exact-event surrogate
700    // it imposes NO per-row `q̇(L) > 0` hazard-derivative feasibility condition
701    // (which the tied/degenerate cold-start derivative design can violate), so it
702    // is robust where exact-event-at-L is not. The warm σ then refines from the
703    // bracket-width spread inside the (now in-basin) interval fit.
704    //
705    // Failure is NON-SILENT (#1108): a surrogate that errors or returns a
706    // non-finite / all-zero degenerate β is surfaced as a hard error rather than
707    // silently reverting to the diverging cold start (which masked the real
708    // failure across several attempts). Only `initial_beta` is seeded; the EXACT
709    // interval objective/gradient/Hessian are unchanged, so σ̂ is the true MLE.
710    let has_interval_rows = spec
711        .event_target
712        .iter()
713        .any(|&code| code == LATENT_SURVIVAL_EVENT_INTERVAL);
714    if has_interval_rows {
715        let censored_warm_event_target = spec.event_target.mapv(|code| {
716            if code == LATENT_SURVIVAL_EVENT_INTERVAL {
717                0u8
718            } else {
719                code
720            }
721        });
722        let mut warm_family = family.clone();
723        warm_family.event_target = censored_warm_event_target;
724        // Right-censored-at-L ignores the interval upper bound `R`, so the
725        // (unused) `q_right` channel cannot drift the fit; leaving the right
726        // design/mass in place is harmless (no interval row remains to read it).
727        // Fixed-λ surrogate: no outer smoothing loop runs here, and the inner
728        // solve is convergence-gated inside `fit_custom_family_fixed_log_lambdas`,
729        // so the assembled surrogate fit is (vacuously) outer-converged.
730        let warm_fit_result = fit_custom_family_fixed_log_lambdas(
731            &warm_family,
732            &blocks,
733            options,
734            None,
735        );
736        let warm_fit = match warm_fit_result {
737            Ok(fit) => fit,
738            Err(censored_error) => {
739                let has_finite_event_in_censored_surrogate =
740                    warm_family.event_target.iter().any(|&code| code != 0);
741                if has_finite_event_in_censored_surrogate {
742                    return Err(format!(
743                        "latent interval warm start: right-censored-at-L surrogate fit failed \
744                         (so the interval fit cannot be safely warm-started; this surrogate is \
745                         log-concave and should converge — investigate the surrogate, not the \
746                         interval kernel): {censored_error}"
747                    ));
748                }
749
750                // When every observed row is interval-censored, the
751                // right-censored-at-L surrogate contains no failures at all.
752                // Its likelihood is maximized only on the zero-hazard boundary
753                // (β_time -> -∞), so the fixed-λ Newton solve is correctly
754                // allowed to refuse it even though the objective is concave.
755                // Use the finite lower-endpoint event surrogate solely to obtain
756                // an interior β/σ seed for the exact interval likelihood below;
757                // no fitted surrogate likelihood or derivative is reused.
758                let lower_event_warm_target = spec.event_target.mapv(|code| {
759                    if code == LATENT_SURVIVAL_EVENT_INTERVAL {
760                        1u8
761                    } else {
762                        code
763                    }
764                });
765                let mut event_warm_family = family.clone();
766                event_warm_family.event_target = lower_event_warm_target;
767                fit_custom_family_fixed_log_lambdas(
768                    &event_warm_family,
769                    &blocks,
770                    options,
771                    None,
772                )
773                .map_err(|event_error| {
774                    format!(
775                        "latent interval warm start failed: the right-censored-at-L surrogate \
776                         has no finite failures and refused its boundary optimum ({censored_error}); \
777                         the finite lower-endpoint event surrogate also failed ({event_error})"
778                    )
779                })?
780            }
781        };
782        let warm_beta_usable = warm_fit
783            .block_states
784            .iter()
785            .any(|s| s.beta.iter().all(|v| v.is_finite()) && s.beta.iter().any(|&v| v != 0.0));
786        if !warm_beta_usable {
787            return Err(
788                "latent interval warm start: right-censored-at-L surrogate returned a \
789                 degenerate (non-finite or all-zero) β across every block; the warm start \
790                 cannot seed the interval fit. This indicates the surrogate's time-block \
791                 design is rank-deficient or the inner solve stalled at the seed — \
792                 investigate the surrogate before retrying the interval fit."
793                    .to_string(),
794            );
795        }
796        for (block, state) in blocks.iter_mut().zip(warm_fit.block_states.iter()) {
797            if state.beta.iter().all(|v| v.is_finite()) {
798                block.initial_beta = Some(state.beta.clone());
799            }
800        }
801    }
802    let fit = fit_custom_family(&family, &blocks, options).map_err(|e| e.to_string())?;
803    let latent_sd = family.latent_sd(&fit.block_states)?;
804    let baseline_offset_residuals = family.offset_channel_residuals(&fit.block_states)?;
805    Ok(LatentSurvivalTermFitResult {
806        fit,
807        design: mean_design,
808        resolvedspec,
809        latent_sd,
810        baseline_offset_residuals,
811    })
812}
813
814pub fn fit_latent_binary_terms(
815    data: ArrayView2<'_, f64>,
816    spec: LatentBinaryTermSpec,
817    frailty: FrailtySpec,
818    options: &BlockwiseFitOptions,
819) -> Result<LatentBinaryTermFitResult, String> {
820    let latent_sd = validate_latent_binary_inputs(data, &spec, &frailty)?;
821    let (_, hazard_loading) = fixed_latent_hazard_frailty(&frailty, "latent-binary")?;
822    let mean_design =
823        build_term_collection_design(data, &spec.meanspec).map_err(|e| e.to_string())?;
824    let mean_offset = mean_design
825        .compose_offset(spec.mean_offset.view(), "latent-binary mean block")
826        .map_err(|e| e.to_string())?;
827    let resolvedspec = freeze_term_collection_from_design(&spec.meanspec, &mean_design)
828        .map_err(|e| e.to_string())?;
829    let time_prepared = prepare_latent_time_block(&spec.time_block, None, spec.derivative_guard)?;
830
831    let family = LatentBinaryFamily {
832        event_target: spec.event_target.clone(),
833        weights: spec.weights.clone(),
834        latent_sd,
835        hazard_loading,
836        unloaded_mass_entry: spec.unloaded_mass_entry.clone(),
837        unloaded_mass_exit: spec.unloaded_mass_exit.clone(),
838        x_time_entry: time_prepared.design_entry.clone(),
839        x_time_exit: time_prepared.design_exit.clone(),
840        x_mean: mean_design.design.clone(),
841        time_linear_constraints: time_prepared.linear_constraints.clone(),
842        quadctx: Arc::new(QuadratureContext::new()),
843    };
844
845    let blocks = vec![
846        build_time_blockspec(&time_prepared, &spec.time_block),
847        build_mean_blockspec(&mean_design, mean_offset),
848    ];
849    let fit = fit_custom_family(&family, &blocks, options).map_err(|e| e.to_string())?;
850    let baseline_offset_residuals = family.offset_channel_residuals(&fit.block_states)?;
851    Ok(LatentBinaryTermFitResult {
852        fit,
853        design: mean_design,
854        resolvedspec,
855        baseline_offset_residuals,
856    })
857}
858
859/// Latent-survival adapter for the shared [`LatentIntervalModel`] driver.
860///
861/// Survival permits [`FrailtyScale::Learned`] and carries the
862/// per-row unloaded baseline hazard at exit (which feeds the exact-event
863/// loaded/unloaded split); everything else is validated by the shared engine.
864struct LatentSurvivalModel;
865
866impl LatentIntervalModel for LatentSurvivalModel {
867    fn context() -> &'static str {
868        "latent-survival"
869    }
870
871    fn allows_interval() -> bool {
872        true
873    }
874
875    fn frailty_policy(
876        frailty: &FrailtySpec,
877    ) -> Result<LatentFrailtyResolution, LatentSurvivalError> {
878        frailty
879            .validate()
880            .map_err(|err| LatentSurvivalError::InvalidFrailty {
881                reason: err.to_string(),
882            })?;
883        match frailty {
884            FrailtySpec::HazardMultiplier {
885                scale,
886                loading,
887            } => Ok(LatentFrailtyResolution {
888                scale: *scale,
889                loading: *loading,
890            }),
891            FrailtySpec::GaussianShift { .. } => Err(LatentSurvivalError::InvalidFrailty {
892                reason: "latent-survival requires HazardMultiplier frailty, not GaussianShift"
893                    .to_string(),
894            }),
895            FrailtySpec::None => Err(LatentSurvivalError::InvalidFrailty {
896                reason: "latent-survival requires a HazardMultiplier frailty specification"
897                    .to_string(),
898            }),
899        }
900    }
901}
902
903fn validate_latent_survival_inputs(
904    data: ArrayView2<'_, f64>,
905    spec: &LatentSurvivalTermSpec,
906    frailty: &FrailtySpec,
907) -> Result<FrailtyScale, LatentSurvivalError> {
908    let row = LatentIntervalRowView {
909        frailty,
910        age_entry: &spec.age_entry,
911        age_exit: &spec.age_exit,
912        event_target: &spec.event_target,
913        weights: &spec.weights,
914        unloaded_mass_entry: &spec.unloaded_mass_entry,
915        unloaded_mass_exit: &spec.unloaded_mass_exit,
916        unloaded_hazard_exit: Some(&spec.unloaded_hazard_exit),
917        mean_offset: &spec.mean_offset,
918        derivative_guard: spec.derivative_guard,
919        time_block: &spec.time_block,
920    };
921    validate_latent_interval_inputs::<LatentSurvivalModel>(data, &row)
922}
923
924pub(crate) fn validate_unloaded_components_for_loading(
925    context: &str,
926    row_index: usize,
927    loading: HazardLoading,
928    unloaded_entry: f64,
929    unloaded_exit: f64,
930    unloaded_hazard: Option<f64>,
931) -> Result<(), LatentSurvivalError> {
932    match loading {
933        HazardLoading::Full => {
934            if unloaded_entry != 0.0
935                || unloaded_exit != 0.0
936                || unloaded_hazard.is_some_and(|hazard| hazard != 0.0)
937            {
938                return Err(LatentSurvivalError::InvalidDataset {
939                    reason: format!(
940                        "{context} row {} uses full hazard loading, so unloaded components must be exactly zero; got entry_mass={}, exit_mass={}, exit_hazard={}",
941                        row_index + 1,
942                        unloaded_entry,
943                        unloaded_exit,
944                        unloaded_hazard.unwrap_or(0.0)
945                    ),
946                });
947            }
948        }
949        HazardLoading::LoadedVsUnloaded => {}
950    }
951    Ok(())
952}
953
954/// Latent-binary adapter for the shared [`LatentIntervalModel`] driver.
955///
956/// Binary never evaluates an exact event, so it requires a finite *fixed*
957/// latent sigma (via [`fixed_latent_hazard_frailty_typed`]) and carries no
958/// per-row unloaded hazard; every other invariant is validated by the shared
959/// engine.
960struct LatentBinaryModel;
961
962impl LatentIntervalModel for LatentBinaryModel {
963    fn context() -> &'static str {
964        "latent-binary"
965    }
966
967    fn frailty_policy(
968        frailty: &FrailtySpec,
969    ) -> Result<LatentFrailtyResolution, LatentSurvivalError> {
970        let (sigma, loading) = fixed_latent_hazard_frailty_typed(frailty, "latent-binary")?;
971        Ok(LatentFrailtyResolution {
972            scale: FrailtyScale::Fixed { sigma },
973            loading,
974        })
975    }
976}
977
978fn validate_latent_binary_inputs(
979    data: ArrayView2<'_, f64>,
980    spec: &LatentBinaryTermSpec,
981    frailty: &FrailtySpec,
982) -> Result<f64, LatentSurvivalError> {
983    let row = LatentIntervalRowView {
984        frailty,
985        age_entry: &spec.age_entry,
986        age_exit: &spec.age_exit,
987        event_target: &spec.event_target,
988        weights: &spec.weights,
989        unloaded_mass_entry: &spec.unloaded_mass_entry,
990        unloaded_mass_exit: &spec.unloaded_mass_exit,
991        unloaded_hazard_exit: None,
992        mean_offset: &spec.mean_offset,
993        derivative_guard: spec.derivative_guard,
994        time_block: &spec.time_block,
995    };
996    match validate_latent_interval_inputs::<LatentBinaryModel>(data, &row)? {
997        FrailtyScale::Fixed { sigma } => Ok(sigma),
998        FrailtyScale::Learned { .. } => Err(LatentSurvivalError::InvalidFrailty {
999            reason: "latent-binary requires a fixed latent sigma".to_string(),
1000        }),
1001    }
1002}
1003
1004fn prepare_latent_time_block(
1005    input: &TimeBlockInput,
1006    design_right: Option<&DesignMatrix>,
1007    derivative_guard: f64,
1008) -> Result<PreparedLatentTimeBlock, LatentSurvivalError> {
1009    if !input.time_monotonicity.is_coordinate_cone() {
1010        return Err(LatentSurvivalError::UnsupportedConfiguration {
1011            reason: format!(
1012                "latent survival requires a coordinate-cone monotonicity strategy; got {:?}",
1013                input.time_monotonicity
1014            ),
1015        });
1016    }
1017    let design_entry = input
1018        .design_entry
1019        .try_to_dense_by_chunks("latent survival entry time design")?;
1020    let design_exit = input
1021        .design_exit
1022        .try_to_dense_by_chunks("latent survival exit time design")?;
1023    let design_derivative_exit = input
1024        .design_derivative_exit
1025        .try_to_dense_by_chunks("latent survival derivative time design")?;
1026    // The interval upper-bound design shares the time-block coefficients with
1027    // the exit design; when the data has no interval rows we reuse the exit
1028    // design so `q_right` stays well-defined (its likelihood contribution is
1029    // gated off for non-interval rows). When present it must match the exit
1030    // design's shape (same basis, evaluated at R).
1031    let design_right = match design_right {
1032        Some(matrix) => {
1033            let dense =
1034                matrix.try_to_dense_by_chunks("latent survival interval right time design")?;
1035            if dense.nrows() != design_exit.nrows() || dense.ncols() != design_exit.ncols() {
1036                return Err(LatentSurvivalError::InvalidDataset {
1037                    reason: format!(
1038                        "latent survival interval right time design must match exit design shape \
1039                         {:?}, got {:?}",
1040                        design_exit.dim(),
1041                        dense.dim()
1042                    ),
1043                });
1044            }
1045            dense
1046        }
1047        None => design_exit.clone(),
1048    };
1049    let linear_constraints = structural_time_coefficient_constraints(
1050        &input.design_derivative_exit,
1051        &input.derivative_offset_exit,
1052        derivative_guard,
1053    )?;
1054    let initial_beta = match linear_constraints.as_ref() {
1055        // `project_onto_linear_constraints` validates that any supplied
1056        // `initial_beta` matches `design_exit.ncols()`; surface a mismatch as a
1057        // structured error rather than letting an ndarray broadcast panic
1058        // (issue #374).
1059        Some(constraints) => Some(project_onto_linear_constraints(
1060            design_exit.ncols(),
1061            constraints,
1062            input.initial_beta.as_ref(),
1063        )?),
1064        None => None,
1065    };
1066    Ok(PreparedLatentTimeBlock {
1067        design_entry,
1068        design_exit,
1069        design_derivative_exit,
1070        design_right,
1071        linear_constraints,
1072        penalties: input.penalties.clone(),
1073        initial_beta,
1074    })
1075}
1076
1077fn stack_rows(blocks: &[&Array2<f64>]) -> Array2<f64> {
1078    let ncols = blocks.first().map_or(0, |m| m.ncols());
1079    let nrows = blocks.iter().map(|m| m.nrows()).sum();
1080    let mut out = Array2::<f64>::zeros((nrows, ncols));
1081    let mut row = 0usize;
1082    for block in blocks {
1083        let end = row + block.nrows();
1084        out.slice_mut(s![row..end, ..]).assign(block);
1085        row = end;
1086    }
1087    out
1088}
1089
1090fn build_time_blockspec(
1091    prepared: &PreparedLatentTimeBlock,
1092    input: &TimeBlockInput,
1093) -> ParameterBlockSpec {
1094    // The solver produces a `3·n`-long time `eta` (the `[entry; exit; deriv]`
1095    // channel stack that `split_time_eta` slices). That stacked operator is
1096    // the eta-producing matrix and so belongs in `stacked_design`, paired with
1097    // the matching `3·n`-row stacked offset. The audit / shape-policy invariant
1098    // `design.nrows() == n_obs` is satisfied by exposing the single-channel
1099    // n-row exit design as `design`; the audit never inspects `stacked_design`.
1100    //
1101    // This mirrors the survival location-scale fix for the same #326 class
1102    // (`survival_location_scale.rs`): the previous code put the `3·n`-row
1103    // stack in `design`, which tripped the flat identifiability audit's
1104    // row-equality invariant (`block 1 (mean) has n rows, expected 3n`).
1105    let stacked_design = stack_rows(&[
1106        &prepared.design_entry,
1107        &prepared.design_exit,
1108        &prepared.design_derivative_exit,
1109    ]);
1110    let stacked_offset = gam_linalg::utils::stack_offsets(&[
1111        &input.offset_entry,
1112        &input.offset_exit,
1113        &input.derivative_offset_exit,
1114    ]);
1115    ParameterBlockSpec {
1116        name: "time_transform".to_string(),
1117        design: DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(
1118            prepared.design_exit.clone(),
1119        ))),
1120        offset: input.offset_exit.clone(),
1121        penalties: prepared
1122            .penalties
1123            .iter()
1124            .cloned()
1125            .map(PenaltyMatrix::Dense)
1126            .collect(),
1127        nullspace_dims: input.nullspace_dims.clone(),
1128        initial_log_lambdas: input
1129            .initial_log_lambdas
1130            .clone()
1131            .unwrap_or_else(|| Array1::zeros(prepared.penalties.len())),
1132        initial_beta: prepared.initial_beta.clone(),
1133        // Canonical-gauge ownership for the latent-survival joint design: the
1134        // time-transform block carries the structural monotone baseline that
1135        // anchors the parameterisation, so it owns any shared constant
1136        // direction (strictly higher than `mean`/`log_sigma` at 100). This
1137        // matches the survival location-scale gauge contract (time highest).
1138        gauge_priority: 200,
1139        jacobian_callback: None,
1140        stacked_design: Some(DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(
1141            stacked_design,
1142        )))),
1143        stacked_offset: Some(stacked_offset),
1144    }
1145}
1146
1147fn build_mean_blockspec(design: &TermCollectionDesign, offset: Array1<f64>) -> ParameterBlockSpec {
1148    ParameterBlockSpec {
1149        name: "mean".to_string(),
1150        design: design.design.clone(),
1151        offset,
1152        penalties: design.penalties_as_penalty_matrix(),
1153        nullspace_dims: design.nullspace_dims.clone(),
1154        initial_log_lambdas: Array1::zeros(design.penalties.len()),
1155        initial_beta: None,
1156        // Strictly below `time_transform` (200) so any constant direction
1157        // shared between the monotone time baseline and the mean intercept is
1158        // deterministically attributable to the lower-priority `mean` block by
1159        // the canonical-gauge RRQR (the descending-priority contract used by
1160        // survival location-scale; #366/#556 gauge story).
1161        gauge_priority: 150,
1162        jacobian_callback: None,
1163        stacked_design: None,
1164        stacked_offset: None,
1165    }
1166}
1167
1168fn build_log_sigma_blockspec(initial_sigma: f64, n_obs: usize) -> ParameterBlockSpec {
1169    ParameterBlockSpec {
1170        name: "log_sigma".to_string(),
1171        // The frailty/dispersion scale is a single GLOBAL hyperparameter (one free
1172        // coefficient), but the identifiability audit — and the canonical-row
1173        // architecture generally — require every block's effective Jacobian to carry
1174        // `n_obs` rows. A global scalar is realised the same way the survival
1175        // location-scale `log_sigma` block is (see `BinomialLocationScaleFamily`): an
1176        // `n_obs × 1` constant column of ones, so `eta = design · β` is the same scalar
1177        // broadcast to every observation. This keeps it a single free parameter while
1178        // exposing the `n_obs`-row shape the audit checks, and `latent_sd` reads
1179        // `eta[0]` — identical across rows by construction.
1180        design: DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(Array2::from_elem(
1181            (n_obs, 1),
1182            1.0,
1183        )))),
1184        offset: Array1::zeros(n_obs),
1185        penalties: vec![],
1186        nullspace_dims: vec![],
1187        initial_log_lambdas: Array1::zeros(0),
1188        initial_beta: Some(Array1::from_elem(
1189            1,
1190            exp_sigma_eta_for_sigma_scalar(initial_sigma),
1191        )),
1192        // Lowest of the three (time=200, mean=150): the learnable-scale channel
1193        // yields any shared constant to the location blocks.
1194        gauge_priority: 120,
1195        jacobian_callback: None,
1196        stacked_design: None,
1197        stacked_offset: None,
1198    }
1199}
1200
1201/// Install the Marra & Wood function-space null-space shrinkage penalty (the
1202/// "double penalty") on the latent-survival time block, so the monotone I-spline
1203/// baseline's unpenalized affine null direction carries its own REML-selected
1204/// curvature instead of being left flat.
1205///
1206/// The I-spline value-space penalty `S_I = Lᵀ S_B[1:,1:] L` (see the `ISpline`
1207/// arm of [`crate::survival::construction::build_survival_time_basis`])
1208/// deliberately leaves the affine trend `d(log Λ)/d(log t)` in its 1-D null space
1209/// (`constant γ ↦ affine log Λ ↦ D₂ = 0`, gam#1076): on an event-rich fit the
1210/// likelihood identifies that trend, so penalizing it would only bias the
1211/// baseline. But the interval-censored warm-start evaluates the same direction
1212/// where the surrogate likelihood has no curvature there — the
1213/// right-censored-at-`L` surrogate has no finite failures at all, and the
1214/// finite-lower-endpoint surrogate's grid-tied events leave `Jᵀ W J` near-flat
1215/// along the affine mode — so the MAP is non-unique and the fit refuses
1216/// (`gam_identifiability::check_map_uniqueness`: the affine null direction of
1217/// `Jᵀ W J` carries `nᵀ S n < tol`, dominant block `time_transform`).
1218///
1219/// This is the treatment the survival marginal-slope time block already installs
1220/// (`install_time_nullspace_shrinkage_penalty`): the shared
1221/// [`gam_terms::basis::function_space_nullspace_shrinkage`] builds the
1222/// function-metric ridge `G Z (ZᵀGZ)⁻¹ ZᵀG` (`Z` spanning the primary penalty's
1223/// null space, `G` the endpoint-averaged basis Gram), whose range is exactly that
1224/// null direction, so `nᵀ S n > 0` there. It is a *second* REML coordinate:
1225/// where the affine trend is identified by the data REML drives its λ toward zero
1226/// (no bias — the gam#1076 behaviour is preserved), and where it is not (the
1227/// interval warm-start) the ridge supplies the curvature that makes the MAP
1228/// unique. Returns `Ok(false)` when the block carries no penalty with a null
1229/// space (nothing to cover).
1230fn install_latent_time_nullspace_shrinkage_penalty(
1231    time_block: &mut TimeBlockInput,
1232) -> Result<bool, String> {
1233    let p = time_block.design_exit.ncols();
1234    if p == 0 || time_block.penalties.is_empty() {
1235        return Ok(false);
1236    }
1237    if time_block.nullspace_dims.len() != time_block.penalties.len() {
1238        return Err(format!(
1239            "latent-survival time_block nullspace_dims length {} does not match penalties {}",
1240            time_block.nullspace_dims.len(),
1241            time_block.penalties.len(),
1242        ));
1243    }
1244
1245    // Aggregate the existing wiggliness penalties, each normalized by its own
1246    // max-abs scale so the shared null space (not a scale-weighted average) is
1247    // what the ridge covers. Mirrors the marginal-slope installer.
1248    let mut aggregate = Array2::<f64>::zeros((p, p));
1249    for (idx, penalty) in time_block.penalties.iter().enumerate() {
1250        if penalty.nrows() != p || penalty.ncols() != p {
1251            return Err(format!(
1252                "latent-survival time_block penalty {idx} must be {p}x{p}, got {}x{}",
1253                penalty.nrows(),
1254                penalty.ncols(),
1255            ));
1256        }
1257        let scale = penalty
1258            .iter()
1259            .try_fold(0.0_f64, |acc, &value| {
1260                value.is_finite().then_some(acc.max(value.abs()))
1261            })
1262            .ok_or_else(|| {
1263                format!("latent-survival time_block penalty {idx} contains non-finite values")
1264            })?;
1265        if scale > 0.0 {
1266            ndarray::Zip::from(&mut aggregate)
1267                .and(penalty)
1268                .for_each(|agg, &value| *agg += value / scale);
1269        }
1270    }
1271
1272    // Endpoint-averaged function metric: entry and exit are the two value
1273    // channels through which the baseline enters the survival likelihood, so
1274    // averaging their Grams makes the ridge invariant to whole-sample
1275    // replication and covariant under any coefficient-chart change
1276    // (`G -> MᵀGM`), exactly like the marginal-slope time-block ridge.
1277    if time_block.design_entry.ncols() != p {
1278        return Err(format!(
1279            "latent-survival time_block entry design has {} columns, expected {p}",
1280            time_block.design_entry.ncols(),
1281        ));
1282    }
1283    let entry_mass = time_block.design_entry.nrows();
1284    let exit_mass = time_block.design_exit.nrows();
1285    let total_mass = entry_mass.saturating_add(exit_mass);
1286    if total_mass == 0 {
1287        return Err(
1288            "latent-survival time_block cannot define a function metric from zero endpoint rows"
1289                .to_string(),
1290        );
1291    }
1292    let entry_gram = time_block
1293        .design_entry
1294        .diag_xtw_x(&Array1::ones(entry_mass))
1295        .map_err(|err| format!("latent-survival time_block entry function Gram: {err}"))?;
1296    let exit_gram = time_block
1297        .design_exit
1298        .diag_xtw_x(&Array1::ones(exit_mass))
1299        .map_err(|err| format!("latent-survival time_block exit function Gram: {err}"))?;
1300    let function_gram = (entry_gram + exit_gram).mapv(|value| value / total_mass as f64);
1301
1302    let Some(shrinkage) =
1303        gam_terms::basis::function_space_nullspace_shrinkage(&aggregate, &function_gram)
1304            .map_err(|err| format!("latent-survival time_block nullspace shrinkage: {err}"))?
1305    else {
1306        return Ok(false);
1307    };
1308    if shrinkage.nrows() != p || shrinkage.ncols() != p {
1309        return Err(format!(
1310            "latent-survival time_block nullspace shrinkage penalty must be {p}x{p}, got {}x{}",
1311            shrinkage.nrows(),
1312            shrinkage.ncols(),
1313        ));
1314    }
1315    time_block.penalties.push(shrinkage);
1316    time_block.nullspace_dims.push(0);
1317    // Keep the seed ρ vector consistent with the widened penalty list. The
1318    // latent block builder reads `initial_log_lambdas` directly (unlike the
1319    // marginal-slope path, which rebuilds its seed from the penalty list), so a
1320    // `Some` seed must gain a coordinate for the new null-space penalty; seed it
1321    // at the same smoothing scale as the existing time penalties.
1322    if let Some(seeds) = time_block.initial_log_lambdas.as_mut() {
1323        let seed = seeds.iter().copied().last().unwrap_or(0.0);
1324        let mut widened = seeds.to_vec();
1325        widened.push(seed);
1326        *seeds = Array1::from_vec(widened);
1327    }
1328    Ok(true)
1329}
1330
1331const LATENT_SURVIVAL_PRIMARY_Q_ENTRY: usize = 0;
1332const LATENT_SURVIVAL_PRIMARY_Q_EXIT: usize = 1;
1333const LATENT_SURVIVAL_PRIMARY_QDOT_EXIT: usize = 2;
1334// Interval-censored right boundary R: q_right = log B(R) shares the time-block
1335// coefficients with q_exit (same monotone transform, different time point), so
1336// it is a fourth linear functional of the time block, NOT an independent eta
1337// channel. It sits before `mu`/`log_sigma` so the "trailing optional log_sigma"
1338// invariant used by `active_primary` (= `LATENT_SURVIVAL_PRIMARY_LOG_SIGMA`)
1339// keeps q_right always active.
1340const LATENT_SURVIVAL_PRIMARY_Q_RIGHT: usize = 3;
1341const LATENT_SURVIVAL_PRIMARY_MU: usize = 4;
1342const LATENT_SURVIVAL_PRIMARY_LOG_SIGMA: usize = 5;
1343const LATENT_SURVIVAL_PRIMARY_DIM: usize = 6;
1344
1345/// Certified derivative tower of `f(x) = log(1 - exp(x))` for `x < 0`.
1346///
1347/// The value uses `log1mexp`; derivative magnitudes are assembled in log space:
1348///
1349/// ```text
1350/// |f'|    = r / s
1351/// |f''|   = r / s²
1352/// |f'''|  = r(1 + r) / s³
1353/// |f''''| = r(1 + 4r + r²) / s⁴,
1354/// r = exp(x), s = 1 - r.
1355/// ```
1356///
1357/// This never forms `1/s^k`. If a true derivative magnitude cannot be
1358/// represented by `f64`, the routine returns a typed numerical refusal instead
1359/// of publishing an infinite jet. Only the derivative order consumed by the
1360/// selected jet backend is certified. There is no clamp or magnitude cutoff.
1361fn latent_unary_derivatives_log1mexp_negative(
1362    x: f64,
1363    derivative_order: usize,
1364    context: &str,
1365) -> Result<[f64; 5], LatentSurvivalError> {
1366    assert!(derivative_order <= 4);
1367    if !(x.is_finite() && x < 0.0) {
1368        return Err(LatentSurvivalError::NumericalFailure {
1369            reason: format!("{context} requires a finite negative log-boundary gap, got {x:?}"),
1370        });
1371    }
1372    let value = log1mexp_positive(-x);
1373    let exp_x = x.exp();
1374    let log_derivative_magnitudes = [
1375        x - value,
1376        x - 2.0 * value,
1377        x + exp_x.ln_1p() - 3.0 * value,
1378        x + (exp_x * (4.0 + exp_x)).ln_1p() - 4.0 * value,
1379    ];
1380    let mut derivatives = [value, 0.0, 0.0, 0.0, 0.0];
1381    for (offset, log_magnitude) in log_derivative_magnitudes
1382        .into_iter()
1383        .take(derivative_order)
1384        .enumerate()
1385    {
1386        let order = offset + 1;
1387        let magnitude = log_magnitude.exp();
1388        if !magnitude.is_finite() {
1389            return Err(LatentSurvivalError::NumericalFailure {
1390                reason: format!(
1391                    "{context} derivative order {order} is not representable at \
1392                     log-boundary gap {x:?} (log magnitude {log_magnitude:?})"
1393                ),
1394            });
1395        }
1396        derivatives[order] = -magnitude;
1397    }
1398    Ok(derivatives)
1399}
1400
1401/// Stable jet for `log(exp(log_left + c_left) - exp(log_right + c_right))`.
1402///
1403/// Positivity implies the log-domain gap
1404/// `delta = (log_right + c_right) - (log_left + c_left)` is negative, so
1405///
1406/// ```text
1407/// log(A - B) = log(A) + log(1 - exp(delta)).
1408/// ```
1409///
1410/// Absolute mass never leaves log space. The caller supplies a complete
1411/// finiteness predicate for its concrete jet representation so an `Ok` result
1412/// certifies every carried channel, including contracted third/fourth parts.
1413fn latent_survival_positive_log_difference_jet<J: JetField>(
1414    log_left: &J,
1415    log_coefficient_left: f64,
1416    log_right: &J,
1417    log_coefficient_right: f64,
1418    derivative_order: usize,
1419    context: &str,
1420    all_channels_finite: impl Fn(&J) -> bool,
1421) -> Result<J, LatentSurvivalError> {
1422    let weighted_left = log_left.add(&log_left.constant_like(log_coefficient_left));
1423    let weighted_right = log_right.add(&log_right.constant_like(log_coefficient_right));
1424    let delta = weighted_right.sub(&weighted_left);
1425    let delta_value = delta.value();
1426    if !(delta_value.is_finite() && delta_value < 0.0) {
1427        return Err(LatentSurvivalError::NumericalFailure {
1428            reason: format!(
1429                "{context} must be a positive survival-mass difference: \
1430                 log(c_L*K0(M_L))={:?}, log(c_R*K0(M_R))={:?}; \
1431                 require M_L < M_R (i.e. L < R)",
1432                weighted_left.value(),
1433                weighted_right.value(),
1434            ),
1435        });
1436    }
1437    let derivatives =
1438        latent_unary_derivatives_log1mexp_negative(delta_value, derivative_order, context)?;
1439    let out = weighted_left.add(&delta.compose_unary(derivatives));
1440    if !all_channels_finite(&out) {
1441        return Err(LatentSurvivalError::NumericalFailure {
1442            reason: format!(
1443                "{context} derivative jet is not representable at log-boundary gap {delta_value:?}"
1444            ),
1445        });
1446    }
1447    Ok(out)
1448}
1449
1450#[derive(Clone, Copy, Debug)]
1451struct LatentKernelPrimaryTerm {
1452    coeff: f64,
1453    q_exp: usize,
1454    qdot_power: usize,
1455    tau_exp: usize,
1456    k: usize,
1457}
1458
1459/// One signed magnitude kept in logarithmic coordinates.
1460///
1461/// The latent-kernel recurrence naturally produces derivatives as signed
1462/// log-sums.  Keeping that representation through the moment-to-cumulant
1463/// conversion is essential: materialising `S_ab / S` and `S_a / S` separately
1464/// before computing `S_ab / S - (S_a / S)(S_b / S)` rounds both large moments
1465/// and destroys the small curvature left by their cancellation.
1466#[derive(Clone, Copy, Debug)]
1467struct LatentSignedLog {
1468    log_abs: f64,
1469    sign: f64,
1470}
1471
1472impl LatentSignedLog {
1473    const ZERO: Self = Self {
1474        log_abs: f64::NEG_INFINITY,
1475        sign: 0.0,
1476    };
1477    const ONE: Self = Self {
1478        log_abs: 0.0,
1479        sign: 1.0,
1480    };
1481}
1482
1483// A pointed cumulant over `r` slots consists of one leading moment and every
1484// proper pointed block of sizes `1..r-1`. Multiplication by the complementary
1485// rounded moment can at most double an exact expansion's length:
1486//
1487// C₁ = 1
1488// C₂ = 1 + 1·2C₁ = 3
1489// C₃ = 1 + 1·2C₁ + 2·2C₂ = 15
1490// C₄ = 1 + 1·2C₁ + 3·2C₂ + 3·2C₃ = 111.
1491//
1492// Certifying the final rounding subtracts one candidate binary64, requiring
1493// exactly one additional component. These are structural support bounds, not
1494// tunable numerical capacities.
1495const LATENT_EXACT_EXPANSION_ORDER1: usize = 1;
1496const LATENT_EXACT_EXPANSION_ORDER2: usize = 1 + 2 * LATENT_EXACT_EXPANSION_ORDER1;
1497const LATENT_EXACT_EXPANSION_ORDER3: usize =
1498    1 + 2 * LATENT_EXACT_EXPANSION_ORDER1 + 2 * 2 * LATENT_EXACT_EXPANSION_ORDER2;
1499const LATENT_EXACT_EXPANSION_ORDER4: usize = 1
1500    + 2 * LATENT_EXACT_EXPANSION_ORDER1
1501    + 3 * 2 * LATENT_EXACT_EXPANSION_ORDER2
1502    + 3 * 2 * LATENT_EXACT_EXPANSION_ORDER3;
1503const LATENT_EXACT_EXPANSION_CAPACITY: usize = LATENT_EXACT_EXPANSION_ORDER4 + 1;
1504
1505/// The one refusal that replaces the pre-gam#2714 product refusal: not "a
1506/// monomial underflowed" (which is a fact about binary64 and says nothing about
1507/// the derivative) but "what binary64 could not carry is big enough to decide
1508/// the answer", which is the statement a certification is entitled to make.
1509const LATENT_UNREPRESENTABLE_MASS_REACHES_CELL: &str =
1510    "the product mass binary64 cannot represent reaches the cumulant's rounding cell";
1511const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER1 == 1);
1512const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER2 == 3);
1513const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER3 == 15);
1514const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER4 == 111);
1515const _: () = assert!(LATENT_EXACT_EXPANSION_CAPACITY == 112);
1516
1517/// Fixed, increasing-magnitude floating-point expansion.
1518///
1519/// Each component is an exact binary64 and their real sum is the represented
1520/// value TO WITHIN [`Self::unrepresentable_mass`]. `TwoSum` and FMA
1521/// `TwoProduct` retain every arithmetic residual the format can hold, so the
1522/// pointed cumulant polynomial is evaluated exactly over its rounded binary64
1523/// moments wherever binary64 is capable of it. The sole rounding occurs in
1524/// [`Self::certified_round`], which proves its cell against the components AND
1525/// that mass.
1526#[derive(Clone, Copy)]
1527struct LatentExactExpansion {
1528    components: [f64; LATENT_EXACT_EXPANSION_CAPACITY],
1529    len: usize,
1530    /// Outward upper bound on `|exact value − Σ components|` (gam#2714).
1531    ///
1532    /// `TwoSum` is exact for every finite pair, and `TwoProduct` is exact
1533    /// whenever the product stays at or above `2^-970`, so this is `0.0` on
1534    /// every expansion the recurrence could already build. It becomes nonzero
1535    /// exactly where an FMA residual needs bits under `2^-1074` — see
1536    /// [`Self::two_product`] for the bound and its proof — and it is the reason
1537    /// a monomial binary64 cannot carry no longer refuses a derivative binary64
1538    /// can round perfectly well.
1539    unrepresentable_mass: f64,
1540}
1541
1542#[derive(Clone, Copy, Debug)]
1543struct LatentCertifiedCumulant {
1544    /// Unique nearest binary64 rounding of the exact recurrence.
1545    value: f64,
1546    /// Outward upper bound on the sum of absolute expanded monomials.
1547    ///
1548    /// This is the numerator of the componentwise condition number and lets an
1549    /// independent floating implementation derive its own forward-error band
1550    /// without comparing against production or fitting a tolerance.
1551    absolute_term_mass: f64,
1552}
1553
1554impl LatentCertifiedCumulant {
1555    const ZERO: Self = Self {
1556        value: 0.0,
1557        absolute_term_mass: 0.0,
1558    };
1559}
1560
1561impl LatentExactExpansion {
1562    const ZERO: Self = Self {
1563        components: [0.0; LATENT_EXACT_EXPANSION_CAPACITY],
1564        len: 0,
1565        unrepresentable_mass: 0.0,
1566    };
1567
1568    /// One subnormal ulp — the outward bound on a single FMA product residual
1569    /// that binary64 cannot represent (gam#2714).
1570    ///
1571    /// In the guarded band `|p| = |fl(a·b)| < 2^-970` the exact residual obeys
1572    /// `|r| = |a·b − p| ≤ ulp(p)/2 ≤ 2^-1023`, so `r` lies inside the subnormal
1573    /// range where the grid spacing is exactly `2^-1074`. `fma(a, b, −p)`
1574    /// returns `r` correctly rounded onto that grid, so its error is at most a
1575    /// half spacing, `2^-1075`. That half is not itself representable, so the
1576    /// bound carried here is the full spacing — strictly conservative, and the
1577    /// smallest positive binary64 there is. The completely-underflowed case
1578    /// `p = 0` is the same statement with `|a·b| ≤ 2^-1075`.
1579    const SUBNORMAL_ULP: f64 = f64::from_bits(1);
1580
1581    /// Round a nonnegative bound outward, so an accumulated bound stays a
1582    /// bound under binary64 addition and multiplication.
1583    ///
1584    /// Exactly zero is returned unchanged: an expansion that has lost nothing
1585    /// must not acquire a bound merely by being added or scaled, or the "no
1586    /// underflow ⇒ byte-identical" property would not hold.
1587    #[inline]
1588    fn outward_bound(value: f64) -> Result<f64, &'static str> {
1589        if !value.is_finite() || value < 0.0 {
1590            return Err("an unrepresentable-mass bound left the finite nonnegative range");
1591        }
1592        if value == 0.0 {
1593            return Ok(0.0);
1594        }
1595        Ok(Self::next_up(value))
1596    }
1597
1598    fn scalar(value: f64) -> Self {
1599        if value == 0.0 {
1600            Self::ZERO
1601        } else {
1602            let mut out = Self::ZERO;
1603            out.components[0] = value;
1604            out.len = 1;
1605            out
1606        }
1607    }
1608
1609    #[inline]
1610    fn component(&self, index: usize) -> f64 {
1611        self.components[index]
1612    }
1613
1614    fn push_nonzero(&mut self, value: f64) -> Result<(), &'static str> {
1615        if value == 0.0 {
1616            return Ok(());
1617        }
1618        if !value.is_finite() {
1619            return Err("an exact-expansion component became non-finite");
1620        }
1621        if self.len == LATENT_EXACT_EXPANSION_CAPACITY {
1622            return Err("the structurally bounded exact expansion exhausted its capacity");
1623        }
1624        self.components[self.len] = value;
1625        self.len += 1;
1626        Ok(())
1627    }
1628
1629    /// Error-free `a + b = sum + error`.
1630    #[inline]
1631    fn two_sum(a: f64, b: f64) -> Result<(f64, f64), &'static str> {
1632        let sum = a + b;
1633        if !sum.is_finite() {
1634            return Err("an exact-expansion addition overflowed");
1635        }
1636        let virtual_b = sum - a;
1637        let virtual_a = sum - virtual_b;
1638        let error = (a - virtual_a) + (b - virtual_b);
1639        Ok((sum, error))
1640    }
1641
1642    /// Finite product through one fused residual, with an outward bound on the
1643    /// part of the exact product binary64 cannot carry.
1644    ///
1645    /// An FMA product residual is EXACT provided neither the product nor its
1646    /// residual underflows: a product at least `2^-970` has an exact-product
1647    /// least-significant bit no smaller than `2^-1074` for binary64 operands,
1648    /// so `mul_add(a, b, -product)` IS `a·b − product` with no rounding, and
1649    /// the returned bound is `0.0`.
1650    ///
1651    /// Below that proved range the residual can need bits the format does not
1652    /// have. **That is a fact about binary64, not about the derivative**
1653    /// (gam#2714). This used to `Err` there, which threw away an entire
1654    /// certified cumulant over one monomial — and the monomials that land there
1655    /// are, by construction, the ones too small to reach the answer: two
1656    /// perfectly ordinary normal moments at `1e-152` multiply to `1e-304`, 252
1657    /// orders below the half-ulp of a leading moment of `1`. Measured on the
1658    /// #2714 witness, five of seven outer seeds of the veteran latent-frailty
1659    /// fit died on exactly that. It is the same shape as the tie-to-even
1660    /// refusal fixed for gam#2538 in [`Self::certified_round`]: the
1661    /// certification refusing precisely the inputs it can decide.
1662    ///
1663    /// So the residual is KEPT — it is still the correctly rounded value of
1664    /// `a·b − product`, which is the best binary64 has — and the mass it may be
1665    /// off by is carried outward in [`Self::unrepresentable_mass`] and proved
1666    /// against the rounding cell at the end. See [`Self::SUBNORMAL_ULP`] for
1667    /// the bound. Nothing is loosened: an expansion whose products all clear
1668    /// `2^-970` carries a bound of exactly zero and certifies bit-identically.
1669    #[inline]
1670    fn two_product(a: f64, b: f64) -> Result<(f64, f64, f64), &'static str> {
1671        if a == 0.0 || b == 0.0 {
1672            return Ok((0.0, 0.0, 0.0));
1673        }
1674        let product = a * b;
1675        if !product.is_finite() {
1676            return Err("an exact-expansion product overflowed");
1677        }
1678        let error = a.mul_add(b, -product);
1679        if !error.is_finite() {
1680            return Err("an exact-expansion product residual became non-finite");
1681        }
1682        let unrepresentable = if product.abs() < f64::MIN_POSITIVE / f64::EPSILON {
1683            Self::SUBNORMAL_ULP
1684        } else {
1685            0.0
1686        };
1687        Ok((product, error, unrepresentable))
1688    }
1689
1690    /// Error-free sum of two increasing-magnitude expansions.
1691    ///
1692    /// `TwoSum` is exact for every finite pair, so the sum adds no
1693    /// unrepresentable mass of its own; the two operands' bounds simply add.
1694    fn add(self, other: Self) -> Result<Self, &'static str> {
1695        let carried = Self::outward_bound(self.unrepresentable_mass + other.unrepresentable_mass)?;
1696        if self.len == 0 {
1697            return Ok(Self {
1698                unrepresentable_mass: carried,
1699                ..other
1700            });
1701        }
1702        if other.len == 0 {
1703            return Ok(Self {
1704                unrepresentable_mass: carried,
1705                ..self
1706            });
1707        }
1708        if self.len + other.len > LATENT_EXACT_EXPANSION_CAPACITY {
1709            return Err("an exact-expansion sum exceeded its structural support bound");
1710        }
1711
1712        let mut left = 0usize;
1713        let mut right = 0usize;
1714        let take_left = |left_value: f64, right_value: f64| {
1715            left_value.abs() <= right_value.abs()
1716        };
1717        let mut q = if take_left(self.component(left), other.component(right)) {
1718            let value = self.component(left);
1719            left += 1;
1720            value
1721        } else {
1722            let value = other.component(right);
1723            right += 1;
1724            value
1725        };
1726        let mut out = Self::ZERO;
1727
1728        while left < self.len || right < other.len {
1729            let next = if right == other.len
1730                || (left < self.len
1731                    && take_left(self.component(left), other.component(right)))
1732            {
1733                let value = self.component(left);
1734                left += 1;
1735                value
1736            } else {
1737                let value = other.component(right);
1738                right += 1;
1739                value
1740            };
1741            let (sum, error) = Self::two_sum(q, next)?;
1742            out.push_nonzero(error)?;
1743            q = sum;
1744        }
1745        out.push_nonzero(q)?;
1746        out.unrepresentable_mass = carried;
1747        Ok(out)
1748    }
1749
1750    /// Multiplication by one rounded binary64 moment, exact wherever binary64
1751    /// can be (see [`Self::two_product`]).
1752    ///
1753    /// The incoming bound scales with the multiplier — `|exact − Σc| ≤ m` gives
1754    /// `|s·exact − Σ(s·c)| ≤ |s|·m` — and every product residual the format
1755    /// cannot hold adds its own [`Self::SUBNORMAL_ULP`] on top.
1756    fn scale(self, scalar: f64) -> Result<Self, &'static str> {
1757        if self.len == 0 || scalar == 0.0 {
1758            return Ok(Self::ZERO);
1759        }
1760        if self.len * 2 > LATENT_EXACT_EXPANSION_CAPACITY {
1761            return Err("a scaled exact expansion exceeded its structural support bound");
1762        }
1763
1764        let mut unrepresentable =
1765            Self::outward_bound(self.unrepresentable_mass * scalar.abs())?;
1766        let (mut q, first_error, first_unrepresentable) =
1767            Self::two_product(self.component(0), scalar)?;
1768        unrepresentable = Self::outward_bound(unrepresentable + first_unrepresentable)?;
1769        let mut out = Self::ZERO;
1770        out.push_nonzero(first_error)?;
1771        for index in 1..self.len {
1772            let (product, product_error, product_unrepresentable) =
1773                Self::two_product(self.component(index), scalar)?;
1774            unrepresentable = Self::outward_bound(unrepresentable + product_unrepresentable)?;
1775            let (sum, sum_error) = Self::two_sum(q, product_error)?;
1776            out.push_nonzero(sum_error)?;
1777            // The exact scaling recurrence guarantees `|product| >= |sum|`;
1778            // generic TwoSum keeps the identity valid without relying on that
1779            // ordering as a runtime premise.
1780            let (next_q, product_sum_error) = Self::two_sum(product, sum)?;
1781            out.push_nonzero(product_sum_error)?;
1782            q = next_q;
1783        }
1784        out.push_nonzero(q)?;
1785        out.unrepresentable_mass = unrepresentable;
1786        Ok(out)
1787    }
1788
1789    #[inline]
1790    fn next_up(value: f64) -> f64 {
1791        if value.is_nan() || value == f64::INFINITY {
1792            value
1793        } else if value == 0.0 {
1794            f64::from_bits(1)
1795        } else if value > 0.0 {
1796            f64::from_bits(value.to_bits() + 1)
1797        } else {
1798            f64::from_bits(value.to_bits() - 1)
1799        }
1800    }
1801
1802    #[inline]
1803    fn next_down(value: f64) -> f64 {
1804        -Self::next_up(-value)
1805    }
1806
1807    /// Sign of the exact expansion after adding one binary64 scalar.
1808    ///
1809    /// Every finite binary64 is an integer multiple of 2^-1074. Accumulating
1810    /// positive and negative significands into separate fixed unsigned integers
1811    /// and comparing those integers is therefore an exact, order-independent
1812    /// sign decision. It does not assume that a preceding error-free transform
1813    /// happened to retain a particular nonoverlap strength.
1814    fn exact_sign_after_adding_scalar(self, scalar: f64) -> Result<f64, &'static str> {
1815        self.exact_sign_after_adding_scalars(scalar, 0.0)
1816    }
1817
1818    /// As [`Self::exact_sign_after_adding_scalar`], with two addends.
1819    ///
1820    /// The second is the unrepresentable-mass offset that turns a statement
1821    /// about `Σ components` into a statement about the whole interval the exact
1822    /// value is known to lie in (gam#2714). Both go through the same exact
1823    /// fixed-point accumulator, so nothing about the decision becomes a
1824    /// tolerance comparison.
1825    fn exact_sign_after_adding_scalars(
1826        self,
1827        first: f64,
1828        second: f64,
1829    ) -> Result<f64, &'static str> {
1830        let ordering = exact_binary64_sum_sign(
1831            self.components[..self.len]
1832                .iter()
1833                .copied()
1834                .chain([first, second]),
1835        )
1836        .map_err(|_| "the shared exact-sign accumulator rejected its structural finite input")?;
1837        Ok(match ordering {
1838            std::cmp::Ordering::Less => -1.0,
1839            std::cmp::Ordering::Equal => 0.0,
1840            std::cmp::Ordering::Greater => 1.0,
1841        })
1842    }
1843
1844    /// Half the distance from `value` to whichever of its two binary64
1845    /// neighbours is nearer — the radius of its rounding cell.
1846    #[inline]
1847    fn rounding_cell_radius(value: f64) -> f64 {
1848        let up = Self::next_up(value) - value;
1849        let down = value - Self::next_down(value);
1850        0.5 * up.min(down)
1851    }
1852
1853    /// `candidate` is the answer iff the whole interval the exact value is
1854    /// known to lie in — `Σ components ± mass` — sits inside its rounding cell.
1855    ///
1856    /// Used where the components sum to `candidate` EXACTLY, so the only thing
1857    /// separating the two is the mass binary64 could not carry (gam#2714).
1858    fn certify_exact_sum_against_mass(candidate: f64, mass: f64) -> Result<f64, &'static str> {
1859        if mass == 0.0 {
1860            return Ok(candidate);
1861        }
1862        if mass < Self::rounding_cell_radius(candidate) {
1863            return Ok(candidate);
1864        }
1865        Err(LATENT_UNREPRESENTABLE_MASS_REACHES_CELL)
1866    }
1867
1868    /// Unique nearest-even binary64 rounding, proved against both adjacent
1869    /// midpoint boundaries and against the mass binary64 could not carry.
1870    ///
1871    /// The starting point is the accumulated sum of the components, which is
1872    /// only a neighbourhood of the exact value. Where it is not the nearest
1873    /// binary64 the boundary comparison says so exactly, and the walk below
1874    /// steps one ulp onto the neighbour that comparison names, so the returned
1875    /// value is the correctly rounded one regardless of how the accumulation
1876    /// happened to round.
1877    ///
1878    /// gam#2714 adds the second half of the proof. `Σ components` is the exact
1879    /// value only to within [`Self::unrepresentable_mass`], so every boundary
1880    /// comparison is made at the END of that interval that is nearest the
1881    /// boundary. `mass == 0` — every expansion whose products all cleared
1882    /// `2^-970`, which is every expansion this routine could previously be
1883    /// handed at all — reduces each comparison to the one made before, so the
1884    /// returned value is bit-identical there.
1885    fn certified_round(self) -> Result<f64, &'static str> {
1886        let mass = self.unrepresentable_mass;
1887        if !(mass.is_finite() && mass >= 0.0) {
1888            return Err("an unrepresentable-mass bound left the finite nonnegative range");
1889        }
1890        if self.len == 0 {
1891            return Self::certify_exact_sum_against_mass(0.0, mass);
1892        }
1893        let mut candidate = 0.0_f64;
1894        for index in 0..self.len {
1895            candidate += self.component(index);
1896        }
1897        if !candidate.is_finite() {
1898            return Err("the exact cumulant is outside the finite binary64 range");
1899        }
1900
1901        // The accumulation above rounds once per component, so `candidate` is a
1902        // NEIGHBOURHOOD of the exact value, not provably its nearest binary64.
1903        // When it is not, the certification below can say so exactly -- the
1904        // residual expansion and the sign accumulator are both exact -- and the
1905        // honest response is to MOVE to the neighbour it names rather than to
1906        // refuse. A step is taken only when the exact value lies strictly
1907        // beyond the midpoint separating `candidate` from `adjacent`, which is
1908        // precisely the statement that `adjacent` is strictly nearer, so the
1909        // walk contracts by one ulp per step, never reverses direction, and is
1910        // bounded by the (finite) ulp distance to the exact value. The step cap
1911        // is a runaway backstop, not the decision: it is the same structural
1912        // support bound that bounds the number of roundings the accumulation
1913        // above could have committed, since each component contributes at most
1914        // one.
1915        let mut walked = 0usize;
1916        loop {
1917            let residual = self.add(Self::scalar(-candidate))?;
1918            if residual.len == 0 {
1919                return Self::certify_exact_sum_against_mass(candidate, mass);
1920            }
1921            let residual_sign = residual.exact_sign_after_adding_scalar(0.0)?;
1922            if residual_sign == 0.0 {
1923                // Nonzero components summing to exactly zero: `candidate` IS the
1924                // components' sum, and only the unrepresentable mass separates
1925                // it from the exact value.
1926                return Self::certify_exact_sum_against_mass(candidate, mass);
1927            }
1928            let adjacent = if residual_sign > 0.0 {
1929                Self::next_up(candidate)
1930            } else {
1931                Self::next_down(candidate)
1932            };
1933            if !adjacent.is_finite() {
1934                return Err("the exact cumulant is outside the finite binary64 range");
1935            }
1936            let midpoint_distance = 0.5 * (adjacent - candidate).abs();
1937            if midpoint_distance == 0.0 {
1938                return Err("the exact cumulant reached an unrepresentable subnormal midpoint");
1939            }
1940            let boundary_offset = -residual_sign * midpoint_distance;
1941            // The components' sum is inside `candidate`'s cell; the exact value
1942            // is inside it too only if the mass binary64 could not carry fits
1943            // in what is left (gam#2714). `mass == 0` makes the widened
1944            // comparison the same exact-sign call as the plain one.
1945            let cell_holds_the_mass = move |residual: Self| -> Result<f64, &'static str> {
1946                let widened = residual
1947                    .exact_sign_after_adding_scalars(boundary_offset, residual_sign * mass)?;
1948                if widened == -residual_sign {
1949                    Ok(candidate)
1950                } else {
1951                    Err(LATENT_UNREPRESENTABLE_MASS_REACHES_CELL)
1952                }
1953            };
1954            match residual
1955                .exact_sign_after_adding_scalar(boundary_offset)?
1956                .partial_cmp(&0.0)
1957            {
1958                Some(std::cmp::Ordering::Less) if residual_sign > 0.0 => {
1959                    return cell_holds_the_mass(residual);
1960                }
1961                Some(std::cmp::Ordering::Greater) if residual_sign < 0.0 => {
1962                    return cell_holds_the_mass(residual);
1963                }
1964                Some(std::cmp::Ordering::Equal) if mass != 0.0 => {
1965                    // The components land exactly on the midpoint, so the
1966                    // exact value straddles it by the mass. A tie is a fact
1967                    // about a value that is KNOWN; this one is not.
1968                    return Err(LATENT_UNREPRESENTABLE_MASS_REACHES_CELL);
1969                }
1970                Some(std::cmp::Ordering::Equal) => {
1971                    // An exact midpoint is not an unroundable value. IEEE-754's
1972                    // default mode -- round to nearest, ties to EVEN -- makes it
1973                    // unique, and it is the mode every binary64 operation
1974                    // downstream of this one already uses. This function's own doc
1975                    // comment names that rule ("Unique nearest-even binary64
1976                    // rounding"), so refusing here contradicted the contract and
1977                    // was stricter than the arithmetic the result feeds.
1978                    //
1979                    // It is not a measure-zero case in practice. Measured on
1980                    // gam#2538 at current main: the latent-survival frailty fit
1981                    // rejects ALL SEVEN outer seeds in 0.49 s with
1982                    // `latent survival numerator derivative mask 0b0011 has no
1983                    // certified binary64 value`, because at the beta = 0 outer
1984                    // seed the cumulant is a small dyadic rational and lands
1985                    // exactly on a midpoint. The certification refused precisely
1986                    // the inputs it can round exactly.
1987                    //
1988                    // `candidate` and `adjacent` are bit-adjacent -- `next_up` /
1989                    // `next_down` are `to_bits() +/- 1` -- so exactly one of the
1990                    // two has an even significand, and the low bit of the pattern
1991                    // is that significand's LSB. Selecting the even one IS the
1992                    // tie-to-even neighbour; no tolerance and no choice enter.
1993                    return Ok(if candidate.to_bits() % 2 == 0 {
1994                        candidate
1995                    } else {
1996                        adjacent
1997                    });
1998                }
1999                _ => {
2000                    // The exact value lies AT OR BEYOND the far side of the
2001                    // midpoint, so `adjacent` is strictly nearer than `candidate`:
2002                    // the accumulated sum simply missed the nearest binary64. Step
2003                    // onto the neighbour the exact comparison named and certify
2004                    // again. Nothing here is a tolerance -- the step is decided by
2005                    // the same exact sign accumulator that decides the return.
2006                    if walked == LATENT_EXACT_EXPANSION_CAPACITY {
2007                        return Err(
2008                            "the exact cumulant stayed outside its rounding cell after a full \
2009                             structural walk",
2010                        );
2011                    }
2012                    walked += 1;
2013                    candidate = adjacent;
2014                }
2015            }
2016        }
2017    }
2018}
2019
2020#[derive(Clone, Copy)]
2021struct LatentSignedLogOrder2<const K: usize> {
2022    v: LatentSignedLog,
2023    g: [LatentSignedLog; K],
2024    h: [[LatentSignedLog; K]; K],
2025}
2026
2027impl<const K: usize> LatentSignedLogOrder2<K> {
2028    const fn zero() -> Self {
2029        Self {
2030            v: LatentSignedLog::ZERO,
2031            g: [LatentSignedLog::ZERO; K],
2032            h: [[LatentSignedLog::ZERO; K]; K],
2033        }
2034    }
2035}
2036
2037#[derive(Clone, Copy, Debug)]
2038struct LatentKernelPrimaryDirection {
2039    dq: f64,
2040    dqd: f64,
2041    dmu: f64,
2042    dtau: f64,
2043}
2044
2045#[derive(Clone, Copy, Debug)]
2046struct LatentSurvivalPrimaryDirection {
2047    dq_entry: f64,
2048    dq_exit: f64,
2049    dqdot_exit: f64,
2050    dq_right: f64,
2051    dmu: f64,
2052    dlog_sigma: f64,
2053}
2054
2055#[derive(Clone, Copy, Debug)]
2056struct LatentKernelPrimaryState {
2057    q: f64,
2058    qdot: f64,
2059    mu: f64,
2060    sigma: f64,
2061    log_sigma_factor: f64,
2062}
2063
2064/// Complete primary-coordinate state for one latent-survival row.
2065///
2066/// Keeping these coupled channels together prevents boundary reordering and
2067/// cross-row mean/scale mismatches when selecting a derivative backend.
2068/// Public because [`latent_survival_log_sigma_curvature_certified`] takes it: a
2069/// certificate a caller cannot invoke is not an export. Widening this was the API
2070/// decision that function deferred, and the compiler was right to force it —
2071/// `pub(crate)` made the whole certificate dead code, which is the build saying
2072/// "this has no consumer" rather than a lint to route around (#2566).
2073#[derive(Clone, Copy, Debug)]
2074pub struct LatentSurvivalPrimaryPoint {
2075    pub q_entry: f64,
2076    pub q_exit: f64,
2077    pub qdot_exit: f64,
2078    pub q_right: f64,
2079    pub mu: f64,
2080    pub sigma: f64,
2081}
2082
2083impl LatentSurvivalPrimaryPoint {
2084    /// Log-scale factor used only by derivatives along the learnable-sigma
2085    /// coordinate. Fixed zero frailty has no such coordinate, so its factor is
2086    /// the neutral finite placeholder; every other invalid scale remains NaN
2087    /// or infinite and is rejected by the kernel's numerical checks.
2088    #[inline]
2089    fn log_sigma_factor(self) -> f64 {
2090        if self.sigma == 0.0 {
2091            0.0
2092        } else {
2093            self.sigma.ln()
2094        }
2095    }
2096}
2097
2098#[cfg(test)]
2099mod tests_kernel_recurrence {
2100    use super::*;
2101    use std::collections::BTreeMap;
2102
2103    fn latent_kernel_accumulate_term(
2104        terms: &mut BTreeMap<(usize, usize, usize, usize), f64>,
2105        term: LatentKernelPrimaryTerm,
2106        scale: f64,
2107    ) {
2108        if scale == 0.0 || term.coeff == 0.0 {
2109            return;
2110        }
2111        *terms
2112            .entry((term.q_exp, term.qdot_power, term.tau_exp, term.k))
2113            .or_insert(0.0) += scale * term.coeff;
2114    }
2115
2116    pub(super) fn latent_kernel_differentiate_terms(
2117        terms: &[LatentKernelPrimaryTerm],
2118        dir: LatentKernelPrimaryDirection,
2119    ) -> Vec<LatentKernelPrimaryTerm> {
2120        let mut out = BTreeMap::<(usize, usize, usize, usize), f64>::new();
2121        for term in terms {
2122            if dir.dq != 0.0 {
2123                if term.q_exp > 0 {
2124                    latent_kernel_accumulate_term(&mut out, *term, dir.dq * term.q_exp as f64);
2125                }
2126                latent_kernel_accumulate_term(
2127                    &mut out,
2128                    LatentKernelPrimaryTerm {
2129                        q_exp: term.q_exp + 1,
2130                        k: term.k + 1,
2131                        ..*term
2132                    },
2133                    -dir.dq,
2134                );
2135            }
2136            if dir.dmu != 0.0 {
2137                if term.k > 0 {
2138                    latent_kernel_accumulate_term(&mut out, *term, dir.dmu * term.k as f64);
2139                }
2140                latent_kernel_accumulate_term(
2141                    &mut out,
2142                    LatentKernelPrimaryTerm {
2143                        q_exp: term.q_exp + 1,
2144                        k: term.k + 1,
2145                        ..*term
2146                    },
2147                    -dir.dmu,
2148                );
2149            }
2150            if dir.dtau != 0.0 {
2151                if term.tau_exp > 0 {
2152                    latent_kernel_accumulate_term(&mut out, *term, dir.dtau * term.tau_exp as f64);
2153                }
2154                let kf = term.k as f64;
2155                latent_kernel_accumulate_term(
2156                    &mut out,
2157                    LatentKernelPrimaryTerm {
2158                        tau_exp: term.tau_exp + 2,
2159                        ..*term
2160                    },
2161                    dir.dtau * kf * kf,
2162                );
2163                latent_kernel_accumulate_term(
2164                    &mut out,
2165                    LatentKernelPrimaryTerm {
2166                        q_exp: term.q_exp + 1,
2167                        tau_exp: term.tau_exp + 2,
2168                        k: term.k + 1,
2169                        ..*term
2170                    },
2171                    -dir.dtau * (2.0 * kf + 1.0),
2172                );
2173                latent_kernel_accumulate_term(
2174                    &mut out,
2175                    LatentKernelPrimaryTerm {
2176                        q_exp: term.q_exp + 2,
2177                        tau_exp: term.tau_exp + 2,
2178                        k: term.k + 2,
2179                        ..*term
2180                    },
2181                    dir.dtau,
2182                );
2183            }
2184            if dir.dqd != 0.0 && term.qdot_power > 0 {
2185                latent_kernel_accumulate_term(
2186                    &mut out,
2187                    LatentKernelPrimaryTerm {
2188                        qdot_power: term.qdot_power - 1,
2189                        ..*term
2190                    },
2191                    dir.dqd * term.qdot_power as f64,
2192                );
2193            }
2194        }
2195        out.into_iter()
2196            .filter_map(|((q_exp, qdot_power, tau_exp, k), coeff)| {
2197                (coeff != 0.0).then_some(LatentKernelPrimaryTerm {
2198                    coeff,
2199                    q_exp,
2200                    qdot_power,
2201                    tau_exp,
2202                    k,
2203                })
2204            })
2205            .collect()
2206    }
2207}
2208
2209// Fourth-order latent-kernel recurrences have a small finite support. Keeping
2210// the sorted support inline removes the BTreeMap node allocation and output-Vec
2211// allocation that the pre-cutover directional oracle intentionally retains.
2212// The all-event/K=5/K=6 oracle below asserts this capacity never spills.
2213const LATENT_TERM_INLINE_CAPACITY: usize = 64;
2214type LatentTermBuffer = SmallVec<[LatentKernelPrimaryTerm; LATENT_TERM_INLINE_CAPACITY]>;
2215
2216/// Highest kernel rung, `σ`-power, and `qdot`-power the `∂_a` basis handles.
2217///
2218/// These are structural bounds, not tolerances: the rung bound keeps every
2219/// falling-factorial coefficient an exact f64 integer (`|s(12,1)| = 11! =
2220/// 39916800`), and the other two size the accumulation table. A term list that
2221/// exceeds any of them routes to the rung basis instead, which is always
2222/// available.
2223const LATENT_A_BASIS_MAX_RUNG: usize = 12;
2224const LATENT_A_BASIS_MAX_TAU_EXP: usize = 12;
2225const LATENT_A_BASIS_MAX_QDOT_POWER: usize = 4;
2226
2227/// Signed Stirling numbers of the first kind: the coefficients of the falling
2228/// factorial `(x)_k = x(x−1)···(x−k+1) = Σ_j s(k,j) x^j`.
2229///
2230/// These are the weights that re-express a kernel rung in the `∂_a^j K_0` basis,
2231/// because `m^k K_k = (−1)^k (∂_a)_k K_0` (#2610). Built by the standard
2232/// recurrence `(x)_{k+1} = (x)_k · (x − k)` in integers so every entry converts
2233/// to f64 exactly.
2234const fn latent_falling_factorial_table()
2235-> [[i64; LATENT_A_BASIS_MAX_RUNG + 1]; LATENT_A_BASIS_MAX_RUNG + 1] {
2236    let mut table = [[0_i64; LATENT_A_BASIS_MAX_RUNG + 1]; LATENT_A_BASIS_MAX_RUNG + 1];
2237    table[0][0] = 1;
2238    let mut rung = 0usize;
2239    while rung < LATENT_A_BASIS_MAX_RUNG {
2240        let mut power = 0usize;
2241        while power <= rung {
2242            let value = table[rung][power];
2243            if value != 0 {
2244                table[rung + 1][power + 1] += value;
2245                table[rung + 1][power] -= (rung as i64) * value;
2246            }
2247            power += 1;
2248        }
2249        rung += 1;
2250    }
2251    table
2252}
2253
2254const LATENT_FALLING_FACTORIAL: [[i64; LATENT_A_BASIS_MAX_RUNG + 1];
2255    LATENT_A_BASIS_MAX_RUNG + 1] = latent_falling_factorial_table();
2256
2257/// Evaluate one latent kernel term list as a signed log magnitude.
2258///
2259/// Shared by the packed order-two production path and the multi-direction
2260/// oracle so the two cannot drift in either the basis they use or the
2261/// accumulation order they use it in.
2262fn latent_kernel_evaluate_terms(
2263    bundle: &LogLognormalKernelBundle,
2264    state: LatentKernelPrimaryState,
2265    terms: &[LatentKernelPrimaryTerm],
2266    context: &str,
2267) -> Result<(f64, f64), LatentSurvivalError> {
2268    let needs_qdot = terms
2269        .iter()
2270        .any(|term| term.coeff != 0.0 && term.qdot_power > 0);
2271    if needs_qdot && !(state.qdot.is_finite() && state.qdot > 0.0) {
2272        return Err(LatentSurvivalError::NumericalFailure {
2273            reason: format!(
2274                "{context} requires positive finite qdot for exact-event directional terms, got {}",
2275                state.qdot
2276            ),
2277        });
2278    }
2279    let log_qdot = if needs_qdot { state.qdot.ln() } else { 0.0 };
2280    if let Some(sum) = latent_kernel_evaluate_terms_in_a_basis(bundle, state, terms, log_qdot) {
2281        return Ok(sum);
2282    }
2283    let mut log_mags = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2284    let mut signs = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2285    for term in terms {
2286        if term.coeff == 0.0 {
2287            continue;
2288        }
2289        log_mags.push(
2290            term.coeff.abs().ln()
2291                + term.q_exp as f64 * state.q
2292                + term.tau_exp as f64 * state.log_sigma_factor
2293                + term.qdot_power as f64 * log_qdot
2294                + bundle.get(term.k),
2295        );
2296        signs.push(term.coeff.signum());
2297    }
2298    if log_mags.is_empty() {
2299        return Ok((f64::NEG_INFINITY, 0.0));
2300    }
2301    Ok(signed_log_sum_exp(&log_mags, &signs))
2302}
2303
2304/// The same term list evaluated in the `∂_a^j K_0` basis, or `None` when that
2305/// basis is unavailable for this bundle or this term list (#2610).
2306///
2307/// The rung basis and this one are related by an exact integer change of basis,
2308/// so this is not an approximation — it is the same sum with the cancellation
2309/// performed on the COEFFICIENTS instead of on the values. That is the whole
2310/// repair. `∂_{log σ} K_0` reaches the rung basis as `σ²(−mK_1 + m²K_2)`, whose
2311/// two summands agree to `~1/(120σ²)` of their own size; in this basis the
2312/// identical quantity is `σ² ∂_a² K_0`, one entry, because the `∂_a` coefficient
2313/// cancels ANALYTICALLY when like powers are collected. Past `log σ ≈ 5.4` that
2314/// is the difference between a curvature and its roundoff.
2315///
2316/// Requires `q_exp == k` on every term. That is not a restriction in practice:
2317/// the differentiation rules shift `q_exp` and `k` in lockstep, and every base
2318/// term the row expression builds starts on the diagonal, so the whole
2319/// derivative tree stays there. A term off the diagonal routes to the rung
2320/// basis rather than being handled approximately.
2321fn latent_kernel_evaluate_terms_in_a_basis(
2322    bundle: &LogLognormalKernelBundle,
2323    state: LatentKernelPrimaryState,
2324    terms: &[LatentKernelPrimaryTerm],
2325    log_qdot: f64,
2326) -> Option<(f64, f64)> {
2327    let tower = bundle.log_scaled_a_derivatives.as_ref()?;
2328    let mut max_rung = 0usize;
2329    let mut max_tau_exp = 0usize;
2330    let mut max_qdot_power = 0usize;
2331    for term in terms {
2332        if term.coeff == 0.0 {
2333            continue;
2334        }
2335        if term.q_exp != term.k
2336            || term.k >= tower.len()
2337            || term.k > LATENT_A_BASIS_MAX_RUNG
2338            || term.tau_exp > LATENT_A_BASIS_MAX_TAU_EXP
2339            || term.qdot_power > LATENT_A_BASIS_MAX_QDOT_POWER
2340        {
2341            return None;
2342        }
2343        max_rung = max_rung.max(term.k);
2344        max_tau_exp = max_tau_exp.max(term.tau_exp);
2345        max_qdot_power = max_qdot_power.max(term.qdot_power);
2346    }
2347    // One accumulator per `(qdot_power, tau_exp, j)` monomial. Terms sharing a
2348    // cell share every factor except the integer coefficient, so collecting them
2349    // here is where the analytic cancellation happens — exactly, in integers
2350    // scaled by one common power of σ.
2351    let rung_stride = max_rung + 1;
2352    let tau_stride = max_tau_exp + 1;
2353    let mut coefficients =
2354        SmallVec::<[f64; 256]>::from_elem(0.0, (max_qdot_power + 1) * tau_stride * rung_stride);
2355    for term in terms {
2356        if term.coeff == 0.0 {
2357            continue;
2358        }
2359        let rung_parity = if term.k % 2 == 0 { 1.0 } else { -1.0 };
2360        let cell = (term.qdot_power * tau_stride + term.tau_exp) * rung_stride;
2361        for power in 0..=term.k {
2362            let stirling = LATENT_FALLING_FACTORIAL[term.k][power];
2363            if stirling != 0 {
2364                coefficients[cell + power] += rung_parity * term.coeff * stirling as f64;
2365            }
2366        }
2367    }
2368    let mut log_mags = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2369    let mut signs = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2370    for qdot_power in 0..=max_qdot_power {
2371        for tau_exp in 0..=max_tau_exp {
2372            for power in 0..=max_rung {
2373                let coefficient =
2374                    coefficients[(qdot_power * tau_stride + tau_exp) * rung_stride + power];
2375                let entry = tower[power];
2376                if coefficient == 0.0 || entry.sign == 0.0 {
2377                    continue;
2378                }
2379                // `tower` holds `σ^j ∂_a^j K_0`, so the stored `σ^j` is divided
2380                // back out alongside the term's own `σ^{tau_exp}`.
2381                log_mags.push(
2382                    coefficient.abs().ln()
2383                        + (tau_exp as f64 - power as f64) * state.log_sigma_factor
2384                        + qdot_power as f64 * log_qdot
2385                        + entry.log_abs,
2386                );
2387                signs.push(coefficient.signum() * entry.sign);
2388            }
2389        }
2390    }
2391    if log_mags.is_empty() {
2392        return Some((f64::NEG_INFINITY, 0.0));
2393    }
2394    Some(signed_log_sum_exp(&log_mags, &signs))
2395}
2396
2397#[inline]
2398fn latent_kernel_accumulate_term_inline(
2399    terms: &mut LatentTermBuffer,
2400    term: LatentKernelPrimaryTerm,
2401    scale: f64,
2402) {
2403    if scale == 0.0 || term.coeff == 0.0 {
2404        return;
2405    }
2406    let contribution = scale * term.coeff;
2407    if let Some(existing) = terms.iter_mut().find(|existing| {
2408        existing.q_exp == term.q_exp
2409            && existing.qdot_power == term.qdot_power
2410            && existing.tau_exp == term.tau_exp
2411            && existing.k == term.k
2412    }) {
2413        existing.coeff += contribution;
2414    } else {
2415        terms.push(LatentKernelPrimaryTerm {
2416            coeff: contribution,
2417            ..term
2418        });
2419    }
2420}
2421
2422fn latent_kernel_differentiate_terms_inline(
2423    terms: &[LatentKernelPrimaryTerm],
2424    dir: LatentKernelPrimaryDirection,
2425) -> LatentTermBuffer {
2426    let mut out = LatentTermBuffer::new();
2427    for term in terms {
2428        if dir.dq != 0.0 {
2429            if term.q_exp > 0 {
2430                latent_kernel_accumulate_term_inline(&mut out, *term, dir.dq * term.q_exp as f64);
2431            }
2432            latent_kernel_accumulate_term_inline(
2433                &mut out,
2434                LatentKernelPrimaryTerm {
2435                    q_exp: term.q_exp + 1,
2436                    k: term.k + 1,
2437                    ..*term
2438                },
2439                -dir.dq,
2440            );
2441        }
2442        if dir.dmu != 0.0 {
2443            if term.k > 0 {
2444                latent_kernel_accumulate_term_inline(&mut out, *term, dir.dmu * term.k as f64);
2445            }
2446            latent_kernel_accumulate_term_inline(
2447                &mut out,
2448                LatentKernelPrimaryTerm {
2449                    q_exp: term.q_exp + 1,
2450                    k: term.k + 1,
2451                    ..*term
2452                },
2453                -dir.dmu,
2454            );
2455        }
2456        if dir.dtau != 0.0 {
2457            if term.tau_exp > 0 {
2458                latent_kernel_accumulate_term_inline(
2459                    &mut out,
2460                    *term,
2461                    dir.dtau * term.tau_exp as f64,
2462                );
2463            }
2464            let kf = term.k as f64;
2465            latent_kernel_accumulate_term_inline(
2466                &mut out,
2467                LatentKernelPrimaryTerm {
2468                    tau_exp: term.tau_exp + 2,
2469                    ..*term
2470                },
2471                dir.dtau * kf * kf,
2472            );
2473            latent_kernel_accumulate_term_inline(
2474                &mut out,
2475                LatentKernelPrimaryTerm {
2476                    q_exp: term.q_exp + 1,
2477                    tau_exp: term.tau_exp + 2,
2478                    k: term.k + 1,
2479                    ..*term
2480                },
2481                -dir.dtau * (2.0 * kf + 1.0),
2482            );
2483            latent_kernel_accumulate_term_inline(
2484                &mut out,
2485                LatentKernelPrimaryTerm {
2486                    q_exp: term.q_exp + 2,
2487                    tau_exp: term.tau_exp + 2,
2488                    k: term.k + 2,
2489                    ..*term
2490                },
2491                dir.dtau,
2492            );
2493        }
2494        if dir.dqd != 0.0 && term.qdot_power > 0 {
2495            latent_kernel_accumulate_term_inline(
2496                &mut out,
2497                LatentKernelPrimaryTerm {
2498                    qdot_power: term.qdot_power - 1,
2499                    ..*term
2500                },
2501                dir.dqd * term.qdot_power as f64,
2502            );
2503        }
2504    }
2505    out.retain(|term| term.coeff != 0.0);
2506    out.sort_unstable_by_key(|term| (term.q_exp, term.qdot_power, term.tau_exp, term.k));
2507    out
2508}
2509
2510fn latent_kernel_term_sequence_inline(
2511    base_terms: &[LatentKernelPrimaryTerm],
2512    axes: &[LatentKernelPrimaryDirection],
2513    suffix: &[LatentKernelPrimaryDirection],
2514) -> LatentTermBuffer {
2515    let mut terms = LatentTermBuffer::from_slice(base_terms);
2516    terms.retain(|term| term.coeff != 0.0);
2517    // The canonical subset-cache recurrence strips the least-significant
2518    // selected slot and applies it after recursively building the remaining
2519    // mask. Its deterministic floating-point order is therefore highest slot
2520    // to lowest slot. Preserve that order here so the allocation-free packed
2521    // path and the independent MultiDir layout accumulate identical analytic
2522    // coefficients, including cancellation-heavy tail derivatives.
2523    for direction in axes.iter().chain(suffix.iter()).rev() {
2524        terms = latent_kernel_differentiate_terms_inline(&terms, *direction);
2525    }
2526    terms
2527}
2528
2529#[cfg(test)]
2530mod tests_multidir_kernel {
2531    /// Derivatives of `log(x)` through fourth order at the only point needed by
2532    /// normalized kernel sums: the literal `x = 1`.
2533    ///
2534    /// Keeping the point in the function name and removing the free argument
2535    /// makes the representability contract structural. No caller can
2536    /// accidentally feed a small positive linear-domain mass into the
2537    /// reciprocal powers.
2538    #[inline]
2539    fn latent_unary_derivatives_log_at_one() -> [f64; 5] {
2540        [0.0, 1.0, -1.0, 2.0, -6.0]
2541    }
2542
2543    use super::tests_kernel_recurrence::latent_kernel_differentiate_terms;
2544    use super::*;
2545    use gam_math::jet_partitions::MultiDirJet as LatentMultiDirJet;
2546
2547    fn latent_kernel_term_lists_for_directions(
2548        base_terms: &[LatentKernelPrimaryTerm],
2549        directions: &[LatentKernelPrimaryDirection],
2550    ) -> Vec<Vec<LatentKernelPrimaryTerm>> {
2551        fn build_mask(
2552            mask: usize,
2553            base_terms: &[LatentKernelPrimaryTerm],
2554            directions: &[LatentKernelPrimaryDirection],
2555            cache: &mut [Option<Vec<LatentKernelPrimaryTerm>>],
2556        ) -> Vec<LatentKernelPrimaryTerm> {
2557            if let Some(existing) = &cache[mask] {
2558                return existing.clone();
2559            }
2560            let built = if mask == 0 {
2561                base_terms.to_vec()
2562            } else {
2563                let bit = 1usize << mask.trailing_zeros();
2564                let prev = build_mask(mask ^ bit, base_terms, directions, cache);
2565                latent_kernel_differentiate_terms(&prev, directions[bit.trailing_zeros() as usize])
2566            };
2567            cache[mask] = Some(built.clone());
2568            built
2569        }
2570
2571        let mut cache = vec![None; 1usize << directions.len()];
2572        (0..cache.len())
2573            .map(|mask| build_mask(mask, base_terms, directions, &mut cache))
2574            .collect()
2575    }
2576
2577    pub(super) fn latent_kernel_sum_log_jet(
2578        quadctx: &QuadratureContext,
2579        base_terms: &[LatentKernelPrimaryTerm],
2580        state: LatentKernelPrimaryState,
2581        directions: &[LatentKernelPrimaryDirection],
2582        context: &str,
2583    ) -> Result<LatentMultiDirJet, LatentSurvivalError> {
2584        let term_lists = latent_kernel_term_lists_for_directions(base_terms, directions);
2585        let max_k = term_lists
2586            .iter()
2587            .flat_map(|terms| terms.iter().map(|term| term.k))
2588            .max()
2589            .unwrap_or(0);
2590        let bundle = log_kernel_bundle(quadctx, state.q.exp(), state.mu, state.sigma, max_k)
2591            .map_err(|e| LatentSurvivalError::NumericalFailure {
2592                reason: format!("{context} kernel evaluation failed: {e}"),
2593            })?;
2594
2595        let evaluate_terms = |terms: &[LatentKernelPrimaryTerm]| {
2596            latent_kernel_evaluate_terms(&bundle, state, terms, context)
2597        };
2598
2599        let (base_log_sum, base_sign) = evaluate_terms(&term_lists[0])?;
2600        if !(base_log_sum.is_finite() && base_sign > 0.0) {
2601            return Err(LatentSurvivalError::NumericalFailure {
2602                reason: format!("{context} produced a non-positive signed kernel sum"),
2603            });
2604        }
2605
2606        let mut normalized = LatentMultiDirJet::constant(directions.len(), 1.0);
2607        let mut signed_moments = [LatentSignedLog::ZERO; 16];
2608        signed_moments[0] = LatentSignedLog::ONE;
2609        for mask in 1..term_lists.len() {
2610            let (log_abs, sign) = evaluate_terms(&term_lists[mask])?;
2611            let moment =
2612                latent_signed_log_normalized(log_abs, sign, base_log_sum, context)?;
2613            signed_moments[mask] = moment;
2614            normalized.coeffs[mask] = latent_signed_log_materialize(moment, context)?;
2615        }
2616
2617        let mut out = normalized.compose_unary(latent_unary_derivatives_log_at_one());
2618        out.coeffs[0] += base_log_sum;
2619        if term_lists.len() == 1 {
2620            return Ok(out);
2621        }
2622
2623        // Independently grade the pre-cutover floating oracle against the exact
2624        // rounded-moment polynomial. `MultiDirJet::compose_unary` supports at
2625        // most four live slots here. Its K=4 pointed schedule walks 34 Dot2
2626        // terms (10 rounded operations each), writes 17 compensated power
2627        // outputs (5 operations each), and combines four powers in at most 32
2628        // operations: 457 total. Smaller orders are strict subsets, so this is
2629        // a structural uniform bound rather than an empirical multiplier.
2630        const MULTIDIR_DOT2_TERMS_K4: usize = 34;
2631        const MULTIDIR_DOT2_OPERATIONS: usize = 10;
2632        const MULTIDIR_POWER_OUTPUTS_K4: usize = 17;
2633        const MULTIDIR_POWER_OUTPUT_OPERATIONS: usize = 5;
2634        const MULTIDIR_COMBINE_OPERATIONS: usize = 32;
2635        const MULTIDIR_MAX_OPERATIONS: usize =
2636            MULTIDIR_DOT2_TERMS_K4 * MULTIDIR_DOT2_OPERATIONS
2637                + MULTIDIR_POWER_OUTPUTS_K4 * MULTIDIR_POWER_OUTPUT_OPERATIONS
2638                + MULTIDIR_COMBINE_OPERATIONS;
2639        const _: () = assert!(MULTIDIR_MAX_OPERATIONS == 457);
2640        let operation_roundoff = MULTIDIR_MAX_OPERATIONS as f64 * f64::EPSILON;
2641        let gamma = LatentExactExpansion::next_up(
2642            operation_roundoff / (1.0 - operation_roundoff),
2643        );
2644        let exact = latent_certified_cumulants(
2645            signed_moments,
2646            term_lists.len() - 1,
2647            context,
2648        )?;
2649        for mask in 1..term_lists.len() {
2650            let relative_roundoff =
2651                LatentExactExpansion::next_up(gamma * exact[mask].absolute_term_mass);
2652            let gradual_underflow =
2653                MULTIDIR_MAX_OPERATIONS as f64 * f64::from_bits(1);
2654            let arithmetic_bound =
2655                LatentExactExpansion::next_up(relative_roundoff + gradual_underflow);
2656            let error =
2657                LatentExactExpansion::next_up((out.coeffs[mask] - exact[mask].value).abs());
2658            assert!(
2659                error <= arithmetic_bound,
2660                "{context}: MultiDir mask {mask:#06b} escaped its derived forward-error \
2661                 certificate: got={:.17e}, exact-rounded={:.17e}, error={error:.17e}, \
2662                 bound={arithmetic_bound:.17e}, absolute-term-mass={:.17e}, operations={MULTIDIR_MAX_OPERATIONS}",
2663                out.coeffs[mask],
2664                exact[mask].value,
2665                exact[mask].absolute_term_mass,
2666            );
2667        }
2668        Ok(out)
2669    }
2670}
2671
2672fn latent_signed_log_checked(
2673    log_abs: f64,
2674    sign: f64,
2675    context: &str,
2676    quantity: &str,
2677) -> Result<LatentSignedLog, LatentSurvivalError> {
2678    if log_abs == f64::NEG_INFINITY && sign == 0.0 {
2679        return Ok(LatentSignedLog::ZERO);
2680    }
2681    if log_abs.is_finite() && (sign == -1.0 || sign == 1.0) {
2682        return Ok(LatentSignedLog { log_abs, sign });
2683    }
2684    Err(LatentSurvivalError::NumericalFailure {
2685        reason: format!(
2686            "{context} produced an invalid signed-log {quantity}: log_abs={log_abs}, sign={sign}"
2687        ),
2688    })
2689}
2690
2691fn latent_signed_log_normalized(
2692    log_abs: f64,
2693    sign: f64,
2694    base_log_sum: f64,
2695    context: &str,
2696) -> Result<LatentSignedLog, LatentSurvivalError> {
2697    let value = latent_signed_log_checked(log_abs, sign, context, "kernel derivative")?;
2698    if value.sign == 0.0 {
2699        Ok(value)
2700    } else {
2701        latent_signed_log_checked(
2702            value.log_abs - base_log_sum,
2703            value.sign,
2704            context,
2705            "normalised kernel derivative",
2706        )
2707    }
2708}
2709
2710fn latent_signed_log_materialize(
2711    value: LatentSignedLog,
2712    context: &str,
2713) -> Result<f64, LatentSurvivalError> {
2714    let value =
2715        latent_signed_log_checked(value.log_abs, value.sign, context, "log-sum derivative")?;
2716    if value.sign == 0.0 {
2717        return Ok(0.0);
2718    }
2719    let materialized = value.sign * value.log_abs.exp();
2720    if materialized.is_finite() {
2721        Ok(materialized)
2722    } else {
2723        Err(LatentSurvivalError::NumericalFailure {
2724            reason: format!(
2725                "{context} log-sum derivative is outside the finite f64 range: \
2726                 log_abs={}, sign={}",
2727                value.log_abs, value.sign
2728            ),
2729        })
2730    }
2731}
2732
2733/// Convert normalized signed-log moments into certified derivatives of `log(S)`.
2734///
2735/// For a non-empty slot set `A`, normalized moments and log derivatives obey
2736///
2737/// `m(A) = Σ_{B ⊆ A, pivot ∈ B} κ(B) m(A \\ B)`,
2738///
2739/// where `m(A) = S_A / S`, `κ(A) = ∂_A log(S)`, and the distinguished pivot is
2740/// the least-significant slot. Isolating `B = A` gives a pointed cumulant
2741/// recurrence.
2742///
2743/// The contract is deliberately about the inputs an actual binary64 consumer
2744/// has: each finite signed-log moment is rounded once to binary64, then the
2745/// exact-real cumulant polynomial over those rounded moments is evaluated by a
2746/// fixed error-free expansion. Publication succeeds only when the expansion
2747/// proves a unique nearest binary64 rounding. A moment lost to underflow,
2748/// overflow, an unrepresentable intermediate product, or an ambiguous final
2749/// rounding produces [`LatentSurvivalError::DerivativeAccuracyUnresolved`]
2750/// instead of an approximate derivative.
2751///
2752/// The four-slot table covers the `(a,b,u,v)` layouts used by Order2, OneSeed,
2753/// and TwoSeed.
2754fn latent_certified_cumulants(
2755    moments: [LatentSignedLog; 16],
2756    target_mask: usize,
2757    context: &str,
2758) -> Result<[LatentCertifiedCumulant; 16], LatentSurvivalError> {
2759    assert!(target_mask > 0 && target_mask < 16);
2760
2761    let unresolved = |mask: usize, reason: &str| {
2762        LatentSurvivalError::DerivativeAccuracyUnresolved {
2763            reason: format!(
2764                "{context} derivative mask {mask:#06b} has no certified binary64 value: {reason}"
2765            ),
2766        }
2767    };
2768    let mut rounded_moments = [0.0_f64; 16];
2769    for mask in 0usize..16 {
2770        if mask & !target_mask != 0 {
2771            continue;
2772        }
2773        let moment = latent_signed_log_checked(
2774            moments[mask].log_abs,
2775            moments[mask].sign,
2776            context,
2777            "normalised cumulant moment",
2778        )?;
2779        if moment.sign == 0.0 {
2780            continue;
2781        }
2782        let magnitude = moment.log_abs.exp();
2783        if !magnitude.is_finite() {
2784            return Err(unresolved(
2785                mask,
2786                "the rounded moment magnitude overflowed",
2787            ));
2788        }
2789        if magnitude == 0.0 {
2790            return Err(unresolved(
2791                mask,
2792                "the rounded moment magnitude underflowed to zero",
2793            ));
2794        }
2795        rounded_moments[mask] = moment.sign * magnitude;
2796    }
2797
2798    // The moments' own dynamic range is what every refusal below is actually
2799    // about, and it is the one number a caller cannot recover from the message
2800    // afterwards — recovering it has twice cost this lane a three-quarter-hour
2801    // fit (gam#2714). Report it with the refusal, not instead of it.
2802    let moment_span = || -> String {
2803        let mut lowest = f64::INFINITY;
2804        let mut highest = 0.0_f64;
2805        for mask in 0usize..16 {
2806            let magnitude = rounded_moments[mask].abs();
2807            if magnitude > 0.0 {
2808                lowest = lowest.min(magnitude);
2809                highest = highest.max(magnitude);
2810            }
2811        }
2812        if highest == 0.0 {
2813            return "no nonzero rounded moment".to_string();
2814        }
2815        format!("rounded moment magnitudes span [{lowest:.6e}, {highest:.6e}]")
2816    };
2817    let unresolved_with_span = |mask: usize, reason: &str| {
2818        unresolved(mask, &format!("{reason} ({})", moment_span()))
2819    };
2820
2821    let mut exact_cumulants = [LatentExactExpansion::ZERO; 16];
2822    let mut certificates = [LatentCertifiedCumulant::ZERO; 16];
2823    for mask in 1usize..16 {
2824        if mask & !target_mask != 0 {
2825            continue;
2826        }
2827        if mask.is_power_of_two() {
2828            exact_cumulants[mask] = LatentExactExpansion::scalar(rounded_moments[mask]);
2829            certificates[mask] = LatentCertifiedCumulant {
2830                value: rounded_moments[mask],
2831                absolute_term_mass: rounded_moments[mask].abs(),
2832            };
2833            continue;
2834        }
2835        let pivot = 1usize << mask.trailing_zeros();
2836        let mut cumulant = LatentExactExpansion::scalar(rounded_moments[mask]);
2837        let mut absolute_term_mass = rounded_moments[mask].abs();
2838        let mut block = (mask - 1) & mask;
2839        while block != 0 {
2840            if block & pivot != 0 {
2841                let complement = mask ^ block;
2842                let subtract = exact_cumulants[block]
2843                    .scale(-rounded_moments[complement])
2844                    .and_then(|term| cumulant.add(term))
2845                    .map_err(|reason| unresolved_with_span(mask, reason))?;
2846                cumulant = subtract;
2847                let term_mass = LatentExactExpansion::next_up(
2848                    certificates[block].absolute_term_mass
2849                        * rounded_moments[complement].abs(),
2850                );
2851                if !term_mass.is_finite() {
2852                    return Err(unresolved(
2853                        mask,
2854                        "the conditioning mass overflowed binary64",
2855                    ));
2856                }
2857                absolute_term_mass =
2858                    LatentExactExpansion::next_up(absolute_term_mass + term_mass);
2859                if !absolute_term_mass.is_finite() {
2860                    return Err(unresolved(
2861                        mask,
2862                        "the accumulated conditioning mass overflowed binary64",
2863                    ));
2864                }
2865            }
2866            block = (block - 1) & mask;
2867        }
2868        let value = cumulant
2869            .certified_round()
2870            .map_err(|reason| unresolved_with_span(mask, reason))?;
2871        exact_cumulants[mask] = cumulant;
2872        certificates[mask] = LatentCertifiedCumulant {
2873            value,
2874            absolute_term_mass,
2875        };
2876    }
2877    Ok(certificates)
2878}
2879
2880/// A one-pass analytic lift of a latent kernel sum into an order-specific jet.
2881///
2882/// `suffixes` describes the nilpotent parts carried by the requested scalar:
2883/// `[]` for the ordinary order-two base, `[u]` for the `OneSeed` epsilon part,
2884/// and `[u]`, `[v]`, `[u,v]` for the three non-base `TwoSeed` parts.  For each
2885/// part we differentiate the SAME kernel-term program in the canonical
2886/// highest-slot-to-lowest-slot order used by the pre-cutover `MultiDirJet`
2887/// subset cache. Every requested raw derivative is therefore assembled by the
2888/// same recurrence, accumulation order, and signed-log reduction as its oracle.
2889/// The expensive quadrature bundle is then evaluated ONCE at the maximum `k`
2890/// required by the complete output instead of once per Hessian cell.
2891/// The kernel rung ceiling one lift needs.
2892///
2893/// Every emitted part carries a full order-two base, so the largest requested
2894/// recurrence is the base list's own highest rung plus two primary
2895/// differentiations plus the largest nilpotent suffix. This exact support bound
2896/// is what lets the one shared bundle be built before any individual derivative
2897/// term list is constructed.
2898///
2899/// Extracted so the value-only lift and the order-two lift cannot drift: the
2900/// bundle's `max_k` decides which rungs exist AND (through
2901/// `log_scaled_a_derivative_tower`) how long the `∂_a` tower is, so two lifts
2902/// that computed it differently would evaluate the same base term list on
2903/// different data (#2714).
2904fn latent_kernel_sum_max_k<const K: usize>(
2905    base_terms: &[LatentKernelPrimaryTerm],
2906    primary_directions: &[LatentKernelPrimaryDirection; K],
2907    suffixes: &[&[LatentKernelPrimaryDirection]],
2908) -> usize {
2909    let base_max_k = base_terms.iter().map(|term| term.k).max().unwrap_or(0);
2910    let k_increment = |direction: &LatentKernelPrimaryDirection| {
2911        if direction.dtau != 0.0 {
2912            2
2913        } else if direction.dq != 0.0 || direction.dmu != 0.0 {
2914            1
2915        } else {
2916            0
2917        }
2918    };
2919    let max_primary_increment = primary_directions
2920        .iter()
2921        .map(&k_increment)
2922        .max()
2923        .unwrap_or(0);
2924    let max_suffix_increment = suffixes
2925        .iter()
2926        .map(|suffix| suffix.iter().map(&k_increment).sum::<usize>())
2927        .max()
2928        .unwrap_or(0);
2929    base_max_k + 2 * max_primary_increment + max_suffix_increment
2930}
2931
2932/// The shared prologue of every lift: the ONE kernel bundle, and the base term
2933/// list's signed-log sum on it.
2934///
2935/// This is the row's log-likelihood contribution before any normalisation —
2936/// `latent_kernel_sum_order2_parts` publishes it verbatim as the value channel
2937/// (`out[0].0.v = base_log_sum`). Sharing it with the value-only lift is what
2938/// makes `log_likelihood_only` and the joint gradient evaluate ONE function of
2939/// `β` rather than two implementations of one formula (#2714).
2940fn latent_kernel_sum_base<const K: usize>(
2941    quadctx: &QuadratureContext,
2942    base_terms: &[LatentKernelPrimaryTerm],
2943    state: LatentKernelPrimaryState,
2944    primary_directions: &[LatentKernelPrimaryDirection; K],
2945    suffixes: &[&[LatentKernelPrimaryDirection]],
2946    context: &str,
2947) -> Result<(LogLognormalKernelBundle, f64), LatentSurvivalError> {
2948    let max_k = latent_kernel_sum_max_k(base_terms, primary_directions, suffixes);
2949    let bundle =
2950        log_kernel_bundle(quadctx, state.q.exp(), state.mu, state.sigma, max_k).map_err(|e| {
2951            LatentSurvivalError::NumericalFailure {
2952                reason: format!("{context} kernel evaluation failed: {e}"),
2953            }
2954        })?;
2955    let (base_log_sum, base_sign) =
2956        latent_kernel_evaluate_terms(&bundle, state, base_terms, context)?;
2957    if !(base_log_sum.is_finite() && base_sign > 0.0) {
2958        return Err(LatentSurvivalError::NumericalFailure {
2959            reason: format!("{context} produced a non-positive signed kernel sum"),
2960        });
2961    }
2962    Ok((bundle, base_log_sum))
2963}
2964
2965fn latent_kernel_sum_order2_parts<const K: usize>(
2966    quadctx: &QuadratureContext,
2967    base_terms: &[LatentKernelPrimaryTerm],
2968    state: LatentKernelPrimaryState,
2969    primary_directions: &[LatentKernelPrimaryDirection; K],
2970    suffixes: &[&[LatentKernelPrimaryDirection]],
2971    context: &str,
2972) -> Result<[Order2<K>; 4], LatentSurvivalError> {
2973    assert!(
2974        !suffixes.is_empty() && suffixes.len() <= 4,
2975        "latent kernel lift supports one to four order-two parts"
2976    );
2977    let (bundle, base_log_sum) = latent_kernel_sum_base(
2978        quadctx,
2979        base_terms,
2980        state,
2981        primary_directions,
2982        suffixes,
2983        context,
2984    )?;
2985
2986    let evaluate_terms = |terms: &[LatentKernelPrimaryTerm]| {
2987        latent_kernel_evaluate_terms(&bundle, state, terms, context)
2988    };
2989
2990    let normalized = |axes: &[LatentKernelPrimaryDirection],
2991                      suffix: &[LatentKernelPrimaryDirection]|
2992     -> Result<LatentSignedLog, LatentSurvivalError> {
2993        let is_zero = |direction: &LatentKernelPrimaryDirection| {
2994            direction.dq == 0.0
2995                && direction.dqd == 0.0
2996                && direction.dmu == 0.0
2997                && direction.dtau == 0.0
2998        };
2999        if axes.iter().chain(suffix.iter()).any(is_zero) {
3000            return Ok(LatentSignedLog::ZERO);
3001        }
3002        let terms = latent_kernel_term_sequence_inline(base_terms, axes, suffix);
3003        assert!(
3004            !terms.spilled(),
3005            "latent derivative support exceeded the inline allocation-free capacity: {} > {}",
3006            terms.len(),
3007            LATENT_TERM_INLINE_CAPACITY
3008        );
3009        let (log_abs, sign) = evaluate_terms(&terms)?;
3010        latent_signed_log_normalized(log_abs, sign, base_log_sum, context)
3011    };
3012
3013    let mut parts = [LatentSignedLogOrder2::<K>::zero(); 4];
3014    for (part, suffix) in suffixes.iter().enumerate() {
3015        let value = if part == 0 {
3016            // The base is the kernel divided by itself.
3017            LatentSignedLog::ONE
3018        } else {
3019            normalized(&[], suffix)?
3020        };
3021        let mut tower = LatentSignedLogOrder2::<K>::zero();
3022        tower.v = value;
3023        for a in 0..K {
3024            tower.g[a] = normalized(&[primary_directions[a]], suffix)?;
3025        }
3026        for a in 0..K {
3027            for b in a..K {
3028                let derivative =
3029                    normalized(&[primary_directions[a], primary_directions[b]], suffix)?;
3030                tower.h[a][b] = derivative;
3031                tower.h[b][a] = derivative;
3032            }
3033        }
3034        parts[part] = tower;
3035    }
3036    latent_kernel_signed_log_parts(
3037        base_log_sum,
3038        parts,
3039        suffixes.len(),
3040        context,
3041    )
3042}
3043
3044/// Convert normalized signed-log kernel moments into derivatives of the log sum.
3045///
3046/// `normalized_parts` is the single analytic recurrence's compact moment
3047/// layout: the base carries `(1, S_a/S, S_ab/S)`, the one-seed parts carry
3048/// `(S_u/S, S_au/S, S_abu/S)`, and the two-seed cross part carries `(S_uv/S,
3049/// S_auv/S, S_abuv/S)`. For each requested output channel those moments become
3050/// a four-slot `(a,b,u,v)` table for [`latent_certified_cumulants`]. Each
3051/// derivative is published only after its exact expansion has certified the
3052/// unique rounded result.
3053fn latent_kernel_signed_log_parts<const K: usize>(
3054    base_log_sum: f64,
3055    normalized_parts: [LatentSignedLogOrder2<K>; 4],
3056    part_count: usize,
3057    context: &str,
3058) -> Result<[Order2<K>; 4], LatentSurvivalError> {
3059    assert!(matches!(part_count, 1 | 2 | 4));
3060    let compose_log = |moments: [LatentSignedLog; 16], target_mask: usize| {
3061        latent_certified_cumulants(moments, target_mask, context)
3062    };
3063    let moments_for = |a: usize, b: usize| {
3064        let base = &normalized_parts[0];
3065        let u = &normalized_parts[1];
3066        let v = &normalized_parts[2];
3067        let uv = &normalized_parts[3];
3068        [
3069            LatentSignedLog::ONE,
3070            base.g[a],
3071            base.g[b],
3072            base.h[a][b],
3073            u.v,
3074            u.g[a],
3075            u.g[b],
3076            u.h[a][b],
3077            v.v,
3078            v.g[a],
3079            v.g[b],
3080            v.h[a][b],
3081            uv.v,
3082            uv.g[a],
3083            uv.g[b],
3084            uv.h[a][b],
3085        ]
3086    };
3087
3088    let mut out = [Order2::<K>::constant(0.0); 4];
3089    out[0].0.v = base_log_sum;
3090    if part_count >= 2 {
3091        out[1].0.v = latent_signed_log_materialize(normalized_parts[1].v, context)?;
3092    }
3093    if part_count == 4 {
3094        out[2].0.v = latent_signed_log_materialize(normalized_parts[2].v, context)?;
3095        let composed = compose_log(moments_for(0, 0), 0b1100)?;
3096        out[3].0.v = composed[0b1100].value;
3097    }
3098
3099    let (gradient_mask, hessian_mask) = match part_count {
3100        1 => (0b0001, 0b0011),
3101        2 => (0b0101, 0b0111),
3102        4 => (0b1101, 0b1111),
3103        other => {
3104            return Err(LatentSurvivalError::NumericalFailure {
3105                reason: format!(
3106                    "{context} composed a latent moment jet over {other} parts; \
3107                     only 1, 2, or 4 are constructible"
3108                ),
3109            })
3110        }
3111    };
3112    for a in 0..K {
3113        let composed = compose_log(moments_for(a, a), gradient_mask)?;
3114        out[0].0.g[a] = composed[0b0001].value;
3115        if part_count >= 2 {
3116            out[1].0.g[a] = composed[0b0101].value;
3117        }
3118        if part_count == 4 {
3119            out[2].0.g[a] = composed[0b1001].value;
3120            out[3].0.g[a] = composed[0b1101].value;
3121        }
3122        for b in a..K {
3123            let composed = compose_log(moments_for(a, b), hessian_mask)?;
3124            out[0].0.h[a][b] = composed[0b0011].value;
3125            if part_count >= 2 {
3126                out[1].0.h[a][b] = composed[0b0111].value;
3127            }
3128            if part_count == 4 {
3129                out[2].0.h[a][b] = composed[0b1011].value;
3130                out[3].0.h[a][b] = composed[0b1111].value;
3131            }
3132            for part in 0..part_count {
3133                out[part].0.h[b][a] = out[part].0.h[a][b];
3134            }
3135        }
3136    }
3137    Ok(out)
3138}
3139
3140#[inline]
3141fn latent_kernel_direction_linear_combination<const K: usize>(
3142    primary_directions: &[LatentKernelPrimaryDirection; K],
3143    coefficients: &[f64; K],
3144) -> LatentKernelPrimaryDirection {
3145    let mut out = LatentKernelPrimaryDirection {
3146        dq: 0.0,
3147        dqd: 0.0,
3148        dmu: 0.0,
3149        dtau: 0.0,
3150    };
3151    for a in 0..K {
3152        out.dq += coefficients[a] * primary_directions[a].dq;
3153        out.dqd += coefficients[a] * primary_directions[a].dqd;
3154        out.dmu += coefficients[a] * primary_directions[a].dmu;
3155        out.dtau += coefficients[a] * primary_directions[a].dtau;
3156    }
3157    out
3158}
3159
3160fn latent_order2_all_finite<const K: usize>(jet: &Order2<K>) -> bool {
3161    jet.value().is_finite()
3162        && jet.g().iter().all(|value| value.is_finite())
3163        && jet
3164            .h()
3165            .iter()
3166            .flatten()
3167            .all(|value| value.is_finite())
3168}
3169
3170/// Backend seam for the single latent-survival row expression.  Only the
3171/// analytic multivariate kernel primitive differs by requested channel; all
3172/// numerator/denominator/event algebra below is instantiated unchanged.
3173trait LatentPrimaryJetBackend<const K: usize> {
3174    type Jet: JetScalar<K>;
3175
3176    fn derivative_order(&self) -> usize;
3177    fn all_channels_finite(&self, jet: &Self::Jet) -> bool;
3178
3179    fn kernel_sum_log(
3180        &self,
3181        quadctx: &QuadratureContext,
3182        base_terms: &[LatentKernelPrimaryTerm],
3183        state: LatentKernelPrimaryState,
3184        primary_directions: &[LatentKernelPrimaryDirection; K],
3185        context: &str,
3186    ) -> Result<Self::Jet, LatentSurvivalError>;
3187}
3188
3189#[derive(Clone, Copy)]
3190struct LatentOrder2Backend;
3191
3192impl<const K: usize> LatentPrimaryJetBackend<K> for LatentOrder2Backend {
3193    type Jet = Order2<K>;
3194
3195    fn derivative_order(&self) -> usize {
3196        2
3197    }
3198
3199    fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3200        latent_order2_all_finite(jet)
3201    }
3202
3203    fn kernel_sum_log(
3204        &self,
3205        quadctx: &QuadratureContext,
3206        base_terms: &[LatentKernelPrimaryTerm],
3207        state: LatentKernelPrimaryState,
3208        primary_directions: &[LatentKernelPrimaryDirection; K],
3209        context: &str,
3210    ) -> Result<Self::Jet, LatentSurvivalError> {
3211        let suffixes: [&[LatentKernelPrimaryDirection]; 1] = [&[]];
3212        let parts = latent_kernel_sum_order2_parts(
3213            quadctx,
3214            base_terms,
3215            state,
3216            primary_directions,
3217            &suffixes,
3218            context,
3219        )?;
3220        Ok(parts[0])
3221    }
3222}
3223
3224/// Value-only backend: the SAME row expression, evaluated for its value channel
3225/// and nothing else (#2714).
3226///
3227/// This is what `log_likelihood_only` — the scalar the joint-Newton trust region
3228/// evaluates at the TRIAL β — goes through, so that the accept test measures the
3229/// value of the function whose gradient the step was built from, rather than a
3230/// second implementation of the same formula. The trust ratio's numerator
3231/// differences `old_objective` (built from the gradient hook's log-likelihood)
3232/// against `trial_objective` (built from this one); a gap between them is a
3233/// constant of the backtracking ladder that shrinking the radius cannot remove.
3234///
3235/// **`K` is deliberately the same `K` the derivative backends use** even though
3236/// no derivative is produced. [`latent_kernel_sum_max_k`] sizes the kernel
3237/// bundle from the primary directions, and the bundle's `max_k` decides both
3238/// which rungs exist and how long the `∂_a` tower is — so a backend that dropped
3239/// the directions would build a SHORTER bundle and could be routed to a
3240/// different basis for the same term list. Same `K` ⇒ same bundle ⇒ same basis ⇒
3241/// the value is bit-identical, which is the entire point.
3242///
3243/// `derivative_order = 0` because no derivative channel is filled: it reaches
3244/// only `latent_survival_positive_log_difference_jet`, where it means the
3245/// interval branch validates the unary composition to order 0. A value-only
3246/// evaluation must not refuse because some derivative of the log-gap is
3247/// unrepresentable.
3248#[derive(Clone, Copy)]
3249struct LatentValueBackend;
3250
3251impl<const K: usize> LatentPrimaryJetBackend<K> for LatentValueBackend {
3252    type Jet = Order2<K>;
3253
3254    fn derivative_order(&self) -> usize {
3255        0
3256    }
3257
3258    fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3259        latent_order2_all_finite(jet)
3260    }
3261
3262    fn kernel_sum_log(
3263        &self,
3264        quadctx: &QuadratureContext,
3265        base_terms: &[LatentKernelPrimaryTerm],
3266        state: LatentKernelPrimaryState,
3267        primary_directions: &[LatentKernelPrimaryDirection; K],
3268        context: &str,
3269    ) -> Result<Self::Jet, LatentSurvivalError> {
3270        // The same `suffixes` the order-two backend passes, so
3271        // `latent_kernel_sum_max_k` returns the same ceiling.
3272        let suffixes: [&[LatentKernelPrimaryDirection]; 1] = [&[]];
3273        let (_, base_log_sum) = latent_kernel_sum_base(
3274            quadctx,
3275            base_terms,
3276            state,
3277            primary_directions,
3278            &suffixes,
3279            context,
3280        )?;
3281        Ok(Order2::<K>::constant(base_log_sum))
3282    }
3283}
3284
3285#[derive(Clone, Copy)]
3286struct LatentOneSeedBackend<const K: usize> {
3287    direction: [f64; K],
3288}
3289
3290impl<const K: usize> LatentPrimaryJetBackend<K> for LatentOneSeedBackend<K> {
3291    type Jet = OneSeed<K>;
3292
3293    fn derivative_order(&self) -> usize {
3294        3
3295    }
3296
3297    fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3298        latent_order2_all_finite(&jet.base) && latent_order2_all_finite(&jet.eps)
3299    }
3300
3301    fn kernel_sum_log(
3302        &self,
3303        quadctx: &QuadratureContext,
3304        base_terms: &[LatentKernelPrimaryTerm],
3305        state: LatentKernelPrimaryState,
3306        primary_directions: &[LatentKernelPrimaryDirection; K],
3307        context: &str,
3308    ) -> Result<Self::Jet, LatentSurvivalError> {
3309        let seed = latent_kernel_direction_linear_combination(primary_directions, &self.direction);
3310        let seed_suffix = [seed];
3311        let suffixes: [&[LatentKernelPrimaryDirection]; 2] = [&[], &seed_suffix];
3312        let parts = latent_kernel_sum_order2_parts(
3313            quadctx,
3314            base_terms,
3315            state,
3316            primary_directions,
3317            &suffixes,
3318            context,
3319        )?;
3320        Ok(OneSeed {
3321            base: parts[0],
3322            eps: parts[1],
3323        })
3324    }
3325}
3326
3327#[derive(Clone, Copy)]
3328struct LatentTwoSeedBackend<const K: usize> {
3329    direction_u: [f64; K],
3330    direction_v: [f64; K],
3331}
3332
3333impl<const K: usize> LatentPrimaryJetBackend<K> for LatentTwoSeedBackend<K> {
3334    type Jet = TwoSeed<K>;
3335
3336    fn derivative_order(&self) -> usize {
3337        4
3338    }
3339
3340    fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3341        latent_order2_all_finite(&jet.base)
3342            && latent_order2_all_finite(&jet.eps)
3343            && latent_order2_all_finite(&jet.del)
3344            && latent_order2_all_finite(&jet.eps_del)
3345    }
3346
3347    fn kernel_sum_log(
3348        &self,
3349        quadctx: &QuadratureContext,
3350        base_terms: &[LatentKernelPrimaryTerm],
3351        state: LatentKernelPrimaryState,
3352        primary_directions: &[LatentKernelPrimaryDirection; K],
3353        context: &str,
3354    ) -> Result<Self::Jet, LatentSurvivalError> {
3355        let seed_u =
3356            latent_kernel_direction_linear_combination(primary_directions, &self.direction_u);
3357        let seed_v =
3358            latent_kernel_direction_linear_combination(primary_directions, &self.direction_v);
3359        let suffix_u = [seed_u];
3360        let suffix_v = [seed_v];
3361        let suffix_uv = [seed_u, seed_v];
3362        let suffixes: [&[LatentKernelPrimaryDirection]; 4] =
3363            [&[], &suffix_u, &suffix_v, &suffix_uv];
3364        let parts = latent_kernel_sum_order2_parts(
3365            quadctx,
3366            base_terms,
3367            state,
3368            primary_directions,
3369            &suffixes,
3370            context,
3371        )?;
3372        Ok(TwoSeed {
3373            base: parts[0],
3374            eps: parts[1],
3375            del: parts[2],
3376            eps_del: parts[3],
3377        })
3378    }
3379}
3380
3381fn latent_survival_basis_direction(primary_idx: usize) -> LatentSurvivalPrimaryDirection {
3382    match primary_idx {
3383        LATENT_SURVIVAL_PRIMARY_Q_ENTRY => LatentSurvivalPrimaryDirection {
3384            dq_entry: 1.0,
3385            dq_exit: 0.0,
3386            dqdot_exit: 0.0,
3387            dq_right: 0.0,
3388            dmu: 0.0,
3389            dlog_sigma: 0.0,
3390        },
3391        LATENT_SURVIVAL_PRIMARY_Q_EXIT => LatentSurvivalPrimaryDirection {
3392            dq_entry: 0.0,
3393            dq_exit: 1.0,
3394            dqdot_exit: 0.0,
3395            dq_right: 0.0,
3396            dmu: 0.0,
3397            dlog_sigma: 0.0,
3398        },
3399        LATENT_SURVIVAL_PRIMARY_QDOT_EXIT => LatentSurvivalPrimaryDirection {
3400            dq_entry: 0.0,
3401            dq_exit: 0.0,
3402            dqdot_exit: 1.0,
3403            dq_right: 0.0,
3404            dmu: 0.0,
3405            dlog_sigma: 0.0,
3406        },
3407        LATENT_SURVIVAL_PRIMARY_Q_RIGHT => LatentSurvivalPrimaryDirection {
3408            dq_entry: 0.0,
3409            dq_exit: 0.0,
3410            dqdot_exit: 0.0,
3411            dq_right: 1.0,
3412            dmu: 0.0,
3413            dlog_sigma: 0.0,
3414        },
3415        LATENT_SURVIVAL_PRIMARY_MU => LatentSurvivalPrimaryDirection {
3416            dq_entry: 0.0,
3417            dq_exit: 0.0,
3418            dqdot_exit: 0.0,
3419            dq_right: 0.0,
3420            dmu: 1.0,
3421            dlog_sigma: 0.0,
3422        },
3423        LATENT_SURVIVAL_PRIMARY_LOG_SIGMA => LatentSurvivalPrimaryDirection {
3424            dq_entry: 0.0,
3425            dq_exit: 0.0,
3426            dqdot_exit: 0.0,
3427            dq_right: 0.0,
3428            dmu: 0.0,
3429            dlog_sigma: 1.0,
3430        },
3431        // SAFETY: latent survival has exactly `LATENT_SURVIVAL_PRIMARY_DIM`
3432        // (= 5) primary directions, indexed 0..=4 via the module-private
3433        // `LATENT_SURVIVAL_PRIMARY_*` constants. All five are matched
3434        // above, so this wildcard fires only on an out-of-range index,
3435        // which the internal iteration bounds (`0..LATENT_SURVIVAL_PRIMARY_DIM`)
3436        // make unreachable.
3437        // SAFETY: primary_idx is bounded by LATENT_SURVIVAL_PRIMARY_DIM at every internal call site.
3438        _ => std::panic::panic_any(format!(
3439            "latent survival primary index out of bounds: primary_idx={primary_idx}, primary_dim={LATENT_SURVIVAL_PRIMARY_DIM}"
3440        )),
3441    }
3442}
3443
3444fn latent_survival_map_entry_direction(
3445    direction: LatentSurvivalPrimaryDirection,
3446) -> LatentKernelPrimaryDirection {
3447    LatentKernelPrimaryDirection {
3448        dq: direction.dq_entry,
3449        dqd: 0.0,
3450        dmu: direction.dmu,
3451        dtau: direction.dlog_sigma,
3452    }
3453}
3454
3455fn latent_survival_map_exit_direction(
3456    direction: LatentSurvivalPrimaryDirection,
3457    event_type: LatentSurvivalEventType,
3458) -> LatentKernelPrimaryDirection {
3459    LatentKernelPrimaryDirection {
3460        dq: direction.dq_exit,
3461        dqd: if matches!(event_type, LatentSurvivalEventType::ExactEvent) {
3462            direction.dqdot_exit
3463        } else {
3464            0.0
3465        },
3466        dmu: direction.dmu,
3467        dtau: direction.dlog_sigma,
3468    }
3469}
3470
3471/// Direction map for the interval-censored LEFT boundary state (mass `M_L =
3472/// exp(q_exit)`). The left boundary tracks the same `q_exit` time functional as
3473/// right-censoring (no hazard-derivative channel), plus the shared `mu`/`sigma`.
3474fn latent_survival_map_left_direction(
3475    direction: LatentSurvivalPrimaryDirection,
3476) -> LatentKernelPrimaryDirection {
3477    LatentKernelPrimaryDirection {
3478        dq: direction.dq_exit,
3479        dqd: 0.0,
3480        dmu: direction.dmu,
3481        dtau: direction.dlog_sigma,
3482    }
3483}
3484
3485/// Direction map for the interval-censored RIGHT boundary state (mass `M_R =
3486/// exp(q_right)`). The right boundary tracks the dedicated `q_right` functional
3487/// (which shares the time-block coefficients with `q_exit` but is evaluated at
3488/// the interval upper bound `R`), plus the shared `mu`/`sigma`.
3489fn latent_survival_map_right_direction(
3490    direction: LatentSurvivalPrimaryDirection,
3491) -> LatentKernelPrimaryDirection {
3492    LatentKernelPrimaryDirection {
3493        dq: direction.dq_right,
3494        dqd: 0.0,
3495        dmu: direction.dmu,
3496        dtau: direction.dlog_sigma,
3497    }
3498}
3499
3500#[cfg(test)]
3501mod tests_multidir_row {
3502    use super::tests_multidir_kernel::latent_kernel_sum_log_jet;
3503    use super::*;
3504    use gam_math::jet_partitions::MultiDirJet as LatentMultiDirJet;
3505
3506    pub(super) fn latent_survival_row_primary_log_jet_multidir_reference(
3507        quadctx: &QuadratureContext,
3508        row: &LatentSurvivalRow,
3509        point: LatentSurvivalPrimaryPoint,
3510        directions: &[LatentSurvivalPrimaryDirection],
3511    ) -> Result<LatentMultiDirJet, String> {
3512        let LatentSurvivalPrimaryPoint {
3513            q_entry,
3514            q_exit,
3515            qdot_exit,
3516            mu,
3517            sigma,
3518            ..
3519        } = point;
3520        let log_sigma_factor = point.log_sigma_factor();
3521        let entry_state = LatentKernelPrimaryState {
3522            q: q_entry,
3523            qdot: 1.0,
3524            mu,
3525            sigma,
3526            log_sigma_factor,
3527        };
3528        let entry_directions = directions
3529            .iter()
3530            .copied()
3531            .map(latent_survival_map_entry_direction)
3532            .collect::<Vec<_>>();
3533
3534        let denominator = latent_kernel_sum_log_jet(
3535            quadctx,
3536            &[LatentKernelPrimaryTerm {
3537                coeff: 1.0,
3538                q_exp: 0,
3539                qdot_power: 0,
3540                tau_exp: 0,
3541                k: 0,
3542            }],
3543            entry_state,
3544            &entry_directions,
3545            "latent survival denominator",
3546        )?;
3547
3548        // The numerator for right-censoring / exact events is a single-state log-sum
3549        // kernel at the exit mass. Interval censoring is the difference of two
3550        // single-state kernels at DIFFERENT masses (L at `q_exit`, R at `q_right`),
3551        // so it is assembled by `latent_survival_interval_numerator_log_jet` below.
3552        let numerator = match row.event_type {
3553            LatentSurvivalEventType::RightCensored | LatentSurvivalEventType::ExactEvent => {
3554                let exit_state = LatentKernelPrimaryState {
3555                    q: q_exit,
3556                    qdot: qdot_exit,
3557                    mu,
3558                    sigma,
3559                    log_sigma_factor,
3560                };
3561                let exit_directions = directions
3562                    .iter()
3563                    .copied()
3564                    .map(|dir| latent_survival_map_exit_direction(dir, row.event_type))
3565                    .collect::<Vec<_>>();
3566                let numerator_terms = match row.event_type {
3567                    LatentSurvivalEventType::RightCensored => vec![LatentKernelPrimaryTerm {
3568                        coeff: 1.0,
3569                        q_exp: 0,
3570                        qdot_power: 0,
3571                        tau_exp: 0,
3572                        k: 0,
3573                    }],
3574                    LatentSurvivalEventType::ExactEvent => {
3575                        let mut terms = Vec::new();
3576                        if row.hazard_unloaded > 0.0 {
3577                            terms.push(LatentKernelPrimaryTerm {
3578                                coeff: row.hazard_unloaded,
3579                                q_exp: 0,
3580                                qdot_power: 0,
3581                                tau_exp: 0,
3582                                k: 0,
3583                            });
3584                        }
3585                        terms.push(LatentKernelPrimaryTerm {
3586                            coeff: 1.0,
3587                            q_exp: 1,
3588                            qdot_power: 1,
3589                            tau_exp: 0,
3590                            k: 1,
3591                        });
3592                        terms
3593                    }
3594                    LatentSurvivalEventType::IntervalCensored => {
3595                        // Interval-censored rows are routed to the dedicated two-state
3596                        // numerator branch (the outer match arm below), so this inner
3597                        // arm is not reached; a clean error rather than a panic guards
3598                        // against a future routing change.
3599                        return Err(
3600                            "interval-censored row reached the single-state numerator branch; \
3601                         it must take the dedicated two-state branch"
3602                                .to_string(),
3603                        );
3604                    }
3605                };
3606                latent_kernel_sum_log_jet(
3607                    quadctx,
3608                    &numerator_terms,
3609                    exit_state,
3610                    &exit_directions,
3611                    "latent survival numerator",
3612                )?
3613            }
3614            LatentSurvivalEventType::IntervalCensored => {
3615                latent_survival_interval_numerator_log_jet_multidir_reference(
3616                    quadctx, row, point, directions,
3617                )?
3618            }
3619        };
3620
3621        let mut total = numerator.add(&denominator.scale(-1.0));
3622        // For interval rows the unloaded exit mass is folded into the per-boundary
3623        // coefficients `exp(-mass_unloaded_{left,right})` inside the two-state
3624        // numerator, so only the (constant) unloaded-entry term remains here; for
3625        // right-censoring / exact events the exit/entry unloaded masses are an
3626        // additive constant on the log-likelihood.
3627        match row.event_type {
3628            LatentSurvivalEventType::IntervalCensored => {
3629                total.coeffs[0] += row.mass_unloaded_entry;
3630            }
3631            _ => {
3632                total.coeffs[0] += -row.mass_unloaded_exit + row.mass_unloaded_entry;
3633            }
3634        }
3635        Ok(total)
3636    }
3637
3638    /// Interval-censored numerator jet `log[ c_L·K_{0,M_L} − c_R·K_{0,M_R} ]` where
3639    /// `M_L = exp(q_exit)`, `M_R = exp(q_right)`, `c_L = exp(-mass_unloaded_left)`
3640    /// and `c_R = exp(-mass_unloaded_right)`.
3641    ///
3642    /// The two boundary kernels use distinct states and direction maps, but their
3643    /// difference stays in log space:
3644    ///
3645    /// ```text
3646    /// log(c_L K_L - c_R K_R)
3647    ///   = log(c_L K_L) + log1mexp(log(c_R K_R) - log(c_L K_L)).
3648    /// ```
3649    ///
3650    /// The same certified unary stack as production is composed over the
3651    /// multi-direction oracle, so absolute tail mass cannot underflow and the
3652    /// reference cannot silently publish a non-finite higher coefficient.
3653    fn latent_survival_interval_numerator_log_jet_multidir_reference(
3654        quadctx: &QuadratureContext,
3655        row: &LatentSurvivalRow,
3656        point: LatentSurvivalPrimaryPoint,
3657        directions: &[LatentSurvivalPrimaryDirection],
3658    ) -> Result<LatentMultiDirJet, String> {
3659        let LatentSurvivalPrimaryPoint {
3660            q_exit,
3661            q_right,
3662            mu,
3663            sigma,
3664            ..
3665        } = point;
3666        let log_sigma_factor = point.log_sigma_factor();
3667        let single_k0 = [LatentKernelPrimaryTerm {
3668            coeff: 1.0,
3669            q_exp: 0,
3670            qdot_power: 0,
3671            tau_exp: 0,
3672            k: 0,
3673        }];
3674
3675        let left_state = LatentKernelPrimaryState {
3676            q: q_exit,
3677            qdot: 1.0,
3678            mu,
3679            sigma,
3680            log_sigma_factor,
3681        };
3682        let right_state = LatentKernelPrimaryState {
3683            q: q_right,
3684            qdot: 1.0,
3685            mu,
3686            sigma,
3687            log_sigma_factor,
3688        };
3689        let left_directions = directions
3690            .iter()
3691            .copied()
3692            .map(latent_survival_map_left_direction)
3693            .collect::<Vec<_>>();
3694        let right_directions = directions
3695            .iter()
3696            .copied()
3697            .map(latent_survival_map_right_direction)
3698            .collect::<Vec<_>>();
3699
3700        let log_left = latent_kernel_sum_log_jet(
3701            quadctx,
3702            &single_k0,
3703            left_state,
3704            &left_directions,
3705            "latent survival interval left boundary",
3706        )?;
3707        let log_right = latent_kernel_sum_log_jet(
3708            quadctx,
3709            &single_k0,
3710            right_state,
3711            &right_directions,
3712            "latent survival interval right boundary",
3713        )?;
3714
3715        // `MultiDirJet` is the runtime-width test oracle rather than a
3716        // `JetField`, so spell out the same log-domain identity while sharing
3717        // the certified unary derivative stack with production.
3718        let weighted_left = log_left.add(&LatentMultiDirJet::constant(
3719            directions.len(),
3720            -row.mass_unloaded_left,
3721        ));
3722        let weighted_right = log_right.add(&LatentMultiDirJet::constant(
3723            directions.len(),
3724            -row.mass_unloaded_right,
3725        ));
3726        let delta = weighted_right.sub(&weighted_left);
3727        let delta_value = delta.coeff(0);
3728        if !(delta_value.is_finite() && delta_value < 0.0) {
3729            return Err(LatentSurvivalError::NumericalFailure {
3730                reason: format!(
3731                    "latent survival interval numerator must be a positive \
3732                     survival-mass difference: log(c_L*K0(M_L))={:?}, \
3733                     log(c_R*K0(M_R))={:?}; require M_L < M_R (i.e. L < R)",
3734                    weighted_left.coeff(0),
3735                    weighted_right.coeff(0),
3736                ),
3737            }
3738            .to_string());
3739        }
3740        let derivatives = latent_unary_derivatives_log1mexp_negative(
3741            delta_value,
3742            directions.len(),
3743            "latent survival interval numerator",
3744        )
3745        .map_err(|error| error.to_string())?;
3746        let out = weighted_left.add(&delta.compose_unary(derivatives));
3747        if !out.coeffs.iter().all(|value| value.is_finite()) {
3748            return Err(LatentSurvivalError::NumericalFailure {
3749                reason: format!(
3750                    "latent survival interval numerator derivative jet is not \
3751                     representable at log-boundary gap {delta_value:?}"
3752                ),
3753            }
3754            .to_string());
3755        }
3756        Ok(out)
3757    }
3758}
3759
3760/// The single latent-survival row program, instantiated at an order-two,
3761/// one-seed, or two-seed backend.  Event dispatch and the interval
3762/// log-difference are intentionally expressed once here; a backend changes only
3763/// the derivative layout used to lift each analytic kernel-sum primitive.
3764fn latent_survival_row_primary_jet<const K: usize, B: LatentPrimaryJetBackend<K>>(
3765    backend: &B,
3766    quadctx: &QuadratureContext,
3767    row: &LatentSurvivalRow,
3768    point: LatentSurvivalPrimaryPoint,
3769) -> Result<B::Jet, LatentSurvivalError> {
3770    let LatentSurvivalPrimaryPoint {
3771        q_entry,
3772        q_exit,
3773        qdot_exit,
3774        mu,
3775        sigma,
3776        ..
3777    } = point;
3778    let log_sigma_factor = point.log_sigma_factor();
3779    let entry_state = LatentKernelPrimaryState {
3780        q: q_entry,
3781        qdot: 1.0,
3782        mu,
3783        sigma,
3784        log_sigma_factor,
3785    };
3786    let entry_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3787        latent_survival_map_entry_direction(latent_survival_basis_direction(a))
3788    });
3789    let denominator = backend
3790        .kernel_sum_log(
3791            quadctx,
3792            &[LatentKernelPrimaryTerm {
3793                coeff: 1.0,
3794                q_exp: 0,
3795                qdot_power: 0,
3796                tau_exp: 0,
3797                k: 0,
3798            }],
3799            entry_state,
3800            &entry_directions,
3801            "latent survival denominator",
3802        )?;
3803
3804    let numerator = match row.event_type {
3805        LatentSurvivalEventType::RightCensored => {
3806            let exit_state = LatentKernelPrimaryState {
3807                q: q_exit,
3808                qdot: qdot_exit,
3809                mu,
3810                sigma,
3811                log_sigma_factor,
3812            };
3813            let exit_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3814                latent_survival_map_exit_direction(
3815                    latent_survival_basis_direction(a),
3816                    row.event_type,
3817                )
3818            });
3819            backend
3820                .kernel_sum_log(
3821                    quadctx,
3822                    &[LatentKernelPrimaryTerm {
3823                        coeff: 1.0,
3824                        q_exp: 0,
3825                        qdot_power: 0,
3826                        tau_exp: 0,
3827                        k: 0,
3828                    }],
3829                    exit_state,
3830                    &exit_directions,
3831                    "latent survival numerator",
3832                )?
3833        }
3834        LatentSurvivalEventType::ExactEvent => {
3835            let exit_state = LatentKernelPrimaryState {
3836                q: q_exit,
3837                qdot: qdot_exit,
3838                mu,
3839                sigma,
3840                log_sigma_factor,
3841            };
3842            let exit_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3843                latent_survival_map_exit_direction(
3844                    latent_survival_basis_direction(a),
3845                    LatentSurvivalEventType::ExactEvent,
3846                )
3847            });
3848            // A zero unloaded-hazard term remains in this stack array but the
3849            // signed-log evaluator and derivative recurrences discard it.
3850            let numerator_terms = [
3851                LatentKernelPrimaryTerm {
3852                    coeff: row.hazard_unloaded,
3853                    q_exp: 0,
3854                    qdot_power: 0,
3855                    tau_exp: 0,
3856                    k: 0,
3857                },
3858                LatentKernelPrimaryTerm {
3859                    coeff: 1.0,
3860                    q_exp: 1,
3861                    qdot_power: 1,
3862                    tau_exp: 0,
3863                    k: 1,
3864                },
3865            ];
3866            backend
3867                .kernel_sum_log(
3868                    quadctx,
3869                    &numerator_terms,
3870                    exit_state,
3871                    &exit_directions,
3872                    "latent survival numerator",
3873                )?
3874        }
3875        LatentSurvivalEventType::IntervalCensored => {
3876            latent_survival_interval_numerator_jet(backend, quadctx, row, point)?
3877        }
3878    };
3879
3880    let unloaded_offset = match row.event_type {
3881        LatentSurvivalEventType::IntervalCensored => row.mass_unloaded_entry,
3882        _ => -row.mass_unloaded_exit + row.mass_unloaded_entry,
3883    };
3884    Ok(numerator
3885        .sub(&denominator)
3886        .add(&B::Jet::constant(unloaded_offset)))
3887}
3888
3889fn latent_survival_interval_numerator_jet<const K: usize, B: LatentPrimaryJetBackend<K>>(
3890    backend: &B,
3891    quadctx: &QuadratureContext,
3892    row: &LatentSurvivalRow,
3893    point: LatentSurvivalPrimaryPoint,
3894) -> Result<B::Jet, LatentSurvivalError> {
3895    let LatentSurvivalPrimaryPoint {
3896        q_exit,
3897        q_right,
3898        mu,
3899        sigma,
3900        ..
3901    } = point;
3902    let log_sigma_factor = point.log_sigma_factor();
3903    let single_k0 = [LatentKernelPrimaryTerm {
3904        coeff: 1.0,
3905        q_exp: 0,
3906        qdot_power: 0,
3907        tau_exp: 0,
3908        k: 0,
3909    }];
3910    let left_state = LatentKernelPrimaryState {
3911        q: q_exit,
3912        qdot: 1.0,
3913        mu,
3914        sigma,
3915        log_sigma_factor,
3916    };
3917    let right_state = LatentKernelPrimaryState {
3918        q: q_right,
3919        qdot: 1.0,
3920        mu,
3921        sigma,
3922        log_sigma_factor,
3923    };
3924    let left_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3925        latent_survival_map_left_direction(latent_survival_basis_direction(a))
3926    });
3927    let right_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3928        latent_survival_map_right_direction(latent_survival_basis_direction(a))
3929    });
3930    let log_left = backend
3931        .kernel_sum_log(
3932            quadctx,
3933            &single_k0,
3934            left_state,
3935            &left_directions,
3936            "latent survival interval left boundary",
3937        )?;
3938    let log_right = backend
3939        .kernel_sum_log(
3940            quadctx,
3941            &single_k0,
3942            right_state,
3943            &right_directions,
3944            "latent survival interval right boundary",
3945        )?;
3946
3947    latent_survival_positive_log_difference_jet(
3948        &log_left,
3949        -row.mass_unloaded_left,
3950        &log_right,
3951        -row.mass_unloaded_right,
3952        backend.derivative_order(),
3953        "latent survival interval numerator",
3954        |jet| backend.all_channels_finite(jet),
3955    )
3956}
3957
3958/// # Accuracy of the `log σ` curvature (#2566)
3959///
3960/// The value and gradient channels are trustworthy across the whole σ range. The
3961/// **second-derivative** channel is not, and the boundary has been measured
3962/// rather than estimated.
3963///
3964/// A 0.05-step sweep of `log σ` on the #2566 fixture
3965/// (`zz_measure_2566_curvature_fine_sweep`) shows `value` and `gradient` smooth
3966/// and monotone to every printed digit while the returned curvature degrades
3967/// progressively — relative step-to-step jumps run `0.05, 0.07, 0.19, 0.47, 0.84,
3968/// 2.89, 9.78` — and past `log σ ≈ 5.45` it **changes sign between adjacent
3969/// samples**, repeatedly.
3970///
3971/// That is catastrophic cancellation in forming the second cumulant, not a
3972/// routing switch: a branch gives a consistently wrong value on one side of a
3973/// threshold, whereas this oscillates. Three consequences the measurements
3974/// already settled, so nobody has to re-derive them:
3975///
3976/// * more quadrature resolution MOVES the crossing and cannot remove it — going
3977///   513 → 1025 nodes bought 335× at `log σ = 5` and left `log σ ≥ 6` untouched,
3978///   because the cancelled quantity keeps shrinking while the roundoff floor does
3979///   not;
3980/// * `max_k` is not the mechanism (the bundle mode is constant in `k` over
3981///   `0..8`), and `IntegratedExpectationMode` cannot flag it — the mode is
3982///   `ControlledAsymptotic` at both the healthy `log σ = 4` and the inverted
3983///   `log σ = 7`;
3984/// * past the crossing the curvature stops tracking its inputs at all: a node
3985///   change that moved the gradient channel 34% left the Hessian unmoved.
3986///
3987/// **Usable to `log σ ≈ 5.4` on this fixture.** Beyond it there is no correct
3988/// value to return, so a consumer needing a definite Hessian must refuse rather
3989/// than scale its tolerance. The discriminator to refuse ON already exists — the
3990/// gate's Richardson construction reads `0.853` at `log σ = 4` against `109.765`
3991/// at `log σ = 6` — and exporting it is #2566's remaining work. A genuine repair
3992/// needs the cumulant formed without the cancelling difference, which is a
3993/// reformulation rather than a tolerance.
3994fn latent_survival_row_primary_gradient_hessian(
3995    quadctx: &QuadratureContext,
3996    row: &LatentSurvivalRow,
3997    point: LatentSurvivalPrimaryPoint,
3998    include_log_sigma: bool,
3999) -> Result<(f64, Array1<f64>, Array2<f64>), LatentSurvivalError> {
4000    if include_log_sigma {
4001        let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4002            &LatentOrder2Backend,
4003            quadctx,
4004            row,
4005            point,
4006        )?;
4007        let out_gradient = out.g();
4008        let hessian = out.h();
4009        Ok((
4010            out.value(),
4011            Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| out_gradient[a]),
4012            Array2::from_shape_fn(
4013                (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4014                |(a, b)| -hessian[a][b],
4015            ),
4016        ))
4017    } else {
4018        let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4019            &LatentOrder2Backend,
4020            quadctx,
4021            row,
4022            point,
4023        )?;
4024        let out_gradient = out.g();
4025        let out_hessian = out.h();
4026        Ok((
4027            out.value(),
4028            Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| {
4029                if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
4030                    out_gradient[a]
4031                } else {
4032                    0.0
4033                }
4034            }),
4035            Array2::from_shape_fn(
4036                (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4037                |(a, b)| {
4038                    if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4039                        && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4040                    {
4041                        -out_hessian[a][b]
4042                    } else {
4043                        0.0
4044                    }
4045                },
4046            ),
4047        ))
4048    }
4049}
4050
4051/// The row log-likelihood, on the SAME surface as
4052/// [`latent_survival_row_primary_gradient_hessian`]'s value channel — and, on a
4053/// bundle whose basis does not depend on the derivative request, bit-identical
4054/// to it (#2714).
4055///
4056/// The `include_log_sigma` switch selects the same `K` the gradient/Hessian
4057/// entry point selects, because `K` is what sizes the kernel bundle.
4058fn latent_survival_row_primary_value(
4059    quadctx: &QuadratureContext,
4060    row: &LatentSurvivalRow,
4061    point: LatentSurvivalPrimaryPoint,
4062    include_log_sigma: bool,
4063) -> Result<f64, LatentSurvivalError> {
4064    if include_log_sigma {
4065        latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4066            &LatentValueBackend,
4067            quadctx,
4068            row,
4069            point,
4070        )
4071        .map(|jet| jet.value())
4072    } else {
4073        latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4074            &LatentValueBackend,
4075            quadctx,
4076            row,
4077            point,
4078        )
4079        .map(|jet| jet.value())
4080    }
4081}
4082
4083fn latent_survival_row_primary_one_seed_fixed_sigma(
4084    quadctx: &QuadratureContext,
4085    row: &LatentSurvivalRow,
4086    point: LatentSurvivalPrimaryPoint,
4087    direction: &Array1<f64>,
4088) -> Result<OneSeed<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA>, LatentSurvivalError> {
4089    let backend = LatentOneSeedBackend {
4090        direction: std::array::from_fn(|a| direction[a]),
4091    };
4092    latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4093        &backend, quadctx, row, point,
4094    )
4095}
4096
4097fn latent_survival_row_primary_two_seed_fixed_sigma(
4098    quadctx: &QuadratureContext,
4099    row: &LatentSurvivalRow,
4100    point: LatentSurvivalPrimaryPoint,
4101    direction_u: &Array1<f64>,
4102    direction_v: &Array1<f64>,
4103) -> Result<TwoSeed<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA>, LatentSurvivalError> {
4104    let backend = LatentTwoSeedBackend {
4105        direction_u: std::array::from_fn(|a| direction_u[a]),
4106        direction_v: std::array::from_fn(|a| direction_v[a]),
4107    };
4108    latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4109        &backend, quadctx, row, point,
4110    )
4111}
4112
4113fn latent_survival_row_primary_third_contracted(
4114    quadctx: &QuadratureContext,
4115    row: &LatentSurvivalRow,
4116    point: LatentSurvivalPrimaryPoint,
4117    direction: &Array1<f64>,
4118    include_log_sigma: bool,
4119) -> Result<Array2<f64>, LatentSurvivalError> {
4120    if include_log_sigma {
4121        let backend = LatentOneSeedBackend {
4122            direction: std::array::from_fn(|a| direction[a]),
4123        };
4124        let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4125            &backend, quadctx, row, point,
4126        )?;
4127        let third = out.contracted_third();
4128        Ok(Array2::from_shape_fn(
4129            (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4130            |(a, b)| -third[a][b],
4131        ))
4132    } else {
4133        let out = latent_survival_row_primary_one_seed_fixed_sigma(quadctx, row, point, direction)?;
4134        let third = out.contracted_third();
4135        Ok(Array2::from_shape_fn(
4136            (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4137            |(a, b)| {
4138                if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
4139                    -third[a][b]
4140                } else {
4141                    0.0
4142                }
4143            },
4144        ))
4145    }
4146}
4147
4148fn latent_survival_row_primary_fourth_contracted(
4149    quadctx: &QuadratureContext,
4150    row: &LatentSurvivalRow,
4151    point: LatentSurvivalPrimaryPoint,
4152    direction_u: &Array1<f64>,
4153    direction_v: &Array1<f64>,
4154    include_log_sigma: bool,
4155) -> Result<Array2<f64>, LatentSurvivalError> {
4156    if include_log_sigma {
4157        let backend = LatentTwoSeedBackend {
4158            direction_u: std::array::from_fn(|a| direction_u[a]),
4159            direction_v: std::array::from_fn(|a| direction_v[a]),
4160        };
4161        let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4162            &backend, quadctx, row, point,
4163        )?;
4164        let fourth = out.contracted_fourth();
4165        Ok(Array2::from_shape_fn(
4166            (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4167            |(a, b)| -fourth[a][b],
4168        ))
4169    } else {
4170        let out = latent_survival_row_primary_two_seed_fixed_sigma(
4171            quadctx,
4172            row,
4173            point,
4174            direction_u,
4175            direction_v,
4176        )?;
4177        let fourth = out.contracted_fourth();
4178        Ok(Array2::from_shape_fn(
4179            (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4180            |(a, b)| {
4181                if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
4182                    -fourth[a][b]
4183                } else {
4184                    0.0
4185                }
4186            },
4187        ))
4188    }
4189}
4190
4191#[cfg(test)]
4192mod tests_multidir_channels {
4193    use super::tests_multidir_row::latent_survival_row_primary_log_jet_multidir_reference;
4194    use super::*;
4195
4196    pub(super) fn latent_survival_row_primary_gradient_hessian_multidir_reference(
4197        quadctx: &QuadratureContext,
4198        row: &LatentSurvivalRow,
4199        point: LatentSurvivalPrimaryPoint,
4200        include_log_sigma: bool,
4201    ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
4202        let mut gradient = Array1::<f64>::zeros(LATENT_SURVIVAL_PRIMARY_DIM);
4203        let mut neg_hessian =
4204            Array2::<f64>::zeros((LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM));
4205        let active_primary = if include_log_sigma {
4206            LATENT_SURVIVAL_PRIMARY_DIM
4207        } else {
4208            LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4209        };
4210        let log_lik =
4211            latent_survival_row_primary_log_jet_multidir_reference(quadctx, row, point, &[])?
4212                .coeff(0);
4213        for a in 0..active_primary {
4214            let dir_a = latent_survival_basis_direction(a);
4215            gradient[a] = latent_survival_row_primary_log_jet_multidir_reference(
4216                quadctx,
4217                row,
4218                point,
4219                &[dir_a],
4220            )?
4221            .coeff(1);
4222            for b in a..active_primary {
4223                let coeff = latent_survival_row_primary_log_jet_multidir_reference(
4224                    quadctx,
4225                    row,
4226                    point,
4227                    &[dir_a, latent_survival_basis_direction(b)],
4228                )?
4229                .coeff(3);
4230                neg_hessian[[a, b]] = -coeff;
4231                neg_hessian[[b, a]] = -coeff;
4232            }
4233        }
4234        Ok((log_lik, gradient, neg_hessian))
4235    }
4236
4237    pub(super) fn latent_survival_row_primary_third_contracted_multidir_reference(
4238        quadctx: &QuadratureContext,
4239        row: &LatentSurvivalRow,
4240        point: LatentSurvivalPrimaryPoint,
4241        direction: &Array1<f64>,
4242        include_log_sigma: bool,
4243    ) -> Result<Array2<f64>, String> {
4244        let active_primary = if include_log_sigma {
4245            LATENT_SURVIVAL_PRIMARY_DIM
4246        } else {
4247            LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4248        };
4249        let dir = LatentSurvivalPrimaryDirection {
4250            dq_entry: direction[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4251            dq_exit: direction[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4252            dqdot_exit: direction[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4253            dq_right: direction[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4254            dmu: direction[LATENT_SURVIVAL_PRIMARY_MU],
4255            dlog_sigma: direction[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4256        };
4257        let mut out =
4258            Array2::<f64>::zeros((LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM));
4259        for a in 0..active_primary {
4260            let dir_a = latent_survival_basis_direction(a);
4261            for b in a..active_primary {
4262                let coeff = latent_survival_row_primary_log_jet_multidir_reference(
4263                    quadctx,
4264                    row,
4265                    point,
4266                    &[dir_a, latent_survival_basis_direction(b), dir],
4267                )?
4268                .coeff(7);
4269                out[[a, b]] = -coeff;
4270                out[[b, a]] = -coeff;
4271            }
4272        }
4273        Ok(out)
4274    }
4275
4276    pub(super) fn latent_survival_row_primary_fourth_contracted_multidir_reference(
4277        quadctx: &QuadratureContext,
4278        row: &LatentSurvivalRow,
4279        point: LatentSurvivalPrimaryPoint,
4280        direction_u: &Array1<f64>,
4281        direction_v: &Array1<f64>,
4282        include_log_sigma: bool,
4283    ) -> Result<Array2<f64>, String> {
4284        let active_primary = if include_log_sigma {
4285            LATENT_SURVIVAL_PRIMARY_DIM
4286        } else {
4287            LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4288        };
4289        let dir_u = LatentSurvivalPrimaryDirection {
4290            dq_entry: direction_u[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4291            dq_exit: direction_u[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4292            dqdot_exit: direction_u[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4293            dq_right: direction_u[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4294            dmu: direction_u[LATENT_SURVIVAL_PRIMARY_MU],
4295            dlog_sigma: direction_u[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4296        };
4297        let dir_v = LatentSurvivalPrimaryDirection {
4298            dq_entry: direction_v[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4299            dq_exit: direction_v[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4300            dqdot_exit: direction_v[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4301            dq_right: direction_v[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4302            dmu: direction_v[LATENT_SURVIVAL_PRIMARY_MU],
4303            dlog_sigma: direction_v[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4304        };
4305        let mut out =
4306            Array2::<f64>::zeros((LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM));
4307        for a in 0..active_primary {
4308            let dir_a = latent_survival_basis_direction(a);
4309            for b in a..active_primary {
4310                let coeff = latent_survival_row_primary_log_jet_multidir_reference(
4311                    quadctx,
4312                    row,
4313                    point,
4314                    &[dir_a, latent_survival_basis_direction(b), dir_u, dir_v],
4315                )?
4316                .coeff(15);
4317                out[[a, b]] = -coeff;
4318                out[[b, a]] = -coeff;
4319            }
4320        }
4321        Ok(out)
4322    }
4323}
4324
4325#[derive(Clone)]
4326struct LatentSurvivalJointSlices {
4327    time: std::ops::Range<usize>,
4328    mean: std::ops::Range<usize>,
4329    log_sigma: Option<std::ops::Range<usize>>,
4330    total: usize,
4331}
4332
4333#[derive(Clone)]
4334struct LatentSurvivalJointGradientAccum {
4335    ll: CompensatedRowSum,
4336    gradient: Array1<f64>,
4337}
4338
4339#[derive(Clone)]
4340struct LatentSurvivalJointDenseAccum {
4341    ll: CompensatedRowSum,
4342    gradient: Array1<f64>,
4343    hessian: Array2<f64>,
4344}
4345
4346#[derive(Clone)]
4347struct LatentSurvivalDenseHessianAccum {
4348    hessian: Array2<f64>,
4349}
4350
4351#[derive(Clone, Copy, Default)]
4352struct CompensatedRowSum {
4353    sum: f64,
4354    correction: f64,
4355}
4356
4357impl CompensatedRowSum {
4358    #[inline]
4359    fn add(&mut self, value: f64) {
4360        let next = self.sum + value;
4361        if self.sum.abs() >= value.abs() {
4362            self.correction += (self.sum - next) + value;
4363        } else {
4364            self.correction += (value - next) + self.sum;
4365        }
4366        self.sum = next;
4367    }
4368
4369    #[inline]
4370    fn value(self) -> f64 {
4371        self.sum + self.correction
4372    }
4373}
4374
4375/// Process latent-survival rows in fixed contiguous chunks, using one
4376/// accumulator per rayon task and reducing those accumulators in chunk-index
4377/// order so gradient/Hessian assembly stays deterministic across runs.
4378fn deterministic_latent_survival_row_reduction<Acc, Init, Process, Combine>(
4379    n_rows: usize,
4380    init: Init,
4381    process_row: Process,
4382    mut combine: Combine,
4383) -> Result<Acc, String>
4384where
4385    Acc: Send,
4386    Init: Fn() -> Acc + Sync,
4387    Process: Fn(usize, &mut Acc) -> Result<(), String> + Sync,
4388    Combine: FnMut(&mut Acc, Acc),
4389{
4390    use rayon::iter::{IntoParallelIterator, ParallelIterator};
4391
4392    const TARGET_CHUNK_COUNT: usize = 32;
4393    if n_rows == 0 {
4394        return Ok(init());
4395    }
4396    let chunk_size = n_rows.div_ceil(TARGET_CHUNK_COUNT).max(1);
4397    let n_chunks = n_rows.div_ceil(chunk_size);
4398    let chunk_accumulators: Vec<Acc> = (0..n_chunks)
4399        .into_par_iter()
4400        .map(|chunk_idx| -> Result<Acc, String> {
4401            let start = chunk_idx * chunk_size;
4402            let end = (start + chunk_size).min(n_rows);
4403            let mut acc = init();
4404            for row_idx in start..end {
4405                process_row(row_idx, &mut acc)?;
4406            }
4407            Ok(acc)
4408        })
4409        .collect::<Result<Vec<_>, String>>()?;
4410
4411    let mut total = init();
4412    for acc in chunk_accumulators {
4413        combine(&mut total, acc);
4414    }
4415    Ok(total)
4416}
4417
4418impl LatentSurvivalFamily {
4419    /// Assemble the per-row [`LatentSurvivalRow`] for `row_idx` from the family's
4420    /// unloaded-mass/hazard fields and the supplied per-row time quantiles.
4421    ///
4422    /// Shared by every per-row reduction (log-likelihood, gradient, Hessian,
4423    /// directional third derivatives): each previously inlined an identical
4424    /// `event_type` lookup followed by the same 12-argument
4425    /// `build_latent_survival_row` call. Behavior is unchanged.
4426    fn build_row_at(
4427        &self,
4428        row_idx: usize,
4429        q_entry: f64,
4430        q_exit: f64,
4431        qdot_exit: f64,
4432        q_right: f64,
4433    ) -> Result<LatentSurvivalRow, LatentSurvivalError> {
4434        let event_type = latent_survival_event_type_for(self.event_target[row_idx]);
4435        build_latent_survival_row(
4436            row_idx,
4437            self.hazard_loading,
4438            event_type,
4439            q_entry,
4440            q_exit,
4441            qdot_exit,
4442            q_right,
4443            self.unloaded_mass_entry[row_idx],
4444            self.unloaded_mass_exit[row_idx],
4445            self.unloaded_mass_right[row_idx],
4446            self.unloaded_hazard_exit[row_idx],
4447        )
4448    }
4449
4450    fn joint_slices(&self) -> LatentSurvivalJointSlices {
4451        let p_time = self.x_time_exit.ncols();
4452        let p_mean = self.x_mean.ncols();
4453        let time = 0..p_time;
4454        let mean = p_time..p_time + p_mean;
4455        let log_sigma = self
4456            .latent_sd_fixed
4457            .is_none()
4458            .then_some((p_time + p_mean)..(p_time + p_mean + 1));
4459        LatentSurvivalJointSlices {
4460            total: log_sigma
4461                .as_ref()
4462                .map_or(p_time + p_mean, |range| range.end),
4463            time,
4464            mean,
4465            log_sigma,
4466        }
4467    }
4468
4469    fn row_primary_direction_from_flat(
4470        &self,
4471        row: usize,
4472        slices: &LatentSurvivalJointSlices,
4473        d_beta_flat: &Array1<f64>,
4474    ) -> Array1<f64> {
4475        let mut out = Array1::<f64>::zeros(LATENT_SURVIVAL_PRIMARY_DIM);
4476        let d_time = d_beta_flat.slice(s![slices.time.clone()]);
4477        out[LATENT_SURVIVAL_PRIMARY_Q_ENTRY] = self.x_time_entry.row(row).dot(&d_time);
4478        out[LATENT_SURVIVAL_PRIMARY_Q_EXIT] = self.x_time_exit.row(row).dot(&d_time);
4479        out[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT] = self.x_time_derivative_exit.row(row).dot(&d_time);
4480        out[LATENT_SURVIVAL_PRIMARY_Q_RIGHT] = self.x_time_right.row(row).dot(&d_time);
4481        out[LATENT_SURVIVAL_PRIMARY_MU] = self
4482            .x_mean
4483            .dot_row_view(row, d_beta_flat.slice(s![slices.mean.clone()]));
4484        if let Some(range) = &slices.log_sigma {
4485            out[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA] = d_beta_flat[range.start];
4486        }
4487        out
4488    }
4489
4490    fn joint_block_ranges(&self) -> Vec<std::ops::Range<usize>> {
4491        let slices = self.joint_slices();
4492        let mut ranges = vec![slices.time.clone(), slices.mean.clone()];
4493        if let Some(log_sigma) = slices.log_sigma {
4494            ranges.push(log_sigma);
4495        }
4496        ranges
4497    }
4498
4499    fn add_pullback_primary_gradient(
4500        &self,
4501        target: &mut Array1<f64>,
4502        row: usize,
4503        slices: &LatentSurvivalJointSlices,
4504        primary_gradient: &Array1<f64>,
4505        weight: f64,
4506    ) -> Result<(), String> {
4507        for (primary_idx, time_vec) in [
4508            (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
4509            (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
4510            (
4511                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4512                self.x_time_derivative_exit.row(row),
4513            ),
4514            (LATENT_SURVIVAL_PRIMARY_Q_RIGHT, self.x_time_right.row(row)),
4515        ] {
4516            let scale = checked_weighted_row_value(
4517                weight,
4518                primary_gradient[primary_idx],
4519                row,
4520                "primary gradient",
4521            )?;
4522            if scale == 0.0 {
4523                continue;
4524            }
4525            for i in 0..time_vec.len() {
4526                let xi = time_vec[i];
4527                if xi != 0.0 {
4528                    target[slices.time.start + i] += scale * xi;
4529                }
4530            }
4531        }
4532
4533        let mean_scale = checked_weighted_row_value(
4534            weight,
4535            primary_gradient[LATENT_SURVIVAL_PRIMARY_MU],
4536            row,
4537            "mean gradient",
4538        )?;
4539        if mean_scale != 0.0 {
4540            self.x_mean
4541                .axpy_row_into(
4542                    row,
4543                    mean_scale,
4544                    &mut target.slice_mut(s![slices.mean.clone()]),
4545                )
4546                .map_err(|error| {
4547                    format!(
4548                        "latent survival mean gradient pullback dimension mismatch: row={row}, mean_slice={:?}, target_len={}, x_mean_cols={}, error={error}",
4549                        slices.mean,
4550                        target.len(),
4551                        self.x_mean.ncols()
4552                    )
4553                })?;
4554        }
4555
4556        if let Some(log_sigma) = &slices.log_sigma {
4557            target[log_sigma.start] += checked_weighted_row_value(
4558                weight,
4559                primary_gradient[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4560                row,
4561                "log-sigma gradient",
4562            )?;
4563        }
4564        Ok(())
4565    }
4566
4567    fn add_pullback_primary_hessian(
4568        &self,
4569        target: &mut Array2<f64>,
4570        row: usize,
4571        slices: &LatentSurvivalJointSlices,
4572        primary_hessian: &Array2<f64>,
4573    ) -> Result<(), String> {
4574        let time_weights = [
4575            primary_hessian[[
4576                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4577                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4578            ]],
4579            primary_hessian[[
4580                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4581                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4582            ]],
4583            primary_hessian[[
4584                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4585                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4586            ]],
4587            primary_hessian[[
4588                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4589                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4590            ]],
4591        ];
4592        let time_cross_weights = [
4593            (
4594                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4595                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4596                &self.x_time_entry,
4597                &self.x_time_exit,
4598            ),
4599            (
4600                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4601                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4602                &self.x_time_entry,
4603                &self.x_time_derivative_exit,
4604            ),
4605            (
4606                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4607                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4608                &self.x_time_exit,
4609                &self.x_time_derivative_exit,
4610            ),
4611            (
4612                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4613                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4614                &self.x_time_entry,
4615                &self.x_time_right,
4616            ),
4617            (
4618                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4619                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4620                &self.x_time_exit,
4621                &self.x_time_right,
4622            ),
4623            (
4624                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4625                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4626                &self.x_time_derivative_exit,
4627                &self.x_time_right,
4628            ),
4629        ];
4630        {
4631            let time_target = &mut target.slice_mut(s![slices.time.clone(), slices.time.clone()]);
4632            dense_outer_accumulate(time_target, time_weights[0], self.x_time_entry.row(row));
4633            dense_outer_accumulate(time_target, time_weights[1], self.x_time_exit.row(row));
4634            dense_outer_accumulate(
4635                time_target,
4636                time_weights[2],
4637                self.x_time_derivative_exit.row(row),
4638            );
4639            dense_outer_accumulate(time_target, time_weights[3], self.x_time_right.row(row));
4640            for (a, b, lhs, rhs) in time_cross_weights {
4641                let weight = primary_hessian[[a, b]];
4642                if weight == 0.0 {
4643                    continue;
4644                }
4645                dense_symmetric_cross_accumulate(time_target, weight, lhs.row(row), rhs.row(row));
4646            }
4647        }
4648
4649        let mean_weight = primary_hessian[[LATENT_SURVIVAL_PRIMARY_MU, LATENT_SURVIVAL_PRIMARY_MU]];
4650        self.x_mean
4651            .syr_row_into_view(
4652                row,
4653                mean_weight,
4654                target.slice_mut(s![slices.mean.clone(), slices.mean.clone()]),
4655            )
4656            .map_err(|error| {
4657                format!(
4658                    "latent survival mean Hessian pullback dimension mismatch: row={row}, mean_slice={:?}, target_dim={:?}, x_mean_cols={}, error={error}",
4659                    slices.mean,
4660                    target.dim(),
4661                    self.x_mean.ncols()
4662                )
4663            })?;
4664
4665        let mean_row = self
4666            .x_mean
4667            .try_row_chunk(row..row + 1)
4668            .map_err(|error| {
4669                format!(
4670                    "latent survival mean pullback row chunk failed: row={row}, x_mean_rows={}, x_mean_cols={}, error={error}",
4671                    self.x_mean.nrows(),
4672                    self.x_mean.ncols()
4673                )
4674            })?;
4675        let mean_vec = mean_row.row(0);
4676        let time_mean_weights = [
4677            (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
4678            (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
4679            (
4680                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4681                self.x_time_derivative_exit.row(row),
4682            ),
4683            (LATENT_SURVIVAL_PRIMARY_Q_RIGHT, self.x_time_right.row(row)),
4684        ];
4685        for (primary_idx, time_vec) in time_mean_weights {
4686            let weight = primary_hessian[[primary_idx, LATENT_SURVIVAL_PRIMARY_MU]];
4687            if weight == 0.0 {
4688                continue;
4689            }
4690            for i in 0..time_vec.len() {
4691                let xi = time_vec[i];
4692                if xi == 0.0 {
4693                    continue;
4694                }
4695                for j in 0..mean_vec.len() {
4696                    let xj = mean_vec[j];
4697                    if xj == 0.0 {
4698                        continue;
4699                    }
4700                    target[[slices.time.start + i, slices.mean.start + j]] += weight * xi * xj;
4701                    target[[slices.mean.start + j, slices.time.start + i]] += weight * xj * xi;
4702                }
4703            }
4704        }
4705
4706        if let Some(log_sigma) = &slices.log_sigma {
4707            let sigma_idx = log_sigma.start;
4708            target[[sigma_idx, sigma_idx]] += primary_hessian[[
4709                LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
4710                LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
4711            ]];
4712
4713            for (primary_idx, time_vec) in [
4714                (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
4715                (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
4716                (
4717                    LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4718                    self.x_time_derivative_exit.row(row),
4719                ),
4720                (LATENT_SURVIVAL_PRIMARY_Q_RIGHT, self.x_time_right.row(row)),
4721            ] {
4722                let weight = primary_hessian[[primary_idx, LATENT_SURVIVAL_PRIMARY_LOG_SIGMA]];
4723                if weight == 0.0 {
4724                    continue;
4725                }
4726                for i in 0..time_vec.len() {
4727                    let xi = time_vec[i];
4728                    if xi == 0.0 {
4729                        continue;
4730                    }
4731                    target[[slices.time.start + i, sigma_idx]] += weight * xi;
4732                    target[[sigma_idx, slices.time.start + i]] += weight * xi;
4733                }
4734            }
4735
4736            let mean_sigma_weight = primary_hessian[[
4737                LATENT_SURVIVAL_PRIMARY_MU,
4738                LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
4739            ]];
4740            if mean_sigma_weight != 0.0 {
4741                for j in 0..mean_vec.len() {
4742                    let xj = mean_vec[j];
4743                    if xj == 0.0 {
4744                        continue;
4745                    }
4746                    target[[slices.mean.start + j, sigma_idx]] += mean_sigma_weight * xj;
4747                    target[[sigma_idx, slices.mean.start + j]] += mean_sigma_weight * xj;
4748                }
4749            }
4750        }
4751        Ok(())
4752    }
4753
4754    fn evaluate_exact_newton_joint_gradient_dense(
4755        &self,
4756        block_states: &[ParameterBlockState],
4757    ) -> Result<(f64, Array1<f64>), String> {
4758        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
4759            .map_err(String::from)?;
4760        let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
4761        let q_right = self.time_q_right(block_states)?;
4762        let sigma = self.latent_sd(block_states)?;
4763        let slices = self.joint_slices();
4764        let include_log_sigma = slices.log_sigma.is_some();
4765        let total = slices.total;
4766        let acc = deterministic_latent_survival_row_reduction(
4767            self.event_target.len(),
4768            || LatentSurvivalJointGradientAccum {
4769                ll: CompensatedRowSum::default(),
4770                gradient: Array1::<f64>::zeros(total),
4771            },
4772            |row_idx, acc| {
4773                let wi = weights.at(row_idx);
4774                if wi == 0.0 {
4775                    return Ok(());
4776                }
4777                let row = self.build_row_at(
4778                    row_idx,
4779                    q_entry[row_idx],
4780                    q_exit[row_idx],
4781                    qdot_exit[row_idx],
4782                    q_right[row_idx],
4783                )?;
4784                let point = LatentSurvivalPrimaryPoint {
4785                    q_entry: q_entry[row_idx],
4786                    q_exit: q_exit[row_idx],
4787                    qdot_exit: qdot_exit[row_idx],
4788                    q_right: q_right[row_idx],
4789                    mu: mu[row_idx],
4790                    sigma,
4791                };
4792                let (row_ll, primary_gradient, _) = latent_survival_row_primary_gradient_hessian(
4793                    &self.quadctx,
4794                    &row,
4795                    point,
4796                    include_log_sigma,
4797                )?;
4798                acc.ll.add(checked_weighted_row_value(
4799                    wi,
4800                    row_ll,
4801                    row_idx,
4802                    "log likelihood",
4803                )?);
4804                self.add_pullback_primary_gradient(
4805                    &mut acc.gradient,
4806                    row_idx,
4807                    &slices,
4808                    &primary_gradient,
4809                    wi,
4810                )?;
4811                Ok(())
4812            },
4813            |total_acc, chunk_acc| {
4814                total_acc.ll.add(chunk_acc.ll.value());
4815                total_acc.gradient += &chunk_acc.gradient;
4816            },
4817        )?;
4818        let ll = require_finite_likelihood_scalar(acc.ll.value(), "log likelihood")?;
4819        require_finite_likelihood_vector(&acc.gradient, "gradient")?;
4820        Ok((ll, acc.gradient))
4821    }
4822
4823    /// Per-row residuals of the unpenalized NLL with respect to the three
4824    /// additive baseline time-block offsets `(entry, exit, derivative)`.
4825    ///
4826    /// The baseline configuration θ enters the latent-survival working model
4827    /// only through the additive offsets on the three time channels
4828    ///   q_entry = x_time_entry·β_time + o_E(θ),
4829    ///   q_exit  = x_time_exit·β_time  + o_X(θ),
4830    ///   q̇_exit = x_time_deriv·β_time + o_D(θ),
4831    /// exactly the offset channel the transformation path carries through
4832    /// `WorkingModelSurvival::offset_channel_residuals`. Because
4833    /// `∂q_ch/∂o_ch = 1`, the residual `∂NLL/∂o_ch_i` equals
4834    /// `−∂(log-likelihood)/∂q_ch_i`, and the per-row primary log-likelihood
4835    /// gradient over `(q_entry, q_exit, q̇_exit)` is precisely the
4836    /// `Q_ENTRY`/`Q_EXIT`/`QDOT_EXIT` components returned by
4837    /// `latent_survival_row_primary_gradient_hessian`. Sampleweight-scaled to
4838    /// match the `OffsetChannelResiduals` contract consumed by
4839    /// `baseline_chain_rule_gradient`.
4840    ///
4841    /// At the converged (constrained) β̂ the envelope theorem makes this the
4842    /// exact θ-gradient of the profile penalized NLL `0.5·deviance + 0.5·βᵀSβ`.
4843    /// The interval upper-bound `q_right = x_time_right·β_time + o_R(θ)` channel
4844    /// DOES carry its own baseline-θ offset `o_R(θ)` (the time basis evaluated at
4845    /// the bracket upper bound `R`), distinct from the exit offset at `L`, so its
4846    /// residual `−∂(log-likelihood)/∂q_right` is returned in the dedicated
4847    /// `OffsetChannelResiduals::right` channel; it is exactly 0 on every
4848    /// non-interval row (the `Q_RIGHT` primary channel is inert there) and the
4849    /// baseline-θ chain rule contracts it against the `age_right`-evaluated
4850    /// η-partial.
4851    pub fn offset_channel_residuals(
4852        &self,
4853        block_states: &[ParameterBlockState],
4854    ) -> Result<crate::survival::OffsetChannelResiduals, LatentSurvivalError> {
4855        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")?;
4856        let n = self.event_target.len();
4857        // `split_time_eta` validates the complete block slate before indexing
4858        // it and returns `LatentSurvivalError::BlockMismatch` when fitted state
4859        // is missing. There is deliberately no zero-residual fallback: zeros
4860        // would manufacture a stationary outer baseline gradient.
4861        let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
4862        let q_right = self.time_q_right(block_states)?;
4863        let sigma = self.latent_sd(block_states)?;
4864        let include_log_sigma = self.joint_slices().log_sigma.is_some();
4865        let mut entry = Array1::<f64>::zeros(n);
4866        let mut exit = Array1::<f64>::zeros(n);
4867        let mut derivative = Array1::<f64>::zeros(n);
4868        let mut right = Array1::<f64>::zeros(n);
4869        for row_idx in 0..n {
4870            let wi = weights.at(row_idx);
4871            if wi == 0.0 {
4872                continue;
4873            }
4874            let row = self.build_row_at(
4875                row_idx,
4876                q_entry[row_idx],
4877                q_exit[row_idx],
4878                qdot_exit[row_idx],
4879                q_right[row_idx],
4880            )?;
4881            let point = LatentSurvivalPrimaryPoint {
4882                q_entry: q_entry[row_idx],
4883                q_exit: q_exit[row_idx],
4884                qdot_exit: qdot_exit[row_idx],
4885                q_right: q_right[row_idx],
4886                mu: mu[row_idx],
4887                sigma,
4888            };
4889            let (_, primary_gradient, _) = latent_survival_row_primary_gradient_hessian(
4890                &self.quadctx,
4891                &row,
4892                point,
4893                include_log_sigma,
4894            )?;
4895            // ∂NLL/∂o_ch = −w · ∂(log-likelihood)/∂q_ch.
4896            entry[row_idx] = -checked_weighted_row_value(
4897                wi,
4898                primary_gradient[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4899                row_idx,
4900                "entry-offset score",
4901            )
4902            .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4903            exit[row_idx] = -checked_weighted_row_value(
4904                wi,
4905                primary_gradient[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4906                row_idx,
4907                "exit-offset score",
4908            )
4909            .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4910            derivative[row_idx] = -checked_weighted_row_value(
4911                wi,
4912                primary_gradient[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4913                row_idx,
4914                "derivative-offset score",
4915            )
4916            .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4917            // Interval upper-bound (`R`) channel. `q_right` shares the time-block
4918            // coefficients but carries its OWN baseline-θ η-offset evaluated at
4919            // `R` (`o_R(θ)`), so the profile-NLL θ-gradient must include it.
4920            // `∂(log-likelihood)/∂q_right` is exactly 0 for non-interval rows
4921            // (the `Q_RIGHT` channel is inert there), so this is 0 except on
4922            // interval-censored rows.
4923            right[row_idx] = -checked_weighted_row_value(
4924                wi,
4925                primary_gradient[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4926                row_idx,
4927                "right-offset score",
4928            )
4929            .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4930        }
4931        Ok(crate::survival::OffsetChannelResiduals {
4932            exit,
4933            entry,
4934            derivative,
4935            right,
4936        })
4937    }
4938
4939    /// Block-diagonal-only pullback: writes only time-time, mean-mean, and
4940    /// log_sigma-log_sigma rowwise contributions into per-block targets.
4941    /// Used by `evaluate()` to populate per-block working sets without ever
4942    /// materializing the cross blocks the inner solver does not consume.
4943    fn add_pullback_primary_block_diagonals(
4944        &self,
4945        row: usize,
4946        primary_hessian: &Array2<f64>,
4947        time_target: &mut Array2<f64>,
4948        mean_target: &mut Array2<f64>,
4949        log_sigma_target: Option<&mut Array2<f64>>,
4950    ) -> Result<(), String> {
4951        let h = primary_hessian;
4952        // Time block: 4 squared rows (entry/exit/qdot/right) + 6 symmetric
4953        // crosses. The interval right-boundary functional `q_right` shares the
4954        // time-block coefficients, so it accumulates into the same time target.
4955        dense_outer_accumulate(
4956            time_target,
4957            h[[
4958                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4959                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4960            ]],
4961            self.x_time_entry.row(row),
4962        );
4963        dense_outer_accumulate(
4964            time_target,
4965            h[[
4966                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4967                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4968            ]],
4969            self.x_time_exit.row(row),
4970        );
4971        dense_outer_accumulate(
4972            time_target,
4973            h[[
4974                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4975                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4976            ]],
4977            self.x_time_derivative_exit.row(row),
4978        );
4979        dense_outer_accumulate(
4980            time_target,
4981            h[[
4982                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4983                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4984            ]],
4985            self.x_time_right.row(row),
4986        );
4987        for (a, b, lhs, rhs) in [
4988            (
4989                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4990                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4991                &self.x_time_entry,
4992                &self.x_time_exit,
4993            ),
4994            (
4995                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4996                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4997                &self.x_time_entry,
4998                &self.x_time_derivative_exit,
4999            ),
5000            (
5001                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
5002                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
5003                &self.x_time_exit,
5004                &self.x_time_derivative_exit,
5005            ),
5006            (
5007                LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
5008                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
5009                &self.x_time_entry,
5010                &self.x_time_right,
5011            ),
5012            (
5013                LATENT_SURVIVAL_PRIMARY_Q_EXIT,
5014                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
5015                &self.x_time_exit,
5016                &self.x_time_right,
5017            ),
5018            (
5019                LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
5020                LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
5021                &self.x_time_derivative_exit,
5022                &self.x_time_right,
5023            ),
5024        ] {
5025            let weight = h[[a, b]];
5026            if weight == 0.0 {
5027                continue;
5028            }
5029            dense_symmetric_cross_accumulate(time_target, weight, lhs.row(row), rhs.row(row));
5030        }
5031        // Mean block.
5032        let mean_weight = h[[LATENT_SURVIVAL_PRIMARY_MU, LATENT_SURVIVAL_PRIMARY_MU]];
5033        self.x_mean
5034            .syr_row_into_view(row, mean_weight, mean_target.view_mut())
5035            .map_err(|error| {
5036                format!(
5037                    "latent survival mean block-diagonal pullback dimension mismatch: row={row}, mean_target_dim={:?}, x_mean_cols={}, error={error}",
5038                    mean_target.dim(),
5039                    self.x_mean.ncols()
5040                )
5041            })?;
5042        // Log-σ block (scalar).
5043        if let Some(target) = log_sigma_target {
5044            target[[0, 0]] += h[[
5045                LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
5046                LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
5047            ]];
5048        }
5049        Ok(())
5050    }
5051
5052    /// Block-diagonal evaluator used by `evaluate()`. Returns the per-row
5053    /// log-likelihood, the joint gradient (sliced into block gradients by
5054    /// the caller), and the three per-block diagonal Hessians without ever
5055    /// materializing the full joint matrix.
5056    fn evaluate_exact_newton_block_diagonals(
5057        &self,
5058        block_states: &[ParameterBlockState],
5059    ) -> Result<
5060        (
5061            f64,
5062            Array1<f64>,
5063            Array2<f64>,
5064            Array2<f64>,
5065            Option<Array2<f64>>,
5066        ),
5067        String,
5068    > {
5069        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5070            .map_err(String::from)?;
5071        let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5072        let q_right = self.time_q_right(block_states)?;
5073        let sigma = self.latent_sd(block_states)?;
5074        let slices = self.joint_slices();
5075        let include_log_sigma = slices.log_sigma.is_some();
5076        let mut ll = CompensatedRowSum::default();
5077        let mut gradient = Array1::<f64>::zeros(slices.total);
5078        let p_time = slices.time.len();
5079        let p_mean = slices.mean.len();
5080        let mut hess_time = Array2::<f64>::zeros((p_time, p_time));
5081        let mut hess_mean = Array2::<f64>::zeros((p_mean, p_mean));
5082        let mut hess_log_sigma = if include_log_sigma {
5083            Some(Array2::<f64>::zeros((1, 1)))
5084        } else {
5085            None
5086        };
5087        for row_idx in 0..self.event_target.len() {
5088            let wi = weights.at(row_idx);
5089            if wi == 0.0 {
5090                continue;
5091            }
5092            let row = self.build_row_at(
5093                row_idx,
5094                q_entry[row_idx],
5095                q_exit[row_idx],
5096                qdot_exit[row_idx],
5097                q_right[row_idx],
5098            )?;
5099            let (row_ll, primary_gradient, primary_hessian) =
5100                latent_survival_row_primary_gradient_hessian(
5101                    &self.quadctx,
5102                    &row,
5103                    LatentSurvivalPrimaryPoint {
5104                        q_entry: q_entry[row_idx],
5105                        q_exit: q_exit[row_idx],
5106                        qdot_exit: qdot_exit[row_idx],
5107                        q_right: q_right[row_idx],
5108                        mu: mu[row_idx],
5109                        sigma,
5110                    },
5111                    include_log_sigma,
5112                )?;
5113            ll.add(checked_weighted_row_value(
5114                wi,
5115                row_ll,
5116                row_idx,
5117                "log likelihood",
5118            )?);
5119            self.add_pullback_primary_gradient(
5120                &mut gradient,
5121                row_idx,
5122                &slices,
5123                &primary_gradient,
5124                wi,
5125            )?;
5126            let weighted_primary_hessian =
5127                checked_weighted_row_matrix(wi, &primary_hessian, row_idx, "primary Hessian")?;
5128            self.add_pullback_primary_block_diagonals(
5129                row_idx,
5130                &weighted_primary_hessian,
5131                &mut hess_time,
5132                &mut hess_mean,
5133                hess_log_sigma.as_mut(),
5134            )?;
5135        }
5136        let ll = require_finite_likelihood_scalar(ll.value(), "log likelihood")?;
5137        require_finite_likelihood_vector(&gradient, "gradient")?;
5138        require_finite_likelihood_matrix(&hess_time, "time Hessian")?;
5139        require_finite_likelihood_matrix(&hess_mean, "mean Hessian")?;
5140        if let Some(hessian) = hess_log_sigma.as_ref() {
5141            require_finite_likelihood_matrix(hessian, "log-sigma Hessian")?;
5142        }
5143        Ok((ll, gradient, hess_time, hess_mean, hess_log_sigma))
5144    }
5145
5146    fn evaluate_exact_newton_joint_dense(
5147        &self,
5148        block_states: &[ParameterBlockState],
5149    ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
5150        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5151            .map_err(String::from)?;
5152        let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5153        let q_right = self.time_q_right(block_states)?;
5154        let sigma = self.latent_sd(block_states)?;
5155        let slices = self.joint_slices();
5156        let include_log_sigma = slices.log_sigma.is_some();
5157        let total = slices.total;
5158        let acc = deterministic_latent_survival_row_reduction(
5159            self.event_target.len(),
5160            || LatentSurvivalJointDenseAccum {
5161                ll: CompensatedRowSum::default(),
5162                gradient: Array1::<f64>::zeros(total),
5163                hessian: Array2::<f64>::zeros((total, total)),
5164            },
5165            |row_idx, acc| {
5166                let wi = weights.at(row_idx);
5167                if wi == 0.0 {
5168                    return Ok(());
5169                }
5170                let row = self.build_row_at(
5171                    row_idx,
5172                    q_entry[row_idx],
5173                    q_exit[row_idx],
5174                    qdot_exit[row_idx],
5175                    q_right[row_idx],
5176                )?;
5177                let (row_ll, primary_gradient, primary_hessian) =
5178                    latent_survival_row_primary_gradient_hessian(
5179                        &self.quadctx,
5180                        &row,
5181                        LatentSurvivalPrimaryPoint {
5182                            q_entry: q_entry[row_idx],
5183                            q_exit: q_exit[row_idx],
5184                            qdot_exit: qdot_exit[row_idx],
5185                            q_right: q_right[row_idx],
5186                            mu: mu[row_idx],
5187                            sigma,
5188                        },
5189                        include_log_sigma,
5190                    )?;
5191                acc.ll.add(checked_weighted_row_value(
5192                    wi,
5193                    row_ll,
5194                    row_idx,
5195                    "log likelihood",
5196                )?);
5197                self.add_pullback_primary_gradient(
5198                    &mut acc.gradient,
5199                    row_idx,
5200                    &slices,
5201                    &primary_gradient,
5202                    wi,
5203                )?;
5204                let weighted_primary_hessian =
5205                    checked_weighted_row_matrix(wi, &primary_hessian, row_idx, "primary Hessian")?;
5206                self.add_pullback_primary_hessian(
5207                    &mut acc.hessian,
5208                    row_idx,
5209                    &slices,
5210                    &weighted_primary_hessian,
5211                )?;
5212                Ok(())
5213            },
5214            |total_acc, chunk_acc| {
5215                total_acc.ll.add(chunk_acc.ll.value());
5216                total_acc.gradient += &chunk_acc.gradient;
5217                total_acc.hessian += &chunk_acc.hessian;
5218            },
5219        )?;
5220        let ll = require_finite_likelihood_scalar(acc.ll.value(), "log likelihood")?;
5221        require_finite_likelihood_vector(&acc.gradient, "gradient")?;
5222        require_finite_likelihood_matrix(&acc.hessian, "Hessian")?;
5223        Ok((ll, acc.gradient, acc.hessian))
5224    }
5225
5226    fn exact_newton_joint_hessian_directional_derivative_dense(
5227        &self,
5228        block_states: &[ParameterBlockState],
5229        d_beta_flat: &Array1<f64>,
5230    ) -> Result<Array2<f64>, String> {
5231        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5232            .map_err(String::from)?;
5233        let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5234        let q_right = self.time_q_right(block_states)?;
5235        let sigma = self.latent_sd(block_states)?;
5236        let slices = self.joint_slices();
5237        if d_beta_flat.len() != slices.total {
5238            return Err(format!(
5239                "latent survival joint dH direction length mismatch: got {}, expected {}",
5240                d_beta_flat.len(),
5241                slices.total
5242            ));
5243        }
5244        let include_log_sigma = slices.log_sigma.is_some();
5245        let total = slices.total;
5246        let acc = deterministic_latent_survival_row_reduction(
5247            self.event_target.len(),
5248            || LatentSurvivalDenseHessianAccum {
5249                hessian: Array2::<f64>::zeros((total, total)),
5250            },
5251            |row_idx, acc| {
5252                let wi = weights.at(row_idx);
5253                if wi == 0.0 {
5254                    return Ok(());
5255                }
5256                let row = self.build_row_at(
5257                    row_idx,
5258                    q_entry[row_idx],
5259                    q_exit[row_idx],
5260                    qdot_exit[row_idx],
5261                    q_right[row_idx],
5262                )?;
5263                let direction = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_flat);
5264                let third = latent_survival_row_primary_third_contracted(
5265                    &self.quadctx,
5266                    &row,
5267                    LatentSurvivalPrimaryPoint {
5268                        q_entry: q_entry[row_idx],
5269                        q_exit: q_exit[row_idx],
5270                        qdot_exit: qdot_exit[row_idx],
5271                        q_right: q_right[row_idx],
5272                        mu: mu[row_idx],
5273                        sigma,
5274                    },
5275                    &direction,
5276                    include_log_sigma,
5277                )?;
5278                let weighted_third =
5279                    checked_weighted_row_matrix(wi, &third, row_idx, "contracted third")?;
5280                self.add_pullback_primary_hessian(
5281                    &mut acc.hessian,
5282                    row_idx,
5283                    &slices,
5284                    &weighted_third,
5285                )?;
5286                Ok(())
5287            },
5288            |total_acc, chunk_acc| {
5289                total_acc.hessian += &chunk_acc.hessian;
5290            },
5291        )?;
5292        require_finite_likelihood_matrix(&acc.hessian, "directional Hessian derivative")?;
5293        Ok(acc.hessian)
5294    }
5295
5296    fn exact_newton_joint_hessian_second_directional_derivative_dense(
5297        &self,
5298        block_states: &[ParameterBlockState],
5299        d_beta_u_flat: &Array1<f64>,
5300        d_beta_v_flat: &Array1<f64>,
5301    ) -> Result<Array2<f64>, String> {
5302        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5303            .map_err(String::from)?;
5304        let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5305        let q_right = self.time_q_right(block_states)?;
5306        let sigma = self.latent_sd(block_states)?;
5307        let slices = self.joint_slices();
5308        if d_beta_u_flat.len() != slices.total || d_beta_v_flat.len() != slices.total {
5309            return Err(format!(
5310                "latent survival joint d2H direction length mismatch: got {} and {}, expected {}",
5311                d_beta_u_flat.len(),
5312                d_beta_v_flat.len(),
5313                slices.total
5314            ));
5315        }
5316        let include_log_sigma = slices.log_sigma.is_some();
5317        let total = slices.total;
5318        let acc = deterministic_latent_survival_row_reduction(
5319            self.event_target.len(),
5320            || LatentSurvivalDenseHessianAccum {
5321                hessian: Array2::<f64>::zeros((total, total)),
5322            },
5323            |row_idx, acc| {
5324                let wi = weights.at(row_idx);
5325                if wi == 0.0 {
5326                    return Ok(());
5327                }
5328                let row = self.build_row_at(
5329                    row_idx,
5330                    q_entry[row_idx],
5331                    q_exit[row_idx],
5332                    qdot_exit[row_idx],
5333                    q_right[row_idx],
5334                )?;
5335                let direction_u =
5336                    self.row_primary_direction_from_flat(row_idx, &slices, d_beta_u_flat);
5337                let direction_v =
5338                    self.row_primary_direction_from_flat(row_idx, &slices, d_beta_v_flat);
5339                let fourth = latent_survival_row_primary_fourth_contracted(
5340                    &self.quadctx,
5341                    &row,
5342                    LatentSurvivalPrimaryPoint {
5343                        q_entry: q_entry[row_idx],
5344                        q_exit: q_exit[row_idx],
5345                        qdot_exit: qdot_exit[row_idx],
5346                        q_right: q_right[row_idx],
5347                        mu: mu[row_idx],
5348                        sigma,
5349                    },
5350                    &direction_u,
5351                    &direction_v,
5352                    include_log_sigma,
5353                )?;
5354                let weighted_fourth =
5355                    checked_weighted_row_matrix(wi, &fourth, row_idx, "contracted fourth")?;
5356                self.add_pullback_primary_hessian(
5357                    &mut acc.hessian,
5358                    row_idx,
5359                    &slices,
5360                    &weighted_fourth,
5361                )?;
5362                Ok(())
5363            },
5364            |total_acc, chunk_acc| {
5365                total_acc.hessian += &chunk_acc.hessian;
5366            },
5367        )?;
5368        require_finite_likelihood_matrix(&acc.hessian, "second directional Hessian derivative")?;
5369        Ok(acc.hessian)
5370    }
5371}
5372
5373fn log_kernel_ratio(
5374    bundle: &crate::survival::lognormal_kernel::LogLognormalKernelBundle,
5375    num: usize,
5376    den: usize,
5377) -> f64 {
5378    let delta = bundle.get(num) - bundle.get(den);
5379    if delta.is_finite() {
5380        delta.exp()
5381    } else if delta > 0.0 {
5382        f64::INFINITY
5383    } else {
5384        0.0
5385    }
5386}
5387
5388fn logk_q_derivatives(
5389    quadctx: &QuadratureContext,
5390    k: usize,
5391    mass: f64,
5392    mu: f64,
5393    sigma: f64,
5394) -> Result<(f64, f64, IntegratedExpectationMode), LatentSurvivalError> {
5395    if mass <= 0.0 {
5396        return Ok((0.0, 0.0, IntegratedExpectationMode::ExactClosedForm));
5397    }
5398    let bundle = log_kernel_bundle(quadctx, mass, mu, sigma, k + 2).map_err(|e| {
5399        LatentSurvivalError::NumericalFailure {
5400            reason: format!("latent survival kernel evaluation failed: {e}"),
5401        }
5402    })?;
5403    let r1 = log_kernel_ratio(&bundle, k + 1, k);
5404    let r2 = log_kernel_ratio(&bundle, k + 2, k);
5405    let d1 = -mass * r1;
5406    // #2610 -- \ is a CANCELLING difference. Past     // the two terms agree to \ of their own size, so the
5407    // subtraction keeps only the bits they do not share and the result changes
5408    // SIGN between adjacent 0.05 steps of \. No working precision
5409    // repairs it: 513 -> 1025 quadrature nodes bought 335x at \ and
5410    // nothing at \, because the cancelled quantity keeps shrinking
5411    // while the roundoff floor does not (#2566).
5412    //
5413    // \ forms the same quantity without the subtraction:
5414    // it factors the common ratio out into an \ and takes the second
5415    // difference of the ANALYTIC prefix in closed form -- exactly \ for
5416    // every \ and \ -- so only the slowly-varying Laplace half is
5417    // differenced numerically. It was written for this defect and had NO
5418    // production caller; this is that caller.
5419    //
5420    // It refuses rather than degrade when a rung is missing or an input is
5421    // non-finite, and the difference form is then the only route left. Taking
5422    // that route silently is what let this defect hide, so the fallback says so.
5423    let second_cumulant = match bundle.second_cumulant_ratio(k, sigma) {
5424        Some(value) => value,
5425        None => {
5426            log::warn!(
5427                "[#2610] cancellation-free second cumulant unavailable at k={k} \
5428                 (sigma={sigma:.6e}, mass={mass:.6e}); falling back to the \
5429                 differencing form, whose relative error grows without bound past \
5430                 log sigma ~ 5.4"
5431            );
5432            r2 - r1 * r1
5433        }
5434    };
5435    let d2 = d1 + mass * mass * second_cumulant;
5436    Ok((d1, d2, bundle.mode))
5437}
5438
5439fn latent_survival_time_jet(
5440    quadctx: &QuadratureContext,
5441    row: &LatentSurvivalRow,
5442    qdot_exit: f64,
5443    mu: f64,
5444    sigma: f64,
5445) -> Result<LatentSurvivalTimeJet, LatentSurvivalError> {
5446    let (entry_d1, entry_d2, _) = logk_q_derivatives(quadctx, 0, row.mass_entry, mu, sigma)?;
5447    match row.event_type {
5448        LatentSurvivalEventType::RightCensored => {
5449            let (exit_d1, exit_d2, _) = logk_q_derivatives(quadctx, 0, row.mass_exit, mu, sigma)?;
5450            Ok(LatentSurvivalTimeJet {
5451                grad_entry: -entry_d1,
5452                grad_exit: exit_d1,
5453                neg_hess_entry: entry_d2,
5454                neg_hess_exit: -exit_d2,
5455            })
5456        }
5457        LatentSurvivalEventType::ExactEvent => {
5458            if !(qdot_exit.is_finite() && qdot_exit > 0.0) {
5459                return Err(LatentSurvivalError::NumericalFailure {
5460                    reason: format!(
5461                        "latent survival requires positive finite baseline hazard derivative, got {qdot_exit}"
5462                    ),
5463                });
5464            }
5465            if row.hazard_unloaded > 0.0 {
5466                let bundle =
5467                    log_kernel_bundle(quadctx, row.mass_exit, mu, sigma, 3).map_err(|e| {
5468                        LatentSurvivalError::NumericalFailure {
5469                            reason: format!("latent survival kernel evaluation failed: {e}"),
5470                        }
5471                    })?;
5472                let (unloaded_d1, unloaded_d2, _) =
5473                    logk_q_derivatives(quadctx, 0, row.mass_exit, mu, sigma)?;
5474                let (loaded_log_d1, loaded_d2, _) =
5475                    logk_q_derivatives(quadctx, 1, row.mass_exit, mu, sigma)?;
5476                let loaded_d1 = 1.0 + loaded_log_d1;
5477                let log_loaded = row.hazard_loaded.ln() + bundle.get(1);
5478                let log_unloaded = row.hazard_unloaded.ln() + bundle.get(0);
5479                let shift = log_loaded.max(log_unloaded);
5480                let loaded_weight = (log_loaded - shift).exp();
5481                let unloaded_weight = (log_unloaded - shift).exp();
5482                let normalizer = loaded_weight + unloaded_weight;
5483                if !(normalizer.is_finite() && normalizer > 0.0) {
5484                    return Err(LatentSurvivalError::NumericalFailure {
5485                        reason: "latent survival exact-event numerator became non-finite under loaded/unloaded hazard decomposition"
5486                            .to_string(),
5487                    });
5488                }
5489                let w_loaded = loaded_weight / normalizer;
5490                let w_unloaded = unloaded_weight / normalizer;
5491                let grad_exit = w_loaded * loaded_d1 + w_unloaded * unloaded_d1;
5492                let d2_exit = w_loaded * (loaded_d2 + loaded_d1 * loaded_d1)
5493                    + w_unloaded * (unloaded_d2 + unloaded_d1 * unloaded_d1)
5494                    - grad_exit * grad_exit;
5495                Ok(LatentSurvivalTimeJet {
5496                    grad_entry: -entry_d1,
5497                    grad_exit,
5498                    neg_hess_entry: entry_d2,
5499                    neg_hess_exit: -d2_exit,
5500                })
5501            } else {
5502                let (exit_d1, exit_d2, _) =
5503                    logk_q_derivatives(quadctx, 1, row.mass_exit, mu, sigma)?;
5504                Ok(LatentSurvivalTimeJet {
5505                    grad_entry: -entry_d1,
5506                    grad_exit: 1.0 + exit_d1,
5507                    neg_hess_entry: entry_d2,
5508                    neg_hess_exit: -exit_d2,
5509                })
5510            }
5511        }
5512        LatentSurvivalEventType::IntervalCensored => {
5513            Err(LatentSurvivalError::UnsupportedConfiguration {
5514                reason:
5515                    "latent survival dynamic time derivatives do not implement interval censoring"
5516                        .to_string(),
5517            })
5518        }
5519    }
5520}
5521
5522fn dense_outer_accumulate<S>(
5523    target: &mut ndarray::ArrayBase<S, ndarray::Ix2>,
5524    weight: f64,
5525    x: ArrayView1<'_, f64>,
5526) where
5527    S: ndarray::DataMut<Elem = f64>,
5528{
5529    for a in 0..x.len() {
5530        let xa = x[a];
5531        if xa == 0.0 {
5532            continue;
5533        }
5534        for b in 0..x.len() {
5535            let xb = x[b];
5536            if xb == 0.0 {
5537                continue;
5538            }
5539            target[[a, b]] += weight * xa * xb;
5540        }
5541    }
5542}
5543
5544fn dense_symmetric_cross_accumulate<S>(
5545    target: &mut ndarray::ArrayBase<S, ndarray::Ix2>,
5546    weight: f64,
5547    x: ArrayView1<'_, f64>,
5548    y: ArrayView1<'_, f64>,
5549) where
5550    S: ndarray::DataMut<Elem = f64>,
5551{
5552    for a in 0..x.len() {
5553        let xa = x[a];
5554        let ya = y[a];
5555        if xa == 0.0 && ya == 0.0 {
5556            continue;
5557        }
5558        for b in 0..x.len() {
5559            let xb = x[b];
5560            let yb = y[b];
5561            let contribution = xa * yb + ya * xb;
5562            if contribution == 0.0 {
5563                continue;
5564            }
5565            target[[a, b]] += weight * contribution;
5566        }
5567    }
5568}
5569
5570fn build_latent_survival_row(
5571    row_index: usize,
5572    hazard_loading: HazardLoading,
5573    event_type: LatentSurvivalEventType,
5574    q_entry: f64,
5575    q_exit: f64,
5576    qdot_exit: f64,
5577    q_right: f64,
5578    unloaded_mass_entry: f64,
5579    unloaded_mass_exit: f64,
5580    unloaded_mass_right: f64,
5581    unloaded_hazard_exit: f64,
5582) -> Result<LatentSurvivalRow, LatentSurvivalError> {
5583    if !(q_entry.is_finite() && q_exit.is_finite()) {
5584        return Err(LatentSurvivalError::NumericalFailure {
5585            reason: format!(
5586                "latent survival requires finite q_entry and q_exit, got q_entry={q_entry}, q_exit={q_exit}"
5587            ),
5588        });
5589    }
5590    if q_exit < q_entry {
5591        return Err(LatentSurvivalError::NumericalFailure {
5592            reason: format!(
5593                "latent survival requires q_exit >= q_entry so cumulative mass is monotone, got q_entry={q_entry}, q_exit={q_exit}"
5594            ),
5595        });
5596    }
5597    if !(unloaded_mass_entry.is_finite()
5598        && unloaded_mass_exit.is_finite()
5599        && unloaded_hazard_exit.is_finite())
5600    {
5601        return Err(LatentSurvivalError::InvalidDataset {
5602            reason: format!(
5603                "latent survival requires finite unloaded components, got entry_mass={unloaded_mass_entry}, exit_mass={unloaded_mass_exit}, exit_hazard={unloaded_hazard_exit}"
5604            ),
5605        });
5606    }
5607    if unloaded_mass_entry < 0.0
5608        || unloaded_mass_exit < unloaded_mass_entry
5609        || unloaded_hazard_exit < 0.0
5610    {
5611        return Err(LatentSurvivalError::InvalidDataset {
5612            reason: format!(
5613                "latent survival requires unloaded masses/hazard to be non-negative and monotone, got entry_mass={unloaded_mass_entry}, exit_mass={unloaded_mass_exit}, exit_hazard={unloaded_hazard_exit}"
5614            ),
5615        });
5616    }
5617    let mass_entry = q_entry.exp();
5618    let mass_exit = q_exit.exp();
5619    let row = match event_type {
5620        LatentSurvivalEventType::RightCensored => {
5621            validate_unloaded_components_for_loading(
5622                "latent-survival",
5623                row_index,
5624                hazard_loading,
5625                unloaded_mass_entry,
5626                unloaded_mass_exit,
5627                Some(unloaded_hazard_exit),
5628            )?;
5629            LatentSurvivalRow::right_censored(
5630                mass_entry,
5631                mass_exit,
5632                unloaded_mass_entry,
5633                unloaded_mass_exit,
5634            )
5635        }
5636        LatentSurvivalEventType::ExactEvent => {
5637            validate_unloaded_components_for_loading(
5638                "latent-survival",
5639                row_index,
5640                hazard_loading,
5641                unloaded_mass_entry,
5642                unloaded_mass_exit,
5643                Some(unloaded_hazard_exit),
5644            )?;
5645            LatentSurvivalRow::exact_event(
5646                mass_entry,
5647                mass_exit,
5648                unloaded_mass_entry,
5649                unloaded_mass_exit,
5650                mass_exit
5651                    * if qdot_exit.is_finite() && qdot_exit > 0.0 {
5652                        qdot_exit
5653                    } else {
5654                        return Err(LatentSurvivalError::NumericalFailure {
5655                            reason: format!(
5656                                "latent survival exact event requires positive finite baseline hazard derivative, got {qdot_exit}"
5657                            ),
5658                        });
5659                    },
5660                unloaded_hazard_exit,
5661            )
5662        }
5663        LatentSurvivalEventType::IntervalCensored => {
5664            // Interval `(L, R]`: `q_exit` carries the LEFT boundary transform
5665            // `log B(L)` (so `mass_left = exp(q_exit)`) and `q_right` the RIGHT
5666            // boundary `log B(R)`. The likelihood is the survival-mass
5667            // difference `log[S(L) − S(R)]`, requiring `B(L) ≤ B(R)` i.e.
5668            // `q_exit ≤ q_right`. No event hazard participates, so the unloaded
5669            // exit hazard must be the full-loading zero (validated below via the
5670            // interval-specific unloaded check at the left/right boundaries).
5671            if !q_right.is_finite() {
5672                return Err(LatentSurvivalError::NumericalFailure {
5673                    reason: format!(
5674                        "latent survival interval row {} requires a finite q_right, got {q_right}",
5675                        row_index + 1
5676                    ),
5677                });
5678            }
5679            if q_right < q_exit {
5680                return Err(LatentSurvivalError::NumericalFailure {
5681                    reason: format!(
5682                        "latent survival interval row {} requires q_right >= q_exit (R >= L) so the \
5683                         survival-mass difference is non-negative, got q_left={q_exit}, q_right={q_right}",
5684                        row_index + 1
5685                    ),
5686                });
5687            }
5688            if !(unloaded_mass_right.is_finite()) || unloaded_mass_right < unloaded_mass_exit {
5689                return Err(LatentSurvivalError::InvalidDataset {
5690                    reason: format!(
5691                        "latent survival interval row {} requires a finite unloaded right mass >= unloaded left mass, got left={unloaded_mass_exit}, right={unloaded_mass_right}",
5692                        row_index + 1
5693                    ),
5694                });
5695            }
5696            // Interval rows carry no exit-event hazard; the loaded/unloaded
5697            // contract is validated by `LatentSurvivalRow::validate` (entry <=
5698            // left <= right monotonicity on both loaded and unloaded masses).
5699            let mass_right = q_right.exp();
5700            LatentSurvivalRow::interval_censored(
5701                mass_entry,
5702                mass_exit,
5703                mass_right,
5704                unloaded_mass_entry,
5705                unloaded_mass_exit,
5706                unloaded_mass_right,
5707            )
5708        }
5709    };
5710    row.validate()
5711        .map_err(|e| LatentSurvivalError::InvalidDataset {
5712            reason: e.to_string(),
5713        })?;
5714    Ok(row)
5715}
5716
5717#[derive(Clone, Copy, Debug)]
5718struct BinaryFromLogSurvival {
5719    log_lik: f64,
5720    /// dℓ/ds where s = log_survival and ℓ = log_lik. For event=1 this is
5721    /// ℓ' = -S/(1-S); for event=0 this is ℓ' = 1 (because ℓ ≡ s).
5722    grad_scale: f64,
5723    /// Coefficient applied to `survival_jet.neg_hessian` (which equals
5724    /// -d²s/dβ²) when assembling the negative Hessian of `wi * log_lik`
5725    /// against β. The Newton accumulator computes
5726    ///     neg_Hess(log_lik) = grad_scale * neg_hessian + outer_scale * score²
5727    /// so by the chain rule this MUST equal `grad_scale` (= ℓ'). Keeping
5728    /// the two fields separate is purely for readability at call sites;
5729    /// the `assert!` in [`binary_from_log_survival`] enforces the
5730    /// equality.
5731    neg_hess_scale: f64,
5732    /// -ℓ''(s). For event=1 this is +S/(1-S)²; for event=0 it is 0.
5733    outer_scale: f64,
5734}
5735
5736/// Exact binary log likelihood from a row log-survival `s`.
5737///
5738/// This value-only path deliberately does not evaluate derivatives: near
5739/// `s = 0`, `log(1-exp(s))` can remain finite after one or more derivatives
5740/// cease to be representable. A likelihood-only caller must not fail because
5741/// of an output it did not request.
5742fn binary_log_likelihood_from_log_survival(
5743    log_survival: f64,
5744    event: u8,
5745) -> Result<f64, LatentSurvivalError> {
5746    match event {
5747        0 => {
5748            if !log_survival.is_finite() || log_survival > 0.0 {
5749                return Err(LatentSurvivalError::NumericalFailure {
5750                    reason: format!(
5751                        "latent-binary requires finite log survival <= 0 for a censored row, got {log_survival:?}"
5752                    ),
5753                });
5754            }
5755            Ok(log_survival)
5756        }
5757        1 => {
5758            if !log_survival.is_finite() || log_survival >= 0.0 {
5759                return Err(LatentSurvivalError::NumericalFailure {
5760                    reason: format!(
5761                        "latent-binary requires finite log survival < 0 for an observed event, got {log_survival:?}"
5762                    ),
5763                });
5764            }
5765            let event_prob = -log_survival.exp_m1();
5766            if !(event_prob.is_finite() && event_prob > 0.0) {
5767                return Err(LatentSurvivalError::NumericalFailure {
5768                    reason: format!(
5769                        "latent-binary event probability is not representable from log survival {log_survival:?}"
5770                    ),
5771                });
5772            }
5773            Ok(event_prob.ln())
5774        }
5775        _ => Err(LatentSurvivalError::InvalidDataset {
5776            reason: format!("latent-binary requires event targets in {{0,1}}, got {event}"),
5777        }),
5778    }
5779}
5780
5781/// Value and first log-survival derivative for the binary row transform.
5782fn binary_from_log_survival_through_first(
5783    log_survival: f64,
5784    event: u8,
5785) -> Result<(f64, f64), LatentSurvivalError> {
5786    let log_lik = binary_log_likelihood_from_log_survival(log_survival, event)?;
5787    if event == 0 {
5788        return Ok((log_lik, 1.0));
5789    }
5790    let odds = (log_survival - log_lik).exp();
5791    if !odds.is_finite() {
5792        return Err(LatentSurvivalError::NumericalFailure {
5793            reason: format!(
5794                "latent-binary log-survival derivative order 1 is not representable at {log_survival:?}: {odds:?}"
5795            ),
5796        });
5797    }
5798    Ok((log_lik, -odds))
5799}
5800
5801/// Analytic source of truth for derivatives of
5802/// `ell(s) = log(1 - exp(s))`, evaluated directly in the log-survival
5803/// coordinate `s < 0`.
5804///
5805/// `P = -expm1(s)` avoids cancellation when survival is near one. Writing the
5806/// derivative algebra in terms of the odds `r = exp(s) / P` avoids the `P²`
5807/// intermediate that previously underflowed before a finite ratio could be
5808/// formed. This base routine computes only through order two; third/fourth
5809/// derivatives are validated lazily by the directional-Hessian paths that
5810/// consume them, so an unrepresentable unused fourth derivative cannot reject
5811/// an otherwise representable likelihood, gradient, and Hessian.
5812fn binary_log_survival_scales(log_survival: f64) -> Result<(f64, f64, f64), LatentSurvivalError> {
5813    let (log_lik, ell_prime) = binary_from_log_survival_through_first(log_survival, 1)?;
5814    let odds = -ell_prime;
5815    let one_plus_odds = 1.0 + odds;
5816    let ell_pp = -odds * one_plus_odds;
5817    let scales = [log_lik, ell_prime, ell_pp];
5818    if let Some((order, value)) = scales
5819        .iter()
5820        .enumerate()
5821        .find(|(_, value)| !value.is_finite())
5822    {
5823        return Err(LatentSurvivalError::NumericalFailure {
5824            reason: format!(
5825                "latent-binary log-survival derivative order {order} is not representable at {log_survival:?}: {value:?}"
5826            ),
5827        });
5828    }
5829    Ok((log_lik, ell_prime, ell_pp))
5830}
5831
5832fn binary_from_log_survival(
5833    log_survival: f64,
5834    event: u8,
5835) -> Result<BinaryFromLogSurvival, LatentSurvivalError> {
5836    if event == 0 {
5837        // ℓ(s) = s ⇒ ℓ' = 1, ℓ'' = ℓ''' = ℓ'''' = 0.
5838        return Ok(BinaryFromLogSurvival {
5839            log_lik: binary_log_likelihood_from_log_survival(log_survival, event)?,
5840            grad_scale: 1.0,
5841            neg_hess_scale: 1.0,
5842            outer_scale: 0.0,
5843        });
5844    }
5845    if event != 1 {
5846        return Err(LatentSurvivalError::InvalidDataset {
5847            reason: format!("latent-binary requires event targets in {{0,1}}, got {event}"),
5848        });
5849    }
5850    let (log_lik, ell_prime, ell_pp) = binary_log_survival_scales(log_survival)?;
5851    let grad_scale = ell_prime;
5852    let neg_hess_scale = ell_prime; // coefficient on (-d²s/dβ²); equals ℓ'.
5853    let outer_scale = -ell_pp;
5854    // The Newton accumulator at the call sites computes
5855    //     neg_Hess(log_lik) = neg_hess_scale * (-d²s/dβ²) + outer_scale * (ds/dβ)²
5856    // For this identity to hold by the chain rule, the coefficient on the
5857    // neg_hessian term must equal ℓ' (== grad_scale). Document the invariant.
5858    assert!(
5859        (grad_scale - neg_hess_scale).abs() <= 1e-15 * grad_scale.abs().max(1.0),
5860        "binary_from_log_survival invariant: neg_hess_scale ({neg_hess_scale}) must equal grad_scale ({grad_scale}) so that grad_scale and the coefficient on neg_hessian share sign"
5861    );
5862    assert!(
5863        outer_scale >= 0.0 || !outer_scale.is_finite(),
5864        "binary_from_log_survival invariant: outer_scale (= -ℓ'') must be non-negative for event=1; got {outer_scale}"
5865    );
5866    Ok(BinaryFromLogSurvival {
5867        log_lik,
5868        grad_scale,
5869        neg_hess_scale,
5870        outer_scale,
5871    })
5872}
5873
5874/// Binary log-survival chain rule through third order. The extra scalar is
5875/// `d outer_scale / ds = -ℓ'''(s)`. The derivative of `grad_scale` is already
5876/// available exactly as `-base.outer_scale = ℓ''(s)`.
5877fn binary_from_log_survival_through_third(
5878    log_survival: f64,
5879    event: u8,
5880) -> Result<(BinaryFromLogSurvival, f64), LatentSurvivalError> {
5881    let base = binary_from_log_survival(log_survival, event)?;
5882    if event == 0 {
5883        return Ok((base, 0.0));
5884    }
5885    let odds = -base.grad_scale;
5886    let ell_pp = -base.outer_scale;
5887    let ell_ppp = ell_pp * (1.0 + 2.0 * odds);
5888    if !ell_ppp.is_finite() {
5889        return Err(LatentSurvivalError::NumericalFailure {
5890            reason: format!(
5891                "latent-binary log-survival derivative order 3 is not representable at {log_survival:?}: {ell_ppp:?}"
5892            ),
5893        });
5894    }
5895    Ok((base, -ell_ppp))
5896}
5897
5898/// Binary log-survival chain rule through fourth order. Returns
5899/// `(base, -ℓ''', -ℓ'''')`, the first and second derivatives of
5900/// `base.outer_scale` with respect to log survival.
5901fn binary_from_log_survival_through_fourth(
5902    log_survival: f64,
5903    event: u8,
5904) -> Result<(BinaryFromLogSurvival, f64, f64), LatentSurvivalError> {
5905    let (base, outer_scale_prime) = binary_from_log_survival_through_third(log_survival, event)?;
5906    if event == 0 {
5907        return Ok((base, 0.0, 0.0));
5908    }
5909    let odds = -base.grad_scale;
5910    let ell_pp = -base.outer_scale;
5911    let ell_pppp = ell_pp * (1.0 + 6.0 * odds + 6.0 * odds * odds);
5912    if !ell_pppp.is_finite() {
5913        return Err(LatentSurvivalError::NumericalFailure {
5914            reason: format!(
5915                "latent-binary log-survival derivative order 4 is not representable at {log_survival:?}: {ell_pppp:?}"
5916            ),
5917        });
5918    }
5919    Ok((base, outer_scale_prime, -ell_pppp))
5920}
5921
5922/// Fitted frailty-scale coordinate used by exact saved latent-survival ALO.
5923///
5924/// A fixed scale is likelihood metadata and therefore contributes no fitted
5925/// coordinate. A learned scale is represented by the exact raw `log_sigma`
5926/// coefficient consumed by the fitter; replay evaluates `sigma = exp(eta)`
5927/// inside the same primary row program.
5928#[derive(Clone, Copy, Debug)]
5929pub enum LatentSurvivalAloSigma {
5930    Fixed(f64),
5931    LearnedLogScale(f64),
5932}
5933
5934/// One saved latent-survival row in the fitter's affine primary coordinates.
5935pub struct LatentSurvivalAloRowInput<'a> {
5936    pub quadrature: &'a QuadratureContext,
5937    pub hazard_loading: HazardLoading,
5938    pub event_code: u8,
5939    pub prior_weight: f64,
5940    pub q_entry: f64,
5941    pub q_exit: f64,
5942    pub qdot_exit: f64,
5943    pub q_right: f64,
5944    pub mu: f64,
5945    pub sigma: LatentSurvivalAloSigma,
5946    pub unloaded_mass_entry: f64,
5947    pub unloaded_mass_exit: f64,
5948    pub unloaded_mass_right: f64,
5949    pub unloaded_hazard_exit: f64,
5950}
5951
5952/// One saved latent-binary row in its three live affine coordinates
5953/// `[q_entry, q_exit, mu]`.
5954pub struct LatentBinaryAloRowInput<'a> {
5955    pub quadrature: &'a QuadratureContext,
5956    pub hazard_loading: HazardLoading,
5957    pub event: u8,
5958    pub prior_weight: f64,
5959    pub q_entry: f64,
5960    pub q_exit: f64,
5961    pub mu: f64,
5962    pub sigma: f64,
5963    pub unloaded_mass_entry: f64,
5964    pub unloaded_mass_exit: f64,
5965}
5966
5967/// Exact NLL row geometry returned by the saved latent-window replay seam.
5968pub struct LatentWindowAloRowGeometry {
5969    pub nll_score: Array1<f64>,
5970    pub observed_hessian: Array2<f64>,
5971    pub coordinate_values: Array1<f64>,
5972}
5973
5974fn validate_saved_alo_weight(weight: f64, context: &str) -> Result<(), String> {
5975    if weight.is_finite() && weight >= 0.0 {
5976        Ok(())
5977    } else {
5978        Err(format!(
5979            "{context} prior weight must be finite and non-negative, got {weight}"
5980        ))
5981    }
5982}
5983
5984fn checked_saved_alo_scale_vector(
5985    values: Array1<f64>,
5986    scale: f64,
5987    context: &str,
5988) -> Result<Array1<f64>, String> {
5989    let mut out = Array1::<f64>::zeros(values.len());
5990    for (axis, value) in values.into_iter().enumerate() {
5991        let product = scale * value;
5992        if !product.is_finite() || (scale != 0.0 && value != 0.0 && product == 0.0) {
5993            return Err(format!(
5994                "{context}[{axis}] is not representable: {scale:?} * {value:?}"
5995            ));
5996        }
5997        out[axis] = product;
5998    }
5999    Ok(out)
6000}
6001
6002fn checked_saved_alo_scale_matrix(
6003    values: Array2<f64>,
6004    scale: f64,
6005    context: &str,
6006) -> Result<Array2<f64>, String> {
6007    let mut out = Array2::<f64>::zeros(values.dim());
6008    for ((row, column), value) in values.indexed_iter() {
6009        let product = scale * value;
6010        if !product.is_finite() || (scale != 0.0 && *value != 0.0 && product == 0.0) {
6011            return Err(format!(
6012                "{context}[{row},{column}] is not representable: {scale:?} * {value:?}"
6013            ));
6014        }
6015        out[[row, column]] = product;
6016    }
6017    Ok(out)
6018}
6019
6020/// Replay one saved latent-survival likelihood row through the exact fitting
6021/// program.
6022///
6023/// Coordinates are `[q_entry, q_exit, qdot_exit, q_right, mu]` followed by
6024/// `log_sigma` only when that scale was learned. The primary authority returns
6025/// log-likelihood score and negative log-likelihood Hessian; this boundary
6026/// applies the row weight and flips only the score to the NLL convention.
6027pub fn latent_survival_alo_row_geometry(
6028    input: LatentSurvivalAloRowInput<'_>,
6029) -> Result<LatentWindowAloRowGeometry, String> {
6030    validate_saved_alo_weight(input.prior_weight, "latent-survival ALO")?;
6031    let mut coordinate_values = vec![
6032        input.q_entry,
6033        input.q_exit,
6034        input.qdot_exit,
6035        input.q_right,
6036        input.mu,
6037    ];
6038    let (sigma, include_log_sigma) = match input.sigma {
6039        LatentSurvivalAloSigma::Fixed(sigma) => (sigma, false),
6040        LatentSurvivalAloSigma::LearnedLogScale(log_sigma) => {
6041            coordinate_values.push(log_sigma);
6042            (log_sigma.exp(), true)
6043        }
6044    };
6045    let coordinate_values = Array1::from_vec(coordinate_values);
6046    let dimension = coordinate_values.len();
6047    if input.prior_weight == 0.0 {
6048        return Ok(LatentWindowAloRowGeometry {
6049            nll_score: Array1::zeros(dimension),
6050            observed_hessian: Array2::zeros((dimension, dimension)),
6051            coordinate_values,
6052        });
6053    }
6054    if !matches!(input.event_code, 0 | 1 | LATENT_SURVIVAL_EVENT_INTERVAL) {
6055        return Err(format!(
6056            "latent-survival ALO event code must be 0, 1, or the interval sentinel {LATENT_SURVIVAL_EVENT_INTERVAL}, got {}",
6057            input.event_code
6058        ));
6059    }
6060    if !sigma.is_finite()
6061        || sigma < 0.0
6062        || (include_log_sigma && (sigma == 0.0 || !coordinate_values[5].is_finite()))
6063    {
6064        return Err(format!(
6065            "latent-survival ALO frailty scale is invalid: sigma={sigma:?}, learned={include_log_sigma}"
6066        ));
6067    }
6068    if coordinate_values.iter().any(|value| !value.is_finite()) {
6069        return Err("latent-survival ALO affine coordinates must be finite".to_string());
6070    }
6071    let event_type = latent_survival_event_type_for(input.event_code);
6072    let row = build_latent_survival_row(
6073        0,
6074        input.hazard_loading,
6075        event_type,
6076        input.q_entry,
6077        input.q_exit,
6078        input.qdot_exit,
6079        input.q_right,
6080        input.unloaded_mass_entry,
6081        input.unloaded_mass_exit,
6082        input.unloaded_mass_right,
6083        input.unloaded_hazard_exit,
6084    )
6085    .map_err(String::from)?;
6086    let (_, log_likelihood_score, negative_log_likelihood_hessian) =
6087        latent_survival_row_primary_gradient_hessian(
6088            input.quadrature,
6089            &row,
6090            LatentSurvivalPrimaryPoint {
6091                q_entry: input.q_entry,
6092                q_exit: input.q_exit,
6093                qdot_exit: input.qdot_exit,
6094                q_right: input.q_right,
6095                mu: input.mu,
6096                sigma,
6097            },
6098            include_log_sigma,
6099        )?;
6100    let nll_score = checked_saved_alo_scale_vector(
6101        log_likelihood_score.slice(s![0..dimension]).to_owned(),
6102        -input.prior_weight,
6103        "latent-survival ALO NLL score",
6104    )?;
6105    let observed_hessian = checked_saved_alo_scale_matrix(
6106        negative_log_likelihood_hessian
6107            .slice(s![0..dimension, 0..dimension])
6108            .to_owned(),
6109        input.prior_weight,
6110        "latent-survival ALO observed Hessian",
6111    )?;
6112    Ok(LatentWindowAloRowGeometry {
6113        nll_score,
6114        observed_hessian,
6115        coordinate_values,
6116    })
6117}
6118
6119/// Replay one saved latent-binary row through the exact right-censored latent
6120/// survival authority and the fitter's analytic binary-from-log-survival
6121/// chain. `W` is the observed NLL Hessian; the score outer product remains a
6122/// separate downstream ALO covariance channel.
6123pub fn latent_binary_alo_row_geometry(
6124    input: LatentBinaryAloRowInput<'_>,
6125) -> Result<LatentWindowAloRowGeometry, String> {
6126    validate_saved_alo_weight(input.prior_weight, "latent-binary ALO")?;
6127    let coordinate_values = Array1::from_vec(vec![input.q_entry, input.q_exit, input.mu]);
6128    const DIMENSION: usize = 3;
6129    if input.prior_weight == 0.0 {
6130        return Ok(LatentWindowAloRowGeometry {
6131            nll_score: Array1::zeros(DIMENSION),
6132            observed_hessian: Array2::zeros((DIMENSION, DIMENSION)),
6133            coordinate_values,
6134        });
6135    }
6136    if input.event > 1 {
6137        return Err(format!(
6138            "latent-binary ALO event must be 0 or 1, got {}",
6139            input.event
6140        ));
6141    }
6142    if !input.sigma.is_finite() || input.sigma < 0.0 {
6143        return Err(format!(
6144            "latent-binary ALO frailty sigma must be finite and non-negative, got {:?}",
6145            input.sigma
6146        ));
6147    }
6148    if coordinate_values.iter().any(|value| !value.is_finite()) {
6149        return Err("latent-binary ALO affine coordinates must be finite".to_string());
6150    }
6151    let row = build_latent_survival_row(
6152        0,
6153        input.hazard_loading,
6154        LatentSurvivalEventType::RightCensored,
6155        input.q_entry,
6156        input.q_exit,
6157        1.0,
6158        input.q_exit,
6159        input.unloaded_mass_entry,
6160        input.unloaded_mass_exit,
6161        0.0,
6162        0.0,
6163    )
6164    .map_err(String::from)?;
6165    let (log_survival, survival_score, survival_negative_hessian) =
6166        latent_survival_row_primary_gradient_hessian(
6167            input.quadrature,
6168            &row,
6169            LatentSurvivalPrimaryPoint {
6170                q_entry: input.q_entry,
6171                q_exit: input.q_exit,
6172                qdot_exit: 1.0,
6173                q_right: input.q_exit,
6174                mu: input.mu,
6175                sigma: input.sigma,
6176            },
6177            false,
6178        )?;
6179    let binary = binary_from_log_survival(log_survival, input.event).map_err(String::from)?;
6180    let primary_indices = [
6181        LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6182        LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6183        LATENT_SURVIVAL_PRIMARY_MU,
6184    ];
6185    let binary_log_likelihood_score = Array1::from_shape_fn(DIMENSION, |axis| {
6186        binary.grad_scale * survival_score[primary_indices[axis]]
6187    });
6188    let binary_negative_log_likelihood_hessian =
6189        Array2::from_shape_fn((DIMENSION, DIMENSION), |(left, right)| {
6190            let source_left = primary_indices[left];
6191            let source_right = primary_indices[right];
6192            binary.neg_hess_scale * survival_negative_hessian[[source_left, source_right]]
6193                + binary.outer_scale * survival_score[source_left] * survival_score[source_right]
6194        });
6195    let nll_score = checked_saved_alo_scale_vector(
6196        binary_log_likelihood_score,
6197        -input.prior_weight,
6198        "latent-binary ALO NLL score",
6199    )?;
6200    let observed_hessian = checked_saved_alo_scale_matrix(
6201        binary_negative_log_likelihood_hessian,
6202        input.prior_weight,
6203        "latent-binary ALO observed Hessian",
6204    )?;
6205    Ok(LatentWindowAloRowGeometry {
6206        nll_score,
6207        observed_hessian,
6208        coordinate_values,
6209    })
6210}
6211
6212impl LatentBinaryFamily {
6213    /// Assemble the per-row [`LatentSurvivalRow`] for a row treated as a pure
6214    /// right-censored survival contribution (exit time is the censoring
6215    /// boundary, unit exit-hazard derivative, no right / post-exit unloaded
6216    /// mass). Shared by every per-row binary-from-survival pullback reduction;
6217    /// behavior is identical to the previously inlined `RightCensored` call.
6218    fn build_right_censored_row_at(
6219        &self,
6220        row_idx: usize,
6221        q_entry: f64,
6222        q_exit: f64,
6223    ) -> Result<LatentSurvivalRow, LatentSurvivalError> {
6224        build_latent_survival_row(
6225            row_idx,
6226            self.hazard_loading,
6227            LatentSurvivalEventType::RightCensored,
6228            q_entry,
6229            q_exit,
6230            1.0,
6231            q_exit,
6232            self.unloaded_mass_entry[row_idx],
6233            self.unloaded_mass_exit[row_idx],
6234            0.0,
6235            0.0,
6236        )
6237    }
6238
6239    fn joint_slices(&self) -> LatentSurvivalJointSlices {
6240        let p_time = self.x_time_exit.ncols();
6241        let p_mean = self.x_mean.ncols();
6242        LatentSurvivalJointSlices {
6243            time: 0..p_time,
6244            mean: p_time..p_time + p_mean,
6245            log_sigma: None,
6246            total: p_time + p_mean,
6247        }
6248    }
6249
6250    fn row_primary_direction_from_flat(
6251        &self,
6252        row: usize,
6253        slices: &LatentSurvivalJointSlices,
6254        d_beta_flat: &Array1<f64>,
6255    ) -> Array1<f64> {
6256        let mut out = Array1::<f64>::zeros(LATENT_SURVIVAL_PRIMARY_DIM);
6257        let d_time = d_beta_flat.slice(s![slices.time.clone()]);
6258        out[LATENT_SURVIVAL_PRIMARY_Q_ENTRY] = self.x_time_entry.row(row).dot(&d_time);
6259        out[LATENT_SURVIVAL_PRIMARY_Q_EXIT] = self.x_time_exit.row(row).dot(&d_time);
6260        out[LATENT_SURVIVAL_PRIMARY_MU] = self
6261            .x_mean
6262            .dot_row_view(row, d_beta_flat.slice(s![slices.mean.clone()]));
6263        out
6264    }
6265
6266    fn add_pullback_primary_gradient(
6267        &self,
6268        target: &mut Array1<f64>,
6269        row: usize,
6270        slices: &LatentSurvivalJointSlices,
6271        primary_gradient: &Array1<f64>,
6272        weight: f64,
6273    ) -> Result<(), String> {
6274        for (primary_idx, time_vec) in [
6275            (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
6276            (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
6277        ] {
6278            let scale = checked_weighted_row_value(
6279                weight,
6280                primary_gradient[primary_idx],
6281                row,
6282                "binary primary gradient",
6283            )?;
6284            if scale == 0.0 {
6285                continue;
6286            }
6287            for i in 0..time_vec.len() {
6288                let xi = time_vec[i];
6289                if xi != 0.0 {
6290                    target[slices.time.start + i] += scale * xi;
6291                }
6292            }
6293        }
6294
6295        let mean_scale = checked_weighted_row_value(
6296            weight,
6297            primary_gradient[LATENT_SURVIVAL_PRIMARY_MU],
6298            row,
6299            "binary mean gradient",
6300        )?;
6301        if mean_scale != 0.0 {
6302            self.x_mean
6303                .axpy_row_into(
6304                    row,
6305                    mean_scale,
6306                    &mut target.slice_mut(s![slices.mean.clone()]),
6307                )
6308                .map_err(|error| {
6309                    format!(
6310                        "latent binary mean gradient pullback dimension mismatch: row={row}, mean_slice={:?}, target_len={}, x_mean_cols={}, error={error}",
6311                        slices.mean,
6312                        target.len(),
6313                        self.x_mean.ncols()
6314                    )
6315                })?;
6316        }
6317        Ok(())
6318    }
6319
6320    fn add_pullback_primary_hessian(
6321        &self,
6322        target: &mut Array2<f64>,
6323        row: usize,
6324        slices: &LatentSurvivalJointSlices,
6325        primary_hessian: &Array2<f64>,
6326    ) {
6327        {
6328            let time_target = &mut target.slice_mut(s![slices.time.clone(), slices.time.clone()]);
6329            dense_outer_accumulate(
6330                time_target,
6331                primary_hessian[[
6332                    LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6333                    LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6334                ]],
6335                self.x_time_entry.row(row),
6336            );
6337            dense_outer_accumulate(
6338                time_target,
6339                primary_hessian[[
6340                    LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6341                    LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6342                ]],
6343                self.x_time_exit.row(row),
6344            );
6345            dense_symmetric_cross_accumulate(
6346                time_target,
6347                primary_hessian[[
6348                    LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6349                    LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6350                ]],
6351                self.x_time_entry.row(row),
6352                self.x_time_exit.row(row),
6353            );
6354        }
6355
6356        let mean_weight = primary_hessian[[LATENT_SURVIVAL_PRIMARY_MU, LATENT_SURVIVAL_PRIMARY_MU]];
6357        self.x_mean
6358            .syr_row_into_view(
6359                row,
6360                mean_weight,
6361                target.slice_mut(s![slices.mean.clone(), slices.mean.clone()]),
6362            )
6363            .unwrap_or_else(|error| {
6364                // SAFETY: `slices.mean` × `slices.mean` slab sized at
6365                // construction to `x_mean.ncols()` × `x_mean.ncols()`;
6366                // an error here is caller-side shape drift, an invariant
6367                // violation. A swallowed sentinel would silently corrupt the
6368                // joint Hessian, so fail loudly instead.
6369                panic!(
6370                    "latent binary mean Hessian pullback dimension mismatch: row={row}, mean_slice={:?}, target_dim={:?}, x_mean_cols={}, error={error}",
6371                    slices.mean,
6372                    target.dim(),
6373                    self.x_mean.ncols()
6374                )
6375            });
6376
6377        let mean_row = self
6378            .x_mean
6379            .try_row_chunk(row..row + 1)
6380            .unwrap_or_else(|error| {
6381                // SAFETY: row index comes from the enclosing `0..n` loop
6382                // bound by `self.x_mean.nrows()`, so `row..row+1` is
6383                // always a valid single-row chunk.
6384                panic!(
6385                    "latent binary mean pullback row chunk failed: row={row}, x_mean_rows={}, x_mean_cols={}, error={error}",
6386                    self.x_mean.nrows(),
6387                    self.x_mean.ncols()
6388                )
6389            });
6390        let mean_vec = mean_row.row(0);
6391        for (primary_idx, time_vec) in [
6392            (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
6393            (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
6394        ] {
6395            let weight = primary_hessian[[primary_idx, LATENT_SURVIVAL_PRIMARY_MU]];
6396            if weight == 0.0 {
6397                continue;
6398            }
6399            for i in 0..time_vec.len() {
6400                let xi = time_vec[i];
6401                if xi == 0.0 {
6402                    continue;
6403                }
6404                for j in 0..mean_vec.len() {
6405                    let xj = mean_vec[j];
6406                    if xj == 0.0 {
6407                        continue;
6408                    }
6409                    target[[slices.time.start + i, slices.mean.start + j]] += weight * xi * xj;
6410                    target[[slices.mean.start + j, slices.time.start + i]] += weight * xj * xi;
6411                }
6412            }
6413        }
6414    }
6415
6416    fn evaluate_exact_newton_joint_dense(
6417        &self,
6418        block_states: &[ParameterBlockState],
6419    ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
6420        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6421            .map_err(String::from)?;
6422        let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6423        let slices = self.joint_slices();
6424        let mut ll = CompensatedRowSum::default();
6425        let mut gradient = Array1::<f64>::zeros(slices.total);
6426        let mut hessian = Array2::<f64>::zeros((slices.total, slices.total));
6427        for row_idx in 0..self.event_target.len() {
6428            let wi = weights.at(row_idx);
6429            if wi == 0.0 {
6430                continue;
6431            }
6432            let row =
6433                self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6434            let (row_log_survival, survival_gradient, survival_hessian) =
6435                latent_survival_row_primary_gradient_hessian(
6436                    &self.quadctx,
6437                    &row,
6438                    LatentSurvivalPrimaryPoint {
6439                        q_entry: q_entry[row_idx],
6440                        q_exit: q_exit[row_idx],
6441                        qdot_exit: 1.0,
6442                        q_right: q_exit[row_idx],
6443                        mu: mu[row_idx],
6444                        sigma: self.latent_sd,
6445                    },
6446                    false,
6447                )?;
6448            let binary = binary_from_log_survival(row_log_survival, self.event_target[row_idx])?;
6449            ll.add(checked_weighted_row_value(
6450                wi,
6451                binary.log_lik,
6452                row_idx,
6453                "binary log likelihood",
6454            )?);
6455            let primary_gradient = binary.grad_scale * &survival_gradient;
6456            let mut primary_hessian = binary.grad_scale * survival_hessian;
6457            for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6458                for b in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6459                    primary_hessian[[a, b]] +=
6460                        binary.outer_scale * survival_gradient[a] * survival_gradient[b];
6461                }
6462            }
6463            self.add_pullback_primary_gradient(
6464                &mut gradient,
6465                row_idx,
6466                &slices,
6467                &primary_gradient,
6468                wi,
6469            )?;
6470            let weighted_primary_hessian = checked_weighted_row_matrix(
6471                wi,
6472                &primary_hessian,
6473                row_idx,
6474                "binary primary Hessian",
6475            )?;
6476            self.add_pullback_primary_hessian(
6477                &mut hessian,
6478                row_idx,
6479                &slices,
6480                &weighted_primary_hessian,
6481            );
6482        }
6483        let ll = require_finite_likelihood_scalar(ll.value(), "binary log likelihood")?;
6484        require_finite_likelihood_vector(&gradient, "binary gradient")?;
6485        require_finite_likelihood_matrix(&hessian, "binary Hessian")?;
6486        Ok((ll, gradient, hessian))
6487    }
6488
6489    /// Per-row residuals of the unpenalized NLL with respect to the baseline
6490    /// time-block offsets `(entry, exit)`.
6491    ///
6492    /// The latent-binary deployment likelihood is a monotone scalar transform
6493    /// `ℓ_bin = b(log S_row)` of the latent-survival row log-survival, so by the
6494    /// chain rule `∂ℓ_bin/∂q_ch = b'(log S)·∂(log S)/∂q_ch = grad_scale·g_ch`,
6495    /// where `g_ch` are the `Q_ENTRY`/`Q_EXIT` components of the survival row
6496    /// primary gradient. The baseline θ enters only the additive entry/exit time
6497    /// offsets (`q̇_exit` is held at the constant deployment derivative `1`, so
6498    /// the derivative channel carries no baseline offset and its residual is 0).
6499    /// Sampleweight-scaled to match the `OffsetChannelResiduals` contract.
6500    pub fn offset_channel_residuals(
6501        &self,
6502        block_states: &[ParameterBlockState],
6503    ) -> Result<crate::survival::OffsetChannelResiduals, LatentSurvivalError> {
6504        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")?;
6505        let n = self.event_target.len();
6506        // `split_time_eta` returns a typed block-count error before indexing.
6507        // Missing state is never translated into zero residuals, because that
6508        // would falsely certify the enclosing baseline optimization.
6509        let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6510        let mut entry = Array1::<f64>::zeros(n);
6511        let mut exit = Array1::<f64>::zeros(n);
6512        for row_idx in 0..n {
6513            let wi = weights.at(row_idx);
6514            if wi == 0.0 {
6515                continue;
6516            }
6517            let row =
6518                self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6519            let (row_log_survival, survival_gradient, _) =
6520                latent_survival_row_primary_gradient_hessian(
6521                    &self.quadctx,
6522                    &row,
6523                    LatentSurvivalPrimaryPoint {
6524                        q_entry: q_entry[row_idx],
6525                        q_exit: q_exit[row_idx],
6526                        qdot_exit: 1.0,
6527                        q_right: q_exit[row_idx],
6528                        mu: mu[row_idx],
6529                        sigma: self.latent_sd,
6530                    },
6531                    false,
6532                )?;
6533            let (_, grad_scale) = binary_from_log_survival_through_first(
6534                row_log_survival,
6535                self.event_target[row_idx],
6536            )?;
6537            // ∂NLL/∂o_ch = −w · grad_scale · ∂(log S)/∂q_ch.
6538            entry[row_idx] = -checked_weighted_row_value(
6539                wi,
6540                grad_scale * survival_gradient[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
6541                row_idx,
6542                "binary entry-offset score",
6543            )
6544            .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
6545            exit[row_idx] = -checked_weighted_row_value(
6546                wi,
6547                grad_scale * survival_gradient[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
6548                row_idx,
6549                "binary exit-offset score",
6550            )
6551            .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
6552        }
6553        Ok(crate::survival::OffsetChannelResiduals {
6554            exit,
6555            entry,
6556            derivative: Array1::<f64>::zeros(n),
6557            // Latent-binary deployment has no interval upper bound; the `R`
6558            // channel is structurally absent (every row is right-censored).
6559            right: Array1::<f64>::zeros(n),
6560        })
6561    }
6562
6563    fn exact_newton_joint_hessian_directional_derivative_dense(
6564        &self,
6565        block_states: &[ParameterBlockState],
6566        d_beta_flat: &Array1<f64>,
6567    ) -> Result<Array2<f64>, String> {
6568        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6569            .map_err(String::from)?;
6570        let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6571        let slices = self.joint_slices();
6572        if d_beta_flat.len() != slices.total {
6573            return Err(format!(
6574                "latent binary joint dH direction length mismatch: got {}, expected {}",
6575                d_beta_flat.len(),
6576                slices.total
6577            ));
6578        }
6579        let mut out = Array2::<f64>::zeros((slices.total, slices.total));
6580        for row_idx in 0..self.event_target.len() {
6581            let wi = weights.at(row_idx);
6582            if wi == 0.0 {
6583                continue;
6584            }
6585            let row =
6586                self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6587            let direction = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_flat);
6588            // OneSeed already carries the ordinary value/gradient/Hessian in
6589            // its base part.  Reuse those channels for the binary outer chain
6590            // instead of running a separate Order2 row first.
6591            let row_jet = latent_survival_row_primary_one_seed_fixed_sigma(
6592                &self.quadctx,
6593                &row,
6594                LatentSurvivalPrimaryPoint {
6595                    q_entry: q_entry[row_idx],
6596                    q_exit: q_exit[row_idx],
6597                    qdot_exit: 1.0,
6598                    q_right: q_exit[row_idx],
6599                    mu: mu[row_idx],
6600                    sigma: self.latent_sd,
6601                },
6602                &direction,
6603            )?;
6604            let (binary, outer_scale_prime) = binary_from_log_survival_through_third(
6605                row_jet.base.value(),
6606                self.event_target[row_idx],
6607            )?;
6608            let base_gradient = row_jet.base.g();
6609            let base_hessian = row_jet.base.h();
6610            let contracted_third = row_jet.contracted_third();
6611            let survival_gradient = Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| {
6612                if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
6613                    base_gradient[a]
6614                } else {
6615                    0.0
6616                }
6617            });
6618            let survival_hessian = Array2::from_shape_fn(
6619                (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
6620                |(a, b)| {
6621                    if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6622                        && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6623                    {
6624                        -base_hessian[a][b]
6625                    } else {
6626                        0.0
6627                    }
6628                },
6629            );
6630            let third = Array2::from_shape_fn(
6631                (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
6632                |(a, b)| {
6633                    if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6634                        && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6635                    {
6636                        -contracted_third[a][b]
6637                    } else {
6638                        0.0
6639                    }
6640                },
6641            );
6642            let g_u = -survival_hessian.dot(&direction);
6643            let t_u = survival_gradient.dot(&direction);
6644            let mut primary = binary.grad_scale * third;
6645            primary.scaled_add(-binary.outer_scale * t_u, &survival_hessian);
6646            for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6647                for b in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6648                    primary[[a, b]] +=
6649                        outer_scale_prime * t_u * survival_gradient[a] * survival_gradient[b]
6650                            + binary.outer_scale
6651                                * (g_u[a] * survival_gradient[b] + survival_gradient[a] * g_u[b]);
6652                }
6653            }
6654            let weighted_primary =
6655                checked_weighted_row_matrix(wi, &primary, row_idx, "binary contracted third")?;
6656            self.add_pullback_primary_hessian(&mut out, row_idx, &slices, &weighted_primary);
6657        }
6658        require_finite_likelihood_matrix(&out, "binary directional Hessian derivative")?;
6659        Ok(out)
6660    }
6661
6662    fn exact_newton_joint_hessian_second_directional_derivative_dense(
6663        &self,
6664        block_states: &[ParameterBlockState],
6665        d_beta_u_flat: &Array1<f64>,
6666        d_beta_v_flat: &Array1<f64>,
6667    ) -> Result<Array2<f64>, String> {
6668        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6669            .map_err(String::from)?;
6670        let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6671        let slices = self.joint_slices();
6672        if d_beta_u_flat.len() != slices.total || d_beta_v_flat.len() != slices.total {
6673            return Err(format!(
6674                "latent binary joint d2H direction length mismatch: got {} and {}, expected {}",
6675                d_beta_u_flat.len(),
6676                d_beta_v_flat.len(),
6677                slices.total
6678            ));
6679        }
6680        let mut out = Array2::<f64>::zeros((slices.total, slices.total));
6681        for row_idx in 0..self.event_target.len() {
6682            let wi = weights.at(row_idx);
6683            if wi == 0.0 {
6684                continue;
6685            }
6686            let row =
6687                self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6688            let direction_u = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_u_flat);
6689            let direction_v = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_v_flat);
6690            // One TwoSeed row contains the base VGH, both one-seed Hessians,
6691            // and the mixed two-seed Hessian.  The previous composition ran
6692            // four complete rows (Order2 + OneSeed(u) + OneSeed(v) +
6693            // TwoSeed(u,v)) to recover these same channels.
6694            let row_jet = latent_survival_row_primary_two_seed_fixed_sigma(
6695                &self.quadctx,
6696                &row,
6697                LatentSurvivalPrimaryPoint {
6698                    q_entry: q_entry[row_idx],
6699                    q_exit: q_exit[row_idx],
6700                    qdot_exit: 1.0,
6701                    q_right: q_exit[row_idx],
6702                    mu: mu[row_idx],
6703                    sigma: self.latent_sd,
6704                },
6705                &direction_u,
6706                &direction_v,
6707            )?;
6708            let (binary, outer_scale_prime, outer_scale_second) =
6709                binary_from_log_survival_through_fourth(
6710                    row_jet.base.value(),
6711                    self.event_target[row_idx],
6712                )?;
6713            let base_gradient = row_jet.base.g();
6714            let base_hessian = row_jet.base.h();
6715            let contracted_third_u = row_jet.eps.h();
6716            let contracted_third_v = row_jet.del.h();
6717            let contracted_fourth = row_jet.contracted_fourth();
6718            let survival_gradient = Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| {
6719                if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
6720                    base_gradient[a]
6721                } else {
6722                    0.0
6723                }
6724            });
6725            let pad_matrix =
6726                |matrix: &[[f64; LATENT_SURVIVAL_PRIMARY_LOG_SIGMA];
6727                      LATENT_SURVIVAL_PRIMARY_LOG_SIGMA]| {
6728                    Array2::from_shape_fn(
6729                        (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
6730                        |(a, b)| {
6731                            if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6732                                && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6733                            {
6734                                -matrix[a][b]
6735                            } else {
6736                                0.0
6737                            }
6738                        },
6739                    )
6740                };
6741            let survival_hessian = pad_matrix(&base_hessian);
6742            let third_u = pad_matrix(&contracted_third_u);
6743            let third_v = pad_matrix(&contracted_third_v);
6744            let fourth = pad_matrix(&contracted_fourth);
6745            let g_u = -survival_hessian.dot(&direction_u);
6746            let g_v = -survival_hessian.dot(&direction_v);
6747            let g_uv = -third_v.dot(&direction_u);
6748            let t_u = survival_gradient.dot(&direction_u);
6749            let t_v = survival_gradient.dot(&direction_v);
6750            let l_uv = -direction_u.dot(&survival_hessian.dot(&direction_v));
6751            let grad_scale_prime = -binary.outer_scale;
6752            let grad_scale_second = -outer_scale_prime;
6753            let c_u = grad_scale_prime * t_u;
6754            let c_v = grad_scale_prime * t_v;
6755            let c_uv = grad_scale_second * t_u * t_v + grad_scale_prime * l_uv;
6756            let o_u = outer_scale_prime * t_u;
6757            let o_v = outer_scale_prime * t_v;
6758            let o_uv = outer_scale_second * t_u * t_v + outer_scale_prime * l_uv;
6759            let mut primary = binary.grad_scale * fourth;
6760            primary.scaled_add(c_u, &third_v);
6761            primary.scaled_add(c_v, &third_u);
6762            primary.scaled_add(c_uv, &survival_hessian);
6763            for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6764                for b in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6765                    primary[[a, b]] += o_uv * survival_gradient[a] * survival_gradient[b]
6766                        + o_v * (g_u[a] * survival_gradient[b] + survival_gradient[a] * g_u[b])
6767                        + o_u * (g_v[a] * survival_gradient[b] + survival_gradient[a] * g_v[b])
6768                        + binary.outer_scale
6769                            * (g_uv[a] * survival_gradient[b]
6770                                + g_u[a] * g_v[b]
6771                                + g_v[a] * g_u[b]
6772                                + survival_gradient[a] * g_uv[b]);
6773                }
6774            }
6775            let weighted_primary =
6776                checked_weighted_row_matrix(wi, &primary, row_idx, "binary contracted fourth")?;
6777            self.add_pullback_primary_hessian(&mut out, row_idx, &slices, &weighted_primary);
6778        }
6779        require_finite_likelihood_matrix(&out, "binary second directional Hessian derivative")?;
6780        Ok(out)
6781    }
6782}
6783
6784/// Shared interface that both `LatentSurvivalFamily` and `LatentBinaryFamily`
6785/// expose to the joint Hessian workspace.
6786///
6787/// The two families produce the same `ExactNewtonJointHessianWorkspace`
6788/// shape — five of the six workspace methods are pure delegations to a
6789/// matching family method (dense evaluation, directional derivatives, and the
6790/// `slices` cache). The only family-specific piece is the per-row matvec body:
6791/// the survival family iterates over real (entry, exit, ḋ) triples and may
6792/// carry a log-σ block, while the binary family rewrites the same row kernel
6793/// through `binary_from_log_survival(·)` to recover the per-row binary
6794/// gradient/Hessian. That single difference is captured by `ws_matvec_into`;
6795/// every other method is shared by the generic `LatentHessianWorkspace<F>`
6796/// below.
6797trait LatentJointHessianFamily {
6798    fn ws_joint_slices(&self) -> LatentSurvivalJointSlices;
6799
6800    fn ws_evaluate_dense(
6801        &self,
6802        block_states: &[ParameterBlockState],
6803    ) -> Result<(f64, Array1<f64>, Array2<f64>), String>;
6804
6805    fn ws_dh_directional(
6806        &self,
6807        block_states: &[ParameterBlockState],
6808        d_beta_flat: &Array1<f64>,
6809    ) -> Result<Array2<f64>, String>;
6810
6811    fn ws_dh_second_directional(
6812        &self,
6813        block_states: &[ParameterBlockState],
6814        d_beta_u: &Array1<f64>,
6815        d_beta_v: &Array1<f64>,
6816    ) -> Result<Array2<f64>, String>;
6817
6818    /// Family-specific per-row Hessian matvec body, hoisted out of the
6819    /// workspace impl. Writes `out := H · v` (with `out.fill(0.0)` already
6820    /// performed by the caller) using the family's row kernel.
6821    fn ws_matvec_into(
6822        &self,
6823        slices: &LatentSurvivalJointSlices,
6824        block_states: &[ParameterBlockState],
6825        v: &Array1<f64>,
6826        out: &mut Array1<f64>,
6827    ) -> Result<bool, String>;
6828
6829    /// Family-name fragment used in the workspace's dimension-mismatch error
6830    /// message, so callers still see "latent survival …" / "latent binary …"
6831    /// after the workspace impl was unified.
6832    fn ws_label() -> &'static str;
6833}
6834
6835impl LatentJointHessianFamily for LatentSurvivalFamily {
6836    fn ws_joint_slices(&self) -> LatentSurvivalJointSlices {
6837        self.joint_slices()
6838    }
6839
6840    fn ws_evaluate_dense(
6841        &self,
6842        block_states: &[ParameterBlockState],
6843    ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
6844        self.evaluate_exact_newton_joint_dense(block_states)
6845    }
6846
6847    fn ws_dh_directional(
6848        &self,
6849        block_states: &[ParameterBlockState],
6850        d_beta_flat: &Array1<f64>,
6851    ) -> Result<Array2<f64>, String> {
6852        self.exact_newton_joint_hessian_directional_derivative_dense(block_states, d_beta_flat)
6853    }
6854
6855    fn ws_dh_second_directional(
6856        &self,
6857        block_states: &[ParameterBlockState],
6858        d_beta_u: &Array1<f64>,
6859        d_beta_v: &Array1<f64>,
6860    ) -> Result<Array2<f64>, String> {
6861        self.exact_newton_joint_hessian_second_directional_derivative_dense(
6862            block_states,
6863            d_beta_u,
6864            d_beta_v,
6865        )
6866    }
6867
6868    fn ws_matvec_into(
6869        &self,
6870        slices: &LatentSurvivalJointSlices,
6871        block_states: &[ParameterBlockState],
6872        v: &Array1<f64>,
6873        out: &mut Array1<f64>,
6874    ) -> Result<bool, String> {
6875        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
6876            .map_err(String::from)?;
6877        let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
6878        let q_right = self.time_q_right(block_states)?;
6879        let sigma = self.latent_sd(block_states)?;
6880        let include_log_sigma = slices.log_sigma.is_some();
6881        for row_idx in 0..self.event_target.len() {
6882            let wi = weights.at(row_idx);
6883            if wi == 0.0 {
6884                continue;
6885            }
6886            let row = self.build_row_at(
6887                row_idx,
6888                q_entry[row_idx],
6889                q_exit[row_idx],
6890                qdot_exit[row_idx],
6891                q_right[row_idx],
6892            )?;
6893            let (_, _, primary_hessian) = latent_survival_row_primary_gradient_hessian(
6894                &self.quadctx,
6895                &row,
6896                LatentSurvivalPrimaryPoint {
6897                    q_entry: q_entry[row_idx],
6898                    q_exit: q_exit[row_idx],
6899                    qdot_exit: qdot_exit[row_idx],
6900                    q_right: q_right[row_idx],
6901                    mu: mu[row_idx],
6902                    sigma,
6903                },
6904                include_log_sigma,
6905            )?;
6906            let primary_dir = self.row_primary_direction_from_flat(row_idx, slices, v);
6907            let primary_hv = primary_hessian.dot(&primary_dir);
6908            self.add_pullback_primary_gradient(out, row_idx, slices, &primary_hv, wi)?;
6909        }
6910        require_finite_likelihood_vector(out, "Hessian matvec")?;
6911        Ok(true)
6912    }
6913
6914    fn ws_label() -> &'static str {
6915        "survival"
6916    }
6917}
6918
6919impl LatentJointHessianFamily for LatentBinaryFamily {
6920    fn ws_joint_slices(&self) -> LatentSurvivalJointSlices {
6921        self.joint_slices()
6922    }
6923
6924    fn ws_evaluate_dense(
6925        &self,
6926        block_states: &[ParameterBlockState],
6927    ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
6928        self.evaluate_exact_newton_joint_dense(block_states)
6929    }
6930
6931    fn ws_dh_directional(
6932        &self,
6933        block_states: &[ParameterBlockState],
6934        d_beta_flat: &Array1<f64>,
6935    ) -> Result<Array2<f64>, String> {
6936        self.exact_newton_joint_hessian_directional_derivative_dense(block_states, d_beta_flat)
6937    }
6938
6939    fn ws_dh_second_directional(
6940        &self,
6941        block_states: &[ParameterBlockState],
6942        d_beta_u: &Array1<f64>,
6943        d_beta_v: &Array1<f64>,
6944    ) -> Result<Array2<f64>, String> {
6945        self.exact_newton_joint_hessian_second_directional_derivative_dense(
6946            block_states,
6947            d_beta_u,
6948            d_beta_v,
6949        )
6950    }
6951
6952    fn ws_matvec_into(
6953        &self,
6954        slices: &LatentSurvivalJointSlices,
6955        block_states: &[ParameterBlockState],
6956        v: &Array1<f64>,
6957        out: &mut Array1<f64>,
6958    ) -> Result<bool, String> {
6959        let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6960            .map_err(String::from)?;
6961        let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6962        for row_idx in 0..self.event_target.len() {
6963            let wi = weights.at(row_idx);
6964            if wi == 0.0 {
6965                continue;
6966            }
6967            let row =
6968                self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6969            let (row_log_survival, survival_gradient, survival_hessian) =
6970                latent_survival_row_primary_gradient_hessian(
6971                    &self.quadctx,
6972                    &row,
6973                    LatentSurvivalPrimaryPoint {
6974                        q_entry: q_entry[row_idx],
6975                        q_exit: q_exit[row_idx],
6976                        qdot_exit: 1.0,
6977                        q_right: q_exit[row_idx],
6978                        mu: mu[row_idx],
6979                        sigma: self.latent_sd,
6980                    },
6981                    false,
6982                )?;
6983            let binary = binary_from_log_survival(row_log_survival, self.event_target[row_idx])?;
6984            let primary_dir = self.row_primary_direction_from_flat(row_idx, slices, v);
6985            let mut primary_hv = binary.grad_scale * survival_hessian.dot(&primary_dir);
6986            let outer_dot = survival_gradient.dot(&primary_dir);
6987            for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6988                primary_hv[a] += binary.outer_scale * survival_gradient[a] * outer_dot;
6989            }
6990            self.add_pullback_primary_gradient(out, row_idx, slices, &primary_hv, wi)?;
6991        }
6992        require_finite_likelihood_vector(out, "binary Hessian matvec")?;
6993        Ok(true)
6994    }
6995
6996    fn ws_label() -> &'static str {
6997        "binary"
6998    }
6999}
7000
7001/// Joint exact-Newton Hessian workspace shared by `LatentSurvivalFamily` and
7002/// `LatentBinaryFamily`. The two families plug into the workspace via
7003/// `LatentJointHessianFamily`; this struct holds the shared bookkeeping
7004/// (block states + cached slices) and routes every trait method either through
7005/// a thin family delegation or through the family's `ws_matvec_into` row
7006/// kernel.
7007struct LatentHessianWorkspace<F: LatentJointHessianFamily> {
7008    family: F,
7009    block_states: Vec<ParameterBlockState>,
7010    slices: LatentSurvivalJointSlices,
7011}
7012
7013impl<F: LatentJointHessianFamily> LatentHessianWorkspace<F> {
7014    fn new(family: F, block_states: Vec<ParameterBlockState>) -> Self {
7015        let slices = family.ws_joint_slices();
7016        Self {
7017            family,
7018            block_states,
7019            slices,
7020        }
7021    }
7022}
7023
7024impl<F> ExactNewtonJointHessianWorkspace for LatentHessianWorkspace<F>
7025where
7026    F: LatentJointHessianFamily + Send + Sync + 'static,
7027{
7028    fn warm_up_outer_caches_for_mode(
7029        &self,
7030        eval_mode: gam_problem::EvalMode,
7031    ) -> Result<(), String> {
7032        match eval_mode {
7033            gam_problem::EvalMode::ValueOnly
7034            | gam_problem::EvalMode::ValueAndGradient
7035            | gam_problem::EvalMode::ValueGradientHessian => Ok(()),
7036        }
7037    }
7038
7039    fn hessian_dense(&self) -> Result<Option<Array2<f64>>, String> {
7040        self.family
7041            .ws_evaluate_dense(&self.block_states)
7042            .map(|(_, _, hessian)| Some(hessian))
7043    }
7044
7045    fn hessian_matvec(&self, v: &Array1<f64>) -> Result<Option<Array1<f64>>, String> {
7046        let mut out = Array1::<f64>::zeros(self.slices.total);
7047        self.hessian_matvec_into(v, &mut out)?;
7048        Ok(Some(out))
7049    }
7050
7051    fn hessian_matvec_into(&self, v: &Array1<f64>, out: &mut Array1<f64>) -> Result<bool, String> {
7052        if v.len() != self.slices.total || out.len() != self.slices.total {
7053            return Err(format!(
7054                "latent {} Hessian matvec dimension mismatch: v={} out={} expected={}",
7055                F::ws_label(),
7056                v.len(),
7057                out.len(),
7058                self.slices.total
7059            ));
7060        }
7061        out.fill(0.0);
7062        self.family
7063            .ws_matvec_into(&self.slices, &self.block_states, v, out)
7064    }
7065
7066    fn hessian_diagonal(&self) -> Result<Option<Array1<f64>>, String> {
7067        let dense = self.family.ws_evaluate_dense(&self.block_states)?.2;
7068        Ok(Some(dense.diag().to_owned()))
7069    }
7070
7071    fn directional_derivative(
7072        &self,
7073        d_beta_flat: &Array1<f64>,
7074    ) -> Result<Option<Array2<f64>>, String> {
7075        self.family
7076            .ws_dh_directional(&self.block_states, d_beta_flat)
7077            .map(Some)
7078    }
7079
7080    fn second_directional_derivative(
7081        &self,
7082        d_beta_u: &Array1<f64>,
7083        d_beta_v: &Array1<f64>,
7084    ) -> Result<Option<Array2<f64>>, String> {
7085        self.family
7086            .ws_dh_second_directional(&self.block_states, d_beta_u, d_beta_v)
7087            .map(Some)
7088    }
7089}
7090
7091type LatentSurvivalHessianWorkspace = LatentHessianWorkspace<LatentSurvivalFamily>;
7092type LatentBinaryHessianWorkspace = LatentHessianWorkspace<LatentBinaryFamily>;
7093
7094/// `CustomFamily` for both latent families. Lexically split out (#2601)
7095/// when this file hit the 10,000-line ceiling; see `survival/custom_family.rs`.
7096mod custom_family;
7097
7098/// The #2566 `log sigma` curvature certificate and its independent authority.
7099/// Split out so the tracked scanner exemption covers the certificate machinery
7100/// rather than this whole file, which is the fit math and must stay covered.
7101mod log_sigma_curvature_certificate;
7102
7103pub use log_sigma_curvature_certificate::{
7104    CertifiedLogSigmaCurvature, latent_survival_log_sigma_curvature_certified,
7105};
7106
7107
7108#[cfg(test)]
7109mod tests;