Skip to main content

gam_models/survival/
base.rs

1use crate::custom_family::{
2    BlockWorkingSet, CustomFamily, FamilyEvaluation, ParameterBlockState,
3    projected_linear_constraint_stationarity_vector,
4};
5use crate::model_types::EstimationError;
6use gam_linalg::faer_ndarray::{fast_atv, fast_av, fast_xt_diag_x, fast_xt_diag_y};
7use gam_linalg::matrix::SymmetricMatrix;
8use gam_problem::{Coefficients, LinearPredictor};
9use gam_row_macros::row_atom;
10use gam_solve::pirls::{
11    ConstraintSet, LinearInequalityConstraints, WorkingModel as PirlsWorkingModel, WorkingState,
12    array1_l2_norm,
13};
14use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayView3, Axis};
15use opt::{BacktrackConfig, RidgeSchedule, backtracking_line_search, constants, escalate_ridge};
16use serde::{Deserialize, Serialize};
17use std::collections::BTreeMap;
18use std::convert::Infallible;
19use std::ops::Range;
20use std::sync::LazyLock;
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum SurvivalError {
25    #[error("input dimensions are inconsistent")]
26    DimensionMismatch,
27    #[error("inputs contain non-finite values")]
28    NonFiniteInput,
29    #[error("survival spec '{0}' is not supported by the one-hazard survival engine")]
30    UnsupportedSpec(&'static str),
31    #[error("crude risk integration setup is invalid")]
32    InvalidIntegrationSetup,
33    #[error("survival time grid must be finite, non-negative, and strictly increasing")]
34    InvalidTimeGrid,
35    #[error("cumulative hazard must be nondecreasing")]
36    NonMonotoneCumulativeHazard,
37    #[error("instantaneous hazard must stay strictly positive during integration")]
38    NonPositiveHazard,
39    #[error("{reason}")]
40    InvalidInput { reason: String },
41    #[error("{reason}")]
42    CauseSpecificDimensionMismatch { reason: String },
43    #[error("{reason}")]
44    NumericalFailure { reason: String },
45    #[error("{reason}")]
46    EventCodeInvalid { reason: String },
47    #[error("cause-specific survival block {block}: {source}")]
48    CauseSpecificBlock {
49        block: usize,
50        #[source]
51        source: Box<SurvivalError>,
52    },
53}
54
55impl From<SurvivalError> for String {
56    fn from(err: SurvivalError) -> Self {
57        err.to_string()
58    }
59}
60
61impl From<crate::block_layout::block_count::BlockCountMismatch> for SurvivalError {
62    fn from(err: crate::block_layout::block_count::BlockCountMismatch) -> SurvivalError {
63        SurvivalError::CauseSpecificDimensionMismatch {
64            reason: err.message(),
65        }
66    }
67}
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
70pub enum SurvivalSpec {
71    #[default]
72    Net,
73    Crude,
74}
75
76#[derive(Debug, Clone)]
77pub struct SurvivalEngineInputs<'a> {
78    pub age_entry: ArrayView1<'a, f64>,
79    pub age_exit: ArrayView1<'a, f64>,
80    pub event_target: ArrayView1<'a, u8>,
81    pub event_competing: ArrayView1<'a, u8>,
82    pub sampleweight: ArrayView1<'a, f64>,
83    pub x_entry: ArrayView2<'a, f64>,
84    pub x_exit: ArrayView2<'a, f64>,
85    pub x_derivative: ArrayView2<'a, f64>,
86    /// Optional global monotonicity collocation rows for the full coefficient vector.
87    /// Non-structural survival models should pass these explicitly instead of
88    /// relying on observed derivative rows.
89    pub monotonicity_constraint_rows: Option<ArrayView2<'a, f64>>,
90    /// Baseline offsets corresponding to `monotonicity_constraint_rows`.
91    pub monotonicity_constraint_offsets: Option<ArrayView1<'a, f64>>,
92}
93
94#[derive(Debug, Clone)]
95pub struct SurvivalTimeCovarInputs<'a> {
96    pub age_entry: ArrayView1<'a, f64>,
97    pub age_exit: ArrayView1<'a, f64>,
98    pub event_target: ArrayView1<'a, u8>,
99    pub event_competing: ArrayView1<'a, u8>,
100    pub sampleweight: ArrayView1<'a, f64>,
101    pub time_entry: ArrayView2<'a, f64>,
102    pub time_exit: ArrayView2<'a, f64>,
103    pub time_derivative: ArrayView2<'a, f64>,
104    pub covariates: ArrayView2<'a, f64>,
105    /// Optional global monotonicity collocation rows for the full coefficient vector.
106    /// Non-structural survival models should pass these explicitly instead of
107    /// relying on observed derivative rows.
108    pub monotonicity_constraint_rows: Option<ArrayView2<'a, f64>>,
109    /// Baseline offsets corresponding to `monotonicity_constraint_rows`.
110    pub monotonicity_constraint_offsets: Option<ArrayView1<'a, f64>>,
111}
112
113#[derive(Debug, Clone)]
114pub struct SurvivalBaselineOffsets<'a> {
115    /// Baseline target contribution to eta at entry time: eta_target(t_entry).
116    pub eta_entry: ArrayView1<'a, f64>,
117    /// Baseline target contribution to eta at exit time: eta_target(t_exit).
118    pub eta_exit: ArrayView1<'a, f64>,
119    /// Baseline target contribution to d eta / d t at exit: eta_target'(t_exit).
120    ///
121    /// This is used in event terms where log-hazard requires
122    /// log(d eta / d t). By threading this as an explicit offset, we get
123    /// "parametric default + spline deviation" behavior:
124    /// - strong penalty => deviation ~ 0 => model collapses to baseline target,
125    /// - weak penalty   => deviation can bend away where data supports it.
126    pub derivative_exit: ArrayView1<'a, f64>,
127}
128
129#[derive(Debug, Clone)]
130pub struct PenaltyBlock {
131    pub matrix: Array2<f64>,
132    pub lambda: f64,
133    pub range: Range<usize>,
134    /// Structural nullspace dimension of this penalty matrix.
135    /// Used for exact pseudo-logdet computation. 0 means full rank.
136    pub nullspace_dim: usize,
137}
138
139#[derive(Debug, Clone)]
140pub struct PenaltyBlocks {
141    pub blocks: Vec<PenaltyBlock>,
142}
143
144impl PenaltyBlocks {
145    pub fn new(blocks: Vec<PenaltyBlock>) -> Self {
146        Self { blocks }
147    }
148
149    pub fn gradient(&self, beta: &Array1<f64>) -> Array1<f64> {
150        let mut grad = Array1::zeros(beta.len());
151        for block in &self.blocks {
152            if block.lambda == 0.0 {
153                continue;
154            }
155            let b = beta.slice(ndarray::s![block.range.clone()]);
156            let g = block.matrix.dot(&b);
157            let mut dst = grad.slice_mut(ndarray::s![block.range.clone()]);
158            dst += &(block.lambda * g);
159        }
160        grad
161    }
162
163    pub fn hessian(&self, dim: usize) -> Array2<f64> {
164        let mut h = Array2::zeros((dim, dim));
165        self.addhessian_inplace(&mut h);
166        h
167    }
168
169    pub fn deviance(&self, beta: &Array1<f64>) -> f64 {
170        let mut value = 0.0;
171        for block in &self.blocks {
172            if block.lambda == 0.0 {
173                continue;
174            }
175            let b = beta.slice(ndarray::s![block.range.clone()]);
176            value += 0.5 * block.lambda * b.dot(&block.matrix.dot(&b));
177        }
178        value
179    }
180
181    pub fn addhessian_inplace(&self, h: &mut Array2<f64>) {
182        for block in &self.blocks {
183            if block.lambda == 0.0 {
184                continue;
185            }
186            let start = block.range.start;
187            let end = block.range.end;
188            h.slice_mut(ndarray::s![start..end, start..end])
189                .scaled_add(block.lambda, &block.matrix);
190        }
191    }
192}
193
194/// Entry ages at or below this value are treated as left-truncation at the time
195/// origin, i.e. "no delayed-entry interval" — the cumulative-hazard term
196/// `exp(η_entry)` is dropped because `H(0) = 0`. The Royston-Parmar baseline is
197/// `η(t) = log H(t)` with `H(t) → 0` as `t → 0`, so `log H` diverges at the
198/// origin; this small positive floor lets a row that genuinely enters at time
199/// zero skip the entry contribution instead of evaluating `log H` at a
200/// degenerate point. Shared so every entry-detection site stays in lockstep.
201///
202/// Public so the fit-orchestration layer can classify a dataset as genuinely
203/// left-truncated (`entry > threshold`) with the SAME origin convention the
204/// likelihood engines use, and pick the left-truncation-robust time anchor
205/// accordingly (issue #1790).
206pub const ENTRY_AT_ORIGIN_THRESHOLD: f64 = 1e-8;
207
208/// Fraction-to-the-boundary factor for the cause-specific feasible-step search.
209/// When a Newton direction would drive a row's derivative down to the
210/// monotonicity floor, the step is capped at this fraction of the distance to
211/// the boundary rather than landing exactly on it. Staying strictly inside the
212/// feasible region (the standard interior-point fraction-to-boundary rule)
213/// keeps the next `1/deriv` / `deriv.ln()` evaluation away from the singular
214/// boundary where curvature blows up.
215const DERIVATIVE_FRACTION_TO_BOUNDARY: f64 = 0.995;
216
217/// Relative projected-KKT accuracy required before evaluating survival LAML.
218///
219/// LAML is an envelope at the fitted inner mode. A looser or decrement-only
220/// certificate is not sufficient: differentiating the criterion away from the
221/// mode requires higher-order residual-response terms that the survival family
222/// deliberately does not approximate.
223pub(crate) const SURVIVAL_LAML_STATIONARITY_RELATIVE_TOL: f64 = 1.0e-8;
224
225#[derive(Debug, Clone)]
226pub struct CauseSpecificRoystonParmarBlock {
227    pub age_entry: Array1<f64>,
228    pub age_exit: Array1<f64>,
229    pub event_target: Array1<u8>,
230    pub sampleweight: Array1<f64>,
231    pub x_entry: Array2<f64>,
232    pub x_exit: Array2<f64>,
233    pub x_derivative: Array2<f64>,
234    pub offset_eta_entry: Array1<f64>,
235    pub offset_eta_exit: Array1<f64>,
236    pub offset_derivative_exit: Array1<f64>,
237    pub derivative_floor: f64,
238    /// Number of leading columns that are structural monotone-I-spline time
239    /// columns (`0` for a non-structural block, e.g. a parametric Weibull
240    /// `log t` baseline). When `> 0`, `block_linear_constraints` emits the
241    /// coefficient cone `β_j ≥ 0` for `j in 0..structural_time_columns` in
242    /// addition to the per-row derivative guard. Each such column is a monotone
243    /// non-decreasing I-spline basis (its M-spline derivative is non-negative
244    /// everywhere), so the cone is the exact DOMAIN-WIDE monotonicity
245    /// certificate — the per-row guard only pins `q'(t_i) ≥ floor` at the
246    /// training rows and leaves tail columns (M-spline support beyond the
247    /// largest training exit time, ≈0 at every training row) free to go
248    /// negative, producing a non-monotone `q'(t)` at prediction horizons the
249    /// Royston-Parmar predictor then refuses. Covariate columns
250    /// (`structural_time_columns..p`) are excluded — their coefficients carry
251    /// covariate effects and legitimately take any sign; the constant column is
252    /// already dropped by the I-spline `keep_cols` at construction, so every
253    /// column in `0..structural_time_columns` is a genuine shape column.
254    pub structural_time_columns: usize,
255}
256
257/// Cause-specific competing-risks survival as a blockwise custom family.
258///
259/// Each cause is represented by one `ParameterBlockState`, so endpoint-specific
260/// coefficients, shared smoothing labels, and user-defined coefficient groups
261/// stay on the existing `CustomFamily` / `BlockwiseFitOptions` joint-fit path.
262#[derive(Debug, Clone)]
263pub struct CauseSpecificRoystonParmarFamily {
264    blocks: Vec<CauseSpecificRoystonParmarBlock>,
265}
266
267impl CauseSpecificRoystonParmarFamily {
268    pub fn new(blocks: Vec<CauseSpecificRoystonParmarBlock>) -> Result<Self, String> {
269        if blocks.is_empty() {
270            return Err(SurvivalError::InvalidInput {
271                reason: "cause-specific survival family requires at least one endpoint".to_string(),
272            }
273            .into());
274        }
275        for (idx, block) in blocks.iter().enumerate() {
276            validate_cause_specific_block(block).map_err(|err| {
277                SurvivalError::CauseSpecificBlock {
278                    block: idx + 1,
279                    source: Box::new(err),
280                }
281                .to_string()
282            })?;
283        }
284        Ok(Self { blocks })
285    }
286
287    pub fn cause_count(&self) -> usize {
288        self.blocks.len()
289    }
290}
291
292fn validate_cause_specific_block(
293    block: &CauseSpecificRoystonParmarBlock,
294) -> Result<(), SurvivalError> {
295    let n = block.event_target.len();
296    let p = block.x_exit.ncols();
297    if n == 0 || p == 0 {
298        bail_invalid_surv!("empty event vector or coefficient block");
299    }
300    if block.age_entry.len() != n
301        || block.age_exit.len() != n
302        || block.sampleweight.len() != n
303        || block.x_entry.nrows() != n
304        || block.x_exit.nrows() != n
305        || block.x_derivative.nrows() != n
306        || block.x_entry.ncols() != p
307        || block.x_derivative.ncols() != p
308        || block.offset_eta_entry.len() != n
309        || block.offset_eta_exit.len() != n
310        || block.offset_derivative_exit.len() != n
311    {
312        return Err(SurvivalError::CauseSpecificDimensionMismatch {
313            reason: "dimension mismatch".to_string(),
314        });
315    }
316    // A cause-specific block's `event_target` is the binary cause-k indicator
317    // produced by `cause_specific_event_indicator`; a label > 1 here means the
318    // caller passed raw multi-cause codes instead of projecting per cause. That
319    // is a valid finite label, not non-finite input, so it gets its own clear
320    // error rather than the misleading "non-finite input".
321    if let Some(&label) = block.event_target.iter().find(|&&v| v > 1) {
322        return Err(SurvivalError::EventCodeInvalid {
323            reason: format!(
324                "cause-specific block event_target must be the binary cause indicator {{0, 1}}, got multi-cause label {label}; project raw codes per cause via cause_specific_event_indicator"
325            ),
326        });
327    }
328    if block.age_entry.iter().any(|v| !v.is_finite())
329        || block.age_exit.iter().any(|v| !v.is_finite())
330        || block
331            .sampleweight
332            .iter()
333            .any(|v| !v.is_finite() || *v < 0.0)
334        || block.x_entry.iter().any(|v| !v.is_finite())
335        || block.x_exit.iter().any(|v| !v.is_finite())
336        || block.x_derivative.iter().any(|v| !v.is_finite())
337        || block.offset_eta_entry.iter().any(|v| !v.is_finite())
338        || block.offset_eta_exit.iter().any(|v| !v.is_finite())
339        || block.offset_derivative_exit.iter().any(|v| !v.is_finite())
340        || !block.derivative_floor.is_finite()
341        || block.derivative_floor < 0.0
342    {
343        bail_invalid_surv!("non-finite input");
344    }
345    Ok(())
346}
347
348row_atom! {
349    fn cause_specific_row [generic, order2, third, fourth](
350        eta_exit,
351        eta_entry,
352        derivative;
353        weight: scale,
354        entry_active: bool,
355        event: bool
356    ) {
357        weight
358            * (exp(eta_exit)
359                - entry_active * exp(eta_entry)
360                - event * (eta_exit + ln(derivative)))
361    }
362}
363
364/// Frozen local coordinates for exact saved-model ALO replay of one
365/// cause-specific transformation/Weibull survival row.
366pub struct CauseSpecificSurvivalAloRowInput {
367    pub eta_exit: f64,
368    pub eta_entry: f64,
369    pub derivative_exit: f64,
370    pub prior_weight: f64,
371    pub entry_active: bool,
372    pub event: bool,
373}
374
375/// Negative-log-likelihood geometry in local coordinates
376/// `[eta_exit, eta_entry, derivative_exit]`.
377#[derive(Clone, Debug, PartialEq)]
378pub struct CauseSpecificSurvivalAloRowGeometry {
379    pub negative_log_likelihood: f64,
380    pub nll_score: [f64; 3],
381    pub observed_hessian: [[f64; 3]; 3],
382}
383
384/// Evaluate the exact order-two row atom shared with survival fitting.
385///
386/// Inactive entry/event channels are canonicalised before evaluation so their
387/// score and curvature are exactly zero and cannot form `0 * overflow` or
388/// `0 * log(nonpositive)` intermediates.
389pub fn cause_specific_survival_alo_row_geometry(
390    input: CauseSpecificSurvivalAloRowInput,
391) -> Result<CauseSpecificSurvivalAloRowGeometry, String> {
392    if !input.prior_weight.is_finite() || input.prior_weight < 0.0 {
393        return Err(format!(
394            "cause-specific saved ALO prior weight must be finite and non-negative, got {}",
395            input.prior_weight
396        ));
397    }
398    // The live fit drops zero-weight rows before it evaluates any predictor
399    // channel. Saved replay must preserve that exact measure: a structurally
400    // invalid derivative or overflowing inactive predictor on a row carrying
401    // no likelihood mass is irrelevant, and must not turn an exact zero
402    // score/curvature row into a diagnostic failure.
403    if input.prior_weight == 0.0 {
404        return Ok(CauseSpecificSurvivalAloRowGeometry {
405            negative_log_likelihood: 0.0,
406            nll_score: [0.0; 3],
407            observed_hessian: [[0.0; 3]; 3],
408        });
409    }
410    if !input.eta_exit.is_finite() {
411        return Err(format!(
412            "cause-specific saved ALO exit index must be finite, got {}",
413            input.eta_exit
414        ));
415    }
416    let eta_entry = if input.entry_active {
417        if !input.eta_entry.is_finite() {
418            return Err(format!(
419                "cause-specific saved ALO active entry index must be finite, got {}",
420                input.eta_entry
421            ));
422        }
423        input.eta_entry
424    } else {
425        0.0
426    };
427    let derivative_exit = if input.event {
428        if !input.derivative_exit.is_finite() || input.derivative_exit <= 0.0 {
429            return Err(format!(
430                "cause-specific saved ALO event derivative must be positive and finite, got {}",
431                input.derivative_exit
432            ));
433        }
434        input.derivative_exit
435    } else {
436        1.0
437    };
438    let atom = cause_specific_row_order2(
439        input.eta_exit,
440        eta_entry,
441        derivative_exit,
442        input.prior_weight,
443        input.entry_active,
444        input.event,
445    );
446    let gradient = atom.gradient();
447    let observed_hessian =
448        std::array::from_fn(|row| std::array::from_fn(|column| atom.hessian_at(row, column)));
449    if !atom.value().is_finite()
450        || gradient.iter().any(|value| !value.is_finite())
451        || observed_hessian
452            .iter()
453            .flatten()
454            .any(|value| !value.is_finite())
455    {
456        return Err(format!(
457            "cause-specific saved ALO row geometry is non-finite: nll={}, score={gradient:?}, hessian={observed_hessian:?}",
458            atom.value(),
459        ));
460    }
461    Ok(CauseSpecificSurvivalAloRowGeometry {
462        negative_log_likelihood: atom.value(),
463        nll_score: gradient,
464        observed_hessian,
465    })
466}
467
468#[derive(Clone, Copy)]
469struct CauseSpecificAtomInput {
470    primary: [f64; 3],
471    weight: f64,
472    entry_active: bool,
473    event: bool,
474}
475
476/// Production [`gam_math::jet_tower::RowProgram`] for one cause-specific
477/// Royston-Parmar row.
478///
479/// The generic evaluator and the live specialized order-2/third/fourth
480/// lowerings are all emitted from `cause_specific_row`. Predictor values are
481/// ordered as exit index, entry index, and positive spline derivative; entry
482/// and event activity remain row constants.
483pub struct CauseSpecificRowProgram {
484    primary: [f64; 3],
485    weight: f64,
486    entry_active: bool,
487    event: bool,
488}
489
490impl CauseSpecificRowProgram {
491    /// Construct one canonical predictor-space row program.
492    pub fn new(primary: [f64; 3], weight: f64, entry_active: bool, event: bool) -> Self {
493        Self {
494            primary,
495            weight,
496            entry_active,
497            event,
498        }
499    }
500
501    fn require_row(row: usize) -> Result<(), String> {
502        if row != 0 {
503            return Err(format!(
504                "CauseSpecificRowProgram holds exactly one row; got row {row}"
505            ));
506        }
507        Ok(())
508    }
509}
510
511impl gam_math::jet_tower::RowProgram<3> for CauseSpecificRowProgram {
512    fn n_rows(&self) -> usize {
513        1
514    }
515
516    fn primaries(&self, row: usize) -> Result<[f64; 3], String> {
517        Self::require_row(row)?;
518        Ok(self.primary)
519    }
520
521    fn eval<S: gam_math::jet_scalar::JetScalar<3>>(
522        &self,
523        row: usize,
524        p: &[S; 3],
525    ) -> Result<S, String> {
526        Self::require_row(row)?;
527        Ok(cause_specific_row(
528            &p[0],
529            &p[1],
530            &p[2],
531            self.weight,
532            self.entry_active,
533            self.event,
534        ))
535    }
536}
537
538/// Validate one live row and canonicalise inactive entry/event primaries to
539/// finite neutral values. The symbolic atom still receives the activity flags,
540/// so every derivative of an inactive term is exactly zero without evaluating
541/// `0 * exp(overflow)` or `0 * ln(nonpositive)`.
542fn cause_specific_atom_input(
543    block: &CauseSpecificRoystonParmarBlock,
544    row: usize,
545    eta_entry: f64,
546    eta_exit: f64,
547    derivative: f64,
548) -> Result<Option<CauseSpecificAtomInput>, SurvivalError> {
549    let weight = block.sampleweight[row];
550    if weight <= 0.0 {
551        return Ok(None);
552    }
553    if block.age_exit[row] < block.age_entry[row] {
554        bail_invalid_surv!("age_exit < age_entry at row {row}");
555    }
556    let entry_active = block.age_entry[row] > ENTRY_AT_ORIGIN_THRESHOLD;
557    let event = block.event_target[row] > 0;
558    let eta_entry = if entry_active { eta_entry } else { 0.0 };
559    let derivative = if event {
560        if !(derivative.is_finite() && derivative > 0.0) {
561            return Err(SurvivalError::NumericalFailure {
562                reason: format!(
563                    "cause-specific survival derivative must be positive at row {row}, got {derivative}"
564                ),
565            });
566        }
567        derivative
568    } else {
569        1.0
570    };
571    let h_exit = eta_exit.exp();
572    let h_entry = eta_entry.exp();
573    if !(h_exit.is_finite() && h_entry.is_finite()) {
574        return Err(SurvivalError::NumericalFailure {
575            reason: format!("non-finite cumulative hazard at row {row}"),
576        });
577    }
578    Ok(Some(CauseSpecificAtomInput {
579        primary: [eta_exit, eta_entry, derivative],
580        weight,
581        entry_active,
582        event,
583    }))
584}
585
586const CAUSE_SPECIFIC_PRIMARY_PAIRS: [(usize, usize); 6] =
587    [(0, 0), (0, 1), (0, 2), (1, 1), (1, 2), (2, 2)];
588
589/// Pull all six primary-Hessian channels through the three coefficient-design
590/// Jacobians. Current cross channels are symbolically zero, so their matrix
591/// products are skipped; if the row expression later gains a coupling, that
592/// generated channel becomes live automatically rather than being dropped by a
593/// hand-maintained diagonal assumption.
594fn cause_specific_pullback_hessian(
595    block: &CauseSpecificRoystonParmarBlock,
596    weights: &[Array1<f64>; 6],
597) -> Array2<f64> {
598    let designs = [&block.x_exit, &block.x_entry, &block.x_derivative];
599    let p = block.x_exit.ncols();
600    let mut hessian = Array2::<f64>::zeros((p, p));
601    for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
602        let channel = &weights[slot];
603        if channel.iter().all(|&value| value == 0.0) {
604            continue;
605        }
606        if left == right {
607            hessian += &fast_xt_diag_x(designs[left], channel);
608        } else {
609            let cross = fast_xt_diag_y(designs[left], channel, designs[right]);
610            hessian += &cross;
611            hessian += &cross.t();
612        }
613    }
614    hessian
615}
616
617fn evaluate_cause_specific_block(
618    block: &CauseSpecificRoystonParmarBlock,
619    beta: &Array1<f64>,
620) -> Result<(f64, Array1<f64>, Array2<f64>), SurvivalError> {
621    let n = block.event_target.len();
622    let p = block.x_exit.ncols();
623    if beta.len() != p {
624        return Err(SurvivalError::CauseSpecificDimensionMismatch {
625            reason: format!("beta length mismatch: got {}, expected {p}", beta.len()),
626        });
627    }
628    let eta_entry = fast_av(&block.x_entry, beta) + &block.offset_eta_entry;
629    let eta_exit = fast_av(&block.x_exit, beta) + &block.offset_eta_exit;
630    let derivative = fast_av(&block.x_derivative, beta) + &block.offset_derivative_exit;
631    let mut log_likelihood = 0.0;
632    let mut gradient_weights: [Array1<f64>; 3] = std::array::from_fn(|_| Array1::<f64>::zeros(n));
633    let mut hessian_weights: [Array1<f64>; 6] = std::array::from_fn(|_| Array1::<f64>::zeros(n));
634
635    for i in 0..n {
636        let Some(input) =
637            cause_specific_atom_input(block, i, eta_entry[i], eta_exit[i], derivative[i])?
638        else {
639            continue;
640        };
641        let atom = cause_specific_row_order2(
642            input.primary[0],
643            input.primary[1],
644            input.primary[2],
645            input.weight,
646            input.entry_active,
647            input.event,
648        );
649        log_likelihood -= atom.value();
650        let gradient = atom.gradient();
651        for axis in 0..3 {
652            gradient_weights[axis][i] = -gradient[axis];
653        }
654        for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
655            hessian_weights[slot][i] = atom.hessian_at(left, right);
656        }
657    }
658
659    let designs = [&block.x_exit, &block.x_entry, &block.x_derivative];
660    let mut gradient = Array1::<f64>::zeros(p);
661    for axis in 0..3 {
662        gradient += &fast_atv(designs[axis], &gradient_weights[axis]);
663    }
664    let hessian = cause_specific_pullback_hessian(block, &hessian_weights);
665    Ok((log_likelihood, gradient, hessian))
666}
667
668impl CustomFamily for CauseSpecificRoystonParmarFamily {
669    // Preserve the pre-gam#1395 behavior: the trait default flipped to OFF (the
670    // flat-prior exact-Newton objective carries no Jeffreys term), so families
671    // that historically armed the term by default opt back in explicitly.
672    fn joint_jeffreys_term_required(&self) -> bool {
673        true
674    }
675
676    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
677        crate::block_layout::block_count::validate_block_count::<SurvivalError>(
678            "cause-specific survival",
679            self.blocks.len(),
680            block_states.len(),
681        )?;
682        let mut log_likelihood = 0.0;
683        let mut blockworking_sets = Vec::with_capacity(self.blocks.len());
684        for (block, state) in self.blocks.iter().zip(block_states.iter()) {
685            let (ll, gradient, hessian) = evaluate_cause_specific_block(block, &state.beta)?;
686            log_likelihood += ll;
687            blockworking_sets.push(BlockWorkingSet::ExactNewton {
688                gradient,
689                hessian: SymmetricMatrix::Dense(hessian),
690            });
691        }
692        Ok(FamilyEvaluation {
693            log_likelihood,
694            blockworking_sets,
695        })
696    }
697
698    fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
699        crate::block_layout::block_count::validate_block_count::<SurvivalError>(
700            "cause-specific survival",
701            self.blocks.len(),
702            block_states.len(),
703        )?;
704        let mut log_likelihood = 0.0;
705        for (block, state) in self.blocks.iter().zip(block_states.iter()) {
706            let (ll, _, _) = evaluate_cause_specific_block(block, &state.beta)?;
707            log_likelihood += ll;
708        }
709        Ok(log_likelihood)
710    }
711
712    fn likelihood_blocks_uncoupled(&self) -> bool {
713        true
714    }
715
716    fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
717        true
718    }
719
720    fn output_channel_assignment(
721        &self,
722        specs: &[crate::custom_family::ParameterBlockSpec],
723    ) -> Option<Vec<usize>> {
724        if specs.len() != self.blocks.len() {
725            return Some((0..self.blocks.len()).collect());
726        }
727        Some((0..specs.len()).collect())
728    }
729
730    fn coefficient_hessian_cost(&self, specs: &[crate::custom_family::ParameterBlockSpec]) -> u64 {
731        crate::custom_family::default_coefficient_hessian_cost(specs)
732    }
733
734    fn block_linear_constraints(
735        &self,
736        block_states: &[ParameterBlockState],
737        block_idx: usize,
738        spec: &crate::custom_family::ParameterBlockSpec,
739    ) -> Result<Option<ConstraintSet>, String> {
740        // The constraint rows built below live in block `block_idx`'s coefficient
741        // space, so the index and the spec must agree on which block that is —
742        // otherwise the returned cone would be imposed on the wrong coordinates.
743        let state = block_states.get(block_idx).ok_or_else(|| {
744            SurvivalError::CauseSpecificDimensionMismatch {
745                reason: format!(
746                    "cause-specific survival expected block index < {}, got {block_idx}",
747                    block_states.len()
748                ),
749            }
750            .to_string()
751        })?;
752        if state.beta.len() != spec.design.ncols() {
753            return Err(SurvivalError::CauseSpecificDimensionMismatch {
754                reason: format!(
755                    "cause-specific survival block {block_idx} carries {} coefficient(s) but spec \
756                     '{}' has {} design column(s)",
757                    state.beta.len(),
758                    spec.name,
759                    spec.design.ncols()
760                ),
761            }
762            .into());
763        }
764        let block = self.blocks.get(block_idx).ok_or_else(|| {
765            SurvivalError::CauseSpecificDimensionMismatch {
766                reason: format!(
767                    "cause-specific survival expected block index < {}, got {block_idx}",
768                    self.blocks.len()
769                ),
770            }
771            .to_string()
772        })?;
773        if block.x_derivative.ncols() != spec.design.ncols() {
774            return Err(SurvivalError::CauseSpecificDimensionMismatch {
775                reason: format!(
776                    "cause-specific survival derivative design has {} columns but block '{}' has {}",
777                    block.x_derivative.ncols(),
778                    spec.name,
779                    spec.design.ncols()
780                ),
781            }
782            .into());
783        }
784        let rhs = block
785            .offset_derivative_exit
786            .mapv(|offset| block.derivative_floor - offset);
787        let p = block.x_derivative.ncols();
788        let n_rows = block.x_derivative.nrows();
789        // Structural monotone-I-spline time columns get the coefficient cone
790        // `β_j ≥ 0` appended to the per-row derivative guard. The per-row guard
791        // only pins `q'(t_i) ≥ floor` at training rows; a tail I-spline column
792        // whose M-spline support sits beyond the largest training exit time is
793        // ≈0 at every training row and so escapes it, letting the penalized fit
794        // drive its coefficient negative and make `q'(t)` negative at a
795        // prediction horizon in that column's support (the Royston-Parmar
796        // predictor then refuses the invalid log-cumulative-hazard derivative).
797        // Because each such column is monotone non-decreasing over the whole
798        // axis, `β_j ≥ 0` is the exact domain-wide monotonicity certificate.
799        let structural_cols = block.structural_time_columns.min(p);
800        if structural_cols == 0 {
801            return Ok(Some(ConstraintSet::Dense(LinearInequalityConstraints {
802                a: block.x_derivative.clone(),
803                b: rhs,
804            })));
805        }
806        let mut a = Array2::<f64>::zeros((n_rows + structural_cols, p));
807        a.slice_mut(ndarray::s![..n_rows, ..])
808            .assign(&block.x_derivative);
809        for j in 0..structural_cols {
810            a[[n_rows + j, j]] = 1.0;
811        }
812        let mut b = Array1::<f64>::zeros(n_rows + structural_cols);
813        b.slice_mut(ndarray::s![..n_rows]).assign(&rhs);
814        Ok(Some(ConstraintSet::Dense(LinearInequalityConstraints {
815            a,
816            b,
817        })))
818    }
819
820    fn max_feasible_step_size(
821        &self,
822        block_states: &[ParameterBlockState],
823        block_idx: usize,
824        delta: &Array1<f64>,
825    ) -> Result<Option<f64>, String> {
826        let block = self.blocks.get(block_idx).ok_or_else(|| {
827            SurvivalError::CauseSpecificDimensionMismatch {
828                reason: format!(
829                    "cause-specific survival expected block index < {}, got {block_idx}",
830                    self.blocks.len()
831                ),
832            }
833            .to_string()
834        })?;
835        let state = block_states.get(block_idx).ok_or_else(|| {
836            SurvivalError::CauseSpecificDimensionMismatch {
837                reason: format!(
838                    "cause-specific survival expected {} block states, got {}",
839                    self.blocks.len(),
840                    block_states.len()
841                ),
842            }
843            .to_string()
844        })?;
845        if delta.len() != state.beta.len() || block.x_derivative.ncols() != delta.len() {
846            return Err(SurvivalError::CauseSpecificDimensionMismatch {
847                reason: "cause-specific survival feasible-step dimension mismatch".to_string(),
848            }
849            .into());
850        }
851        let derivative = fast_av(&block.x_derivative, &state.beta) + &block.offset_derivative_exit;
852        let derivative_delta = fast_av(&block.x_derivative, delta);
853        let mut alpha_max = 1.0_f64;
854        for i in 0..derivative.len() {
855            if block.sampleweight[i] <= 0.0 {
856                continue;
857            }
858            let current = derivative[i] - block.derivative_floor;
859            let slope = derivative_delta[i];
860            if slope < 0.0 {
861                if current <= 0.0 {
862                    return Ok(Some(0.0));
863                }
864                alpha_max = alpha_max.min(DERIVATIVE_FRACTION_TO_BOUNDARY * current / -slope);
865            }
866        }
867        Ok(Some(alpha_max.clamp(0.0, 1.0)))
868    }
869
870    fn exact_newton_hessian_directional_derivative(
871        &self,
872        block_states: &[ParameterBlockState],
873        block_idx: usize,
874        d_beta: &Array1<f64>,
875    ) -> Result<Option<Array2<f64>>, String> {
876        let block = self.blocks.get(block_idx).ok_or_else(|| {
877            SurvivalError::CauseSpecificDimensionMismatch {
878                reason: format!(
879                    "cause-specific survival expected block index < {}, got {block_idx}",
880                    self.blocks.len()
881                ),
882            }
883            .to_string()
884        })?;
885        let state = block_states.get(block_idx).ok_or_else(|| {
886            SurvivalError::CauseSpecificDimensionMismatch {
887                reason: format!(
888                    "cause-specific survival expected {} block states, got {}",
889                    self.blocks.len(),
890                    block_states.len()
891                ),
892            }
893            .to_string()
894        })?;
895        Ok(Some(cause_specific_hessian_directional_derivative(
896            block,
897            &state.beta,
898            d_beta,
899        )?))
900    }
901
902    fn exact_newton_hessian_second_directional_derivative(
903        &self,
904        block_states: &[ParameterBlockState],
905        block_idx: usize,
906        d_beta_u: &Array1<f64>,
907        d_beta_v: &Array1<f64>,
908    ) -> Result<Option<Array2<f64>>, String> {
909        let block = self.blocks.get(block_idx).ok_or_else(|| {
910            SurvivalError::CauseSpecificDimensionMismatch {
911                reason: format!(
912                    "cause-specific survival expected block index < {}, got {block_idx}",
913                    self.blocks.len()
914                ),
915            }
916            .to_string()
917        })?;
918        let state = block_states.get(block_idx).ok_or_else(|| {
919            SurvivalError::CauseSpecificDimensionMismatch {
920                reason: format!(
921                    "cause-specific survival expected {} block states, got {}",
922                    self.blocks.len(),
923                    block_states.len()
924                ),
925            }
926            .to_string()
927        })?;
928        Ok(Some(cause_specific_hessian_second_directional_derivative(
929            block,
930            &state.beta,
931            d_beta_u,
932            d_beta_v,
933        )?))
934    }
935}
936
937/// The live third-order coefficient Hessian, lowered at build time from the same
938/// [`cause_specific_row`] expression as value/gradient/Hessian.
939fn cause_specific_hessian_directional_derivative(
940    block: &CauseSpecificRoystonParmarBlock,
941    beta: &Array1<f64>,
942    d_beta: &Array1<f64>,
943) -> Result<Array2<f64>, SurvivalError> {
944    let p = block.x_exit.ncols();
945    if beta.len() != p || d_beta.len() != p {
946        return Err(SurvivalError::CauseSpecificDimensionMismatch {
947            reason: "cause-specific survival Hessian derivative dimension mismatch".to_string(),
948        });
949    }
950    let eta_entry = fast_av(&block.x_entry, beta) + &block.offset_eta_entry;
951    let eta_exit = fast_av(&block.x_exit, beta) + &block.offset_eta_exit;
952    let derivative = fast_av(&block.x_derivative, beta) + &block.offset_derivative_exit;
953    let d_eta_entry = fast_av(&block.x_entry, d_beta);
954    let d_eta_exit = fast_av(&block.x_exit, d_beta);
955    let d_derivative = fast_av(&block.x_derivative, d_beta);
956    let n = block.event_target.len();
957    let mut weights: [Array1<f64>; 6] = std::array::from_fn(|_| Array1::zeros(n));
958
959    for i in 0..n {
960        let Some(input) =
961            cause_specific_atom_input(block, i, eta_entry[i], eta_exit[i], derivative[i])?
962        else {
963            continue;
964        };
965        let direction = [
966            d_eta_exit[i],
967            d_eta_entry[i] * f64::from(input.entry_active),
968            d_derivative[i] * f64::from(input.event),
969        ];
970        let matrix = cause_specific_row_third_contracted(
971            input.primary[0],
972            input.primary[1],
973            input.primary[2],
974            input.weight,
975            input.entry_active,
976            input.event,
977            &direction,
978        );
979        for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
980            weights[slot][i] = matrix[left][right];
981        }
982    }
983    Ok(cause_specific_pullback_hessian(block, &weights))
984}
985
986/// The live fourth-order coefficient Hessian, lowered at build time from the
987/// same [`cause_specific_row`] expression as every lower channel.
988fn cause_specific_hessian_second_directional_derivative(
989    block: &CauseSpecificRoystonParmarBlock,
990    beta: &Array1<f64>,
991    d_beta_u: &Array1<f64>,
992    d_beta_v: &Array1<f64>,
993) -> Result<Array2<f64>, SurvivalError> {
994    let p = block.x_exit.ncols();
995    if beta.len() != p || d_beta_u.len() != p || d_beta_v.len() != p {
996        return Err(SurvivalError::CauseSpecificDimensionMismatch {
997            reason: "cause-specific survival second Hessian derivative dimension mismatch"
998                .to_string(),
999        });
1000    }
1001    let eta_entry = fast_av(&block.x_entry, beta) + &block.offset_eta_entry;
1002    let eta_exit = fast_av(&block.x_exit, beta) + &block.offset_eta_exit;
1003    let derivative = fast_av(&block.x_derivative, beta) + &block.offset_derivative_exit;
1004    let u_eta_entry = fast_av(&block.x_entry, d_beta_u);
1005    let u_eta_exit = fast_av(&block.x_exit, d_beta_u);
1006    let u_derivative = fast_av(&block.x_derivative, d_beta_u);
1007    let v_eta_entry = fast_av(&block.x_entry, d_beta_v);
1008    let v_eta_exit = fast_av(&block.x_exit, d_beta_v);
1009    let v_derivative = fast_av(&block.x_derivative, d_beta_v);
1010    let n = block.event_target.len();
1011    let mut weights: [Array1<f64>; 6] = std::array::from_fn(|_| Array1::zeros(n));
1012
1013    for i in 0..n {
1014        let Some(input) =
1015            cause_specific_atom_input(block, i, eta_entry[i], eta_exit[i], derivative[i])?
1016        else {
1017            continue;
1018        };
1019        let direction_u = [
1020            u_eta_exit[i],
1021            u_eta_entry[i] * f64::from(input.entry_active),
1022            u_derivative[i] * f64::from(input.event),
1023        ];
1024        let direction_v = [
1025            v_eta_exit[i],
1026            v_eta_entry[i] * f64::from(input.entry_active),
1027            v_derivative[i] * f64::from(input.event),
1028        ];
1029        let matrix = cause_specific_row_fourth_contracted(
1030            input.primary[0],
1031            input.primary[1],
1032            input.primary[2],
1033            input.weight,
1034            input.entry_active,
1035            input.event,
1036            &direction_u,
1037            &direction_v,
1038        );
1039        for (slot, &(left, right)) in CAUSE_SPECIFIC_PRIMARY_PAIRS.iter().enumerate() {
1040            weights[slot][i] = matrix[left][right];
1041        }
1042    }
1043    Ok(cause_specific_pullback_hessian(block, &weights))
1044}
1045
1046pub fn survival_event_code_from_value(value: f64, row_index: usize) -> Result<u8, String> {
1047    const INTEGER_TOL: f64 = 1e-8;
1048    const MAX_AUTO_CAUSES: u8 = 32;
1049    if !value.is_finite() {
1050        return Err(SurvivalError::EventCodeInvalid {
1051            reason: format!(
1052                "survival event value at row {} is non-finite",
1053                row_index + 1
1054            ),
1055        }
1056        .into());
1057    }
1058    if value < 0.0 {
1059        return Err(SurvivalError::EventCodeInvalid {
1060            reason: format!(
1061                "survival event value at row {} is negative: {value}",
1062                row_index + 1
1063            ),
1064        }
1065        .into());
1066    }
1067    let rounded = value.round();
1068    if (value - rounded).abs() > INTEGER_TOL {
1069        return Err(SurvivalError::EventCodeInvalid {
1070            reason: format!(
1071                "survival event value at row {} must be an integer code with 0=censored, got {value}",
1072                row_index + 1
1073            ),
1074        }
1075        .into());
1076    }
1077    if rounded > f64::from(MAX_AUTO_CAUSES) {
1078        return Err(SurvivalError::EventCodeInvalid {
1079            reason: format!(
1080                "survival event value at row {} has code {rounded}; automatic competing-risks detection supports codes 0..={MAX_AUTO_CAUSES}",
1081                row_index + 1
1082            ),
1083        }
1084        .into());
1085    }
1086    Ok(rounded as u8)
1087}
1088
1089pub fn cause_count_from_event_codes(
1090    event_codes: ArrayView1<'_, u8>,
1091) -> Result<usize, SurvivalError> {
1092    let max_code = event_codes.iter().copied().max().map_or(0, usize::from);
1093    if max_code == 0 {
1094        return Ok(1);
1095    }
1096
1097    let mut present = vec![false; max_code + 1];
1098    for code in event_codes.iter().copied() {
1099        present[usize::from(code)] = true;
1100    }
1101    if (1..=max_code).any(|code| !present[code]) {
1102        let actual = present
1103            .iter()
1104            .enumerate()
1105            .skip(1)
1106            .filter_map(|(code, &seen)| seen.then_some(code.to_string()))
1107            .collect::<Vec<_>>()
1108            .join(", ");
1109        return Err(SurvivalError::EventCodeInvalid {
1110            reason: format!(
1111                "survival competing-risks event codes must use contiguous positive codes; observed nonzero codes are {{{actual}}}. Remap event codes contiguously (for example, {{0,1,3}} -> {{0,1,2}}), otherwise a phantom cause is fit with no events and pollutes CIF assembly."
1112            ),
1113        });
1114    }
1115
1116    Ok(max_code)
1117}
1118
1119/// Project multi-cause competing-risks event codes `{0 = censored, k = cause k}`
1120/// onto the binary `{0, 1}` *any-event* indicator the pooled single-hazard
1121/// baseline engine consumes.
1122///
1123/// The shared Royston-Parmar baseline working model fits one hazard across all
1124/// causes; every observed event (regardless of cause) informs that baseline, so
1125/// the indicator is `1` exactly when *any* cause occurred. This is one of the
1126/// two cause-aware projections of the raw code vector; the other is
1127/// [`cause_specific_event_indicator`]. Centralizing both here keeps the single
1128/// source of truth for "how multi-cause labels become a single-hazard binary
1129/// contract", so no construction path open-codes a fragile `mapv` and then
1130/// trips the binary `event_target > 1` guard on the raw labels.
1131pub fn pooled_any_event_indicator(event_codes: ArrayView1<'_, u8>) -> Array1<u8> {
1132    event_codes.mapv(|label| u8::from(label > 0))
1133}
1134
1135/// Project multi-cause competing-risks event codes `{0 = censored, k = cause k}`
1136/// onto the binary `{0, 1}` indicator for the cause-specific Royston-Parmar
1137/// block of cause `cause` (1-based).
1138///
1139/// Within cause `cause`'s block the event of interest is `event == cause`; every
1140/// competing cause is treated as censoring (indicator `0`), which is exactly the
1141/// cause-specific hazard likelihood. Like [`pooled_any_event_indicator`], this
1142/// yields a binary contract that satisfies the single-hazard `event_target` guard
1143/// — the raw multi-cause labels are never handed to a binary engine.
1144pub fn cause_specific_event_indicator(event_codes: ArrayView1<'_, u8>, cause: usize) -> Array1<u8> {
1145    let cause_code = cause as u8;
1146    event_codes.mapv(|observed| u8::from(observed == cause_code))
1147}
1148
1149fn compress_positive_collinear_constraints(
1150    a: &Array2<f64>,
1151    b: &Array1<f64>,
1152) -> LinearInequalityConstraints {
1153    const SCALE_TOL: f64 = 1e-14;
1154    const KEY_TOL: f64 = 1e-8;
1155
1156    let mut grouped: BTreeMap<Vec<i64>, (Vec<f64>, f64)> = BTreeMap::new();
1157    let mut fallbackrows: Vec<(Vec<f64>, f64)> = Vec::new();
1158
1159    for i in 0..a.nrows() {
1160        let row = a.row(i);
1161        let scale = row.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1162        if !scale.is_finite() || scale <= SCALE_TOL {
1163            if b[i] > 0.0 {
1164                fallbackrows.push((row.to_vec(), b[i]));
1165            }
1166            continue;
1167        }
1168
1169        let normalizedrow: Vec<f64> = row
1170            .iter()
1171            .map(|&v| {
1172                let scaled = v / scale;
1173                if scaled.abs() <= KEY_TOL { 0.0 } else { scaled }
1174            })
1175            .collect();
1176        let normalized_rhs = b[i] / scale;
1177        let key: Vec<i64> = normalizedrow
1178            .iter()
1179            .map(|&v| (v / KEY_TOL).round() as i64)
1180            .collect();
1181
1182        match grouped.get_mut(&key) {
1183            Some((_, rhs_max)) => {
1184                if normalized_rhs > *rhs_max {
1185                    *rhs_max = normalized_rhs;
1186                }
1187            }
1188            None => {
1189                grouped.insert(key, (normalizedrow, normalized_rhs));
1190            }
1191        }
1192    }
1193
1194    let nrows = grouped.len() + fallbackrows.len();
1195    let n_cols = a.ncols();
1196    let mut a_out = Array2::<f64>::zeros((nrows, n_cols));
1197    let mut b_out = Array1::<f64>::zeros(nrows);
1198
1199    let mut outrow = 0usize;
1200    for (_, (row, rhs)) in grouped {
1201        for (j, value) in row.into_iter().enumerate() {
1202            a_out[[outrow, j]] = value;
1203        }
1204        b_out[outrow] = rhs;
1205        outrow += 1;
1206    }
1207    for (row, rhs) in fallbackrows {
1208        for (j, value) in row.into_iter().enumerate() {
1209            a_out[[outrow, j]] = value;
1210        }
1211        b_out[outrow] = rhs;
1212        outrow += 1;
1213    }
1214
1215    LinearInequalityConstraints { a: a_out, b: b_out }
1216}
1217
1218#[derive(Debug, Clone, Copy, Default)]
1219pub struct SurvivalMonotonicityPenalty {
1220    pub tolerance: f64,
1221}
1222
1223#[derive(Debug, Clone)]
1224enum SurvivalDesign {
1225    Flat {
1226        x_entry: Array2<f64>,
1227        x_exit: Array2<f64>,
1228        x_derivative: Array2<f64>,
1229    },
1230    TimeCovariateShared {
1231        time_entry: Array2<f64>,
1232        time_exit: Array2<f64>,
1233        time_derivative: Array2<f64>,
1234        covariates: Array2<f64>,
1235    },
1236}
1237
1238impl SurvivalDesign {
1239    fn p_total(&self) -> usize {
1240        match self {
1241            Self::Flat { x_exit, .. } => x_exit.ncols(),
1242            Self::TimeCovariateShared {
1243                time_exit,
1244                covariates,
1245                ..
1246            } => time_exit.ncols() + covariates.ncols(),
1247        }
1248    }
1249
1250    fn design_dot(&self, time_mat: &Array2<f64>, beta: &Array1<f64>) -> Array1<f64> {
1251        match self {
1252            Self::Flat { .. } => time_mat.dot(beta),
1253            Self::TimeCovariateShared { covariates, .. } => {
1254                let p_time = time_mat.ncols();
1255                let mut out = time_mat.dot(&beta.slice(ndarray::s![..p_time]));
1256                if covariates.ncols() > 0 {
1257                    out += &covariates.dot(&beta.slice(ndarray::s![p_time..]));
1258                }
1259                out
1260            }
1261        }
1262    }
1263
1264    fn fill_row(&self, time_mat: &Array2<f64>, i: usize, out: &mut [f64]) {
1265        match self {
1266            Self::Flat { .. } => {
1267                for (dst, &src) in out.iter_mut().zip(time_mat.row(i).iter()) {
1268                    *dst = src;
1269                }
1270            }
1271            Self::TimeCovariateShared { covariates, .. } => {
1272                let p_time = time_mat.ncols();
1273                for j in 0..p_time {
1274                    out[j] = time_mat[[i, j]];
1275                }
1276                for j in 0..covariates.ncols() {
1277                    out[p_time + j] = covariates[[i, j]];
1278                }
1279            }
1280        }
1281    }
1282}
1283
1284/// Pre-allocated workspace buffers for `update_state` to avoid per-iteration allocations.
1285#[derive(Debug, Clone)]
1286struct SurvivalWorkspace {
1287    w_event: Array1<f64>,
1288    w_event_inv_deriv: Array1<f64>,
1289    w_event_outer: Array1<f64>,
1290    w_hess_exit: Array1<f64>,
1291    w_hess_entry: Array1<f64>,
1292}
1293
1294impl SurvivalWorkspace {
1295    fn new(n: usize) -> Self {
1296        Self {
1297            w_event: Array1::zeros(n),
1298            w_event_inv_deriv: Array1::zeros(n),
1299            w_event_outer: Array1::zeros(n),
1300            w_hess_exit: Array1::zeros(n),
1301            w_hess_entry: Array1::zeros(n),
1302        }
1303    }
1304
1305    fn reset(&mut self, n: usize) {
1306        if self.w_event.len() != n {
1307            *self = Self::new(n);
1308        } else {
1309            self.w_event.fill(0.0);
1310            self.w_event_inv_deriv.fill(0.0);
1311            self.w_event_outer.fill(0.0);
1312            self.w_hess_exit.fill(0.0);
1313            self.w_hess_entry.fill(0.0);
1314        }
1315    }
1316}
1317
1318/// Per-observation gradients of the unpenalized survival NLL with respect
1319/// to each additive offset channel, at a given β. See
1320/// [`WorkingModelSurvival::offset_channel_residuals`] for the algebra.
1321///
1322/// Contract: all four arrays have length `n` = number of observations.
1323/// Rows with non-positive sampleweight are 0 in every channel. The
1324/// `derivative` channel is 0 in all non-event rows. The `right` channel is
1325/// the interval upper-bound (`R`) η-offset sensitivity and is exactly 0 for
1326/// every NON-interval-censored model and every non-interval row of the latent
1327/// interval model (only the dedicated `SurvInterval(L, R, event)` latent fit
1328/// populates it); the baseline-θ chain rule contracts it against the
1329/// `age_right`-evaluated η-partial.
1330#[derive(Clone, Debug)]
1331pub struct OffsetChannelResiduals {
1332    /// ∂NLL/∂o_X: w·(exp(η_exit) − δ) per row.
1333    pub exit: Array1<f64>,
1334    /// ∂NLL/∂o_E: −w·exp(η_entry) if row has a positive entry interval else 0.
1335    pub entry: Array1<f64>,
1336    /// ∂NLL/∂o_D: −w·δ / s (event-row only).
1337    pub derivative: Array1<f64>,
1338    /// ∂NLL/∂o_R: interval upper-bound (`R`) η-offset sensitivity,
1339    /// `−w·∂(log-lik)/∂q_right`. Nonzero only for interval-censored latent
1340    /// rows; exactly 0 for every other channel/model.
1341    pub right: Array1<f64>,
1342}
1343
1344/// Per-observation Hessians of the unpenalized survival NLL with respect
1345/// to additive offset channels in `(entry, exit, derivative)` order.
1346#[derive(Clone, Debug)]
1347pub struct OffsetChannelCurvatures {
1348    pub rows: Vec<[[f64; 3]; 3]>,
1349}
1350
1351#[derive(Debug)]
1352pub struct WorkingModelSurvival {
1353    age_entry: Array1<f64>,
1354    age_exit: Array1<f64>,
1355    entry_at_origin: Array1<bool>,
1356    event_target: Array1<u8>,
1357    sampleweight: Array1<f64>,
1358    design: SurvivalDesign,
1359    offset_eta_entry: Array1<f64>,
1360    offset_eta_exit: Array1<f64>,
1361    offset_derivative_exit: Array1<f64>,
1362    penalties: PenaltyBlocks,
1363    monotonicity: SurvivalMonotonicityPenalty,
1364    structurally_monotonic: bool,
1365    structural_time_columns: usize,
1366    monotonicity_constraint_rows: Option<Array2<f64>>,
1367    monotonicity_constraint_offsets: Option<Array1<f64>>,
1368    workspace: std::sync::Mutex<SurvivalWorkspace>,
1369}
1370
1371impl Clone for WorkingModelSurvival {
1372    fn clone(&self) -> Self {
1373        let workspace = self
1374            .workspace
1375            .lock()
1376            .expect("survival workspace mutex was not poisoned by a panicking holder")
1377            .clone();
1378        Self {
1379            age_entry: self.age_entry.clone(),
1380            age_exit: self.age_exit.clone(),
1381            entry_at_origin: self.entry_at_origin.clone(),
1382            event_target: self.event_target.clone(),
1383            sampleweight: self.sampleweight.clone(),
1384            design: self.design.clone(),
1385            offset_eta_entry: self.offset_eta_entry.clone(),
1386            offset_eta_exit: self.offset_eta_exit.clone(),
1387            offset_derivative_exit: self.offset_derivative_exit.clone(),
1388            penalties: self.penalties.clone(),
1389            monotonicity: self.monotonicity,
1390            structurally_monotonic: self.structurally_monotonic,
1391            structural_time_columns: self.structural_time_columns,
1392            monotonicity_constraint_rows: self.monotonicity_constraint_rows.clone(),
1393            monotonicity_constraint_offsets: self.monotonicity_constraint_offsets.clone(),
1394            workspace: std::sync::Mutex::new(workspace),
1395        }
1396    }
1397}
1398
1399impl WorkingModelSurvival {
1400    const LOG_F64_MAX: f64 = 709.782712893384;
1401
1402    #[inline]
1403    fn scaled_exp_component(log_scale: f64, base: f64) -> Result<f64, EstimationError> {
1404        if base == 0.0 {
1405            return Ok(0.0);
1406        }
1407        let log_abs = log_scale + base.abs().ln();
1408        if !log_abs.is_finite() {
1409            crate::bail_invalid_estim!("survival interval term produced non-finite log-magnitude");
1410        }
1411        if log_abs > Self::LOG_F64_MAX {
1412            crate::bail_invalid_estim!(
1413                "survival interval term exceeds f64 range (log-magnitude={log_abs:.3e})"
1414            );
1415        }
1416        Ok(base.signum() * log_abs.exp())
1417    }
1418
1419    fn coefficient_dim(&self) -> usize {
1420        self.design.p_total()
1421    }
1422
1423    fn nrows(&self) -> usize {
1424        self.sampleweight.len()
1425    }
1426
1427    fn entry_dot(&self, beta: &Array1<f64>) -> Array1<f64> {
1428        let time_mat = match &self.design {
1429            SurvivalDesign::Flat { x_entry, .. } => x_entry,
1430            SurvivalDesign::TimeCovariateShared { time_entry, .. } => time_entry,
1431        };
1432        self.design.design_dot(time_mat, beta)
1433    }
1434
1435    fn exit_dot(&self, beta: &Array1<f64>) -> Array1<f64> {
1436        let time_mat = match &self.design {
1437            SurvivalDesign::Flat { x_exit, .. } => x_exit,
1438            SurvivalDesign::TimeCovariateShared { time_exit, .. } => time_exit,
1439        };
1440        self.design.design_dot(time_mat, beta)
1441    }
1442
1443    fn derivative_dot(&self, beta: &Array1<f64>) -> Array1<f64> {
1444        match &self.design {
1445            SurvivalDesign::Flat { x_derivative, .. } => x_derivative.dot(beta),
1446            SurvivalDesign::TimeCovariateShared {
1447                time_derivative, ..
1448            } => time_derivative.dot(&beta.slice(ndarray::s![..time_derivative.ncols()])),
1449        }
1450    }
1451
1452    fn fill_entry_row(&self, i: usize, out: &mut [f64]) {
1453        let time_mat = match &self.design {
1454            SurvivalDesign::Flat { x_entry, .. } => x_entry,
1455            SurvivalDesign::TimeCovariateShared { time_entry, .. } => time_entry,
1456        };
1457        self.design.fill_row(time_mat, i, out);
1458    }
1459
1460    fn fill_exit_row(&self, i: usize, out: &mut [f64]) {
1461        let time_mat = match &self.design {
1462            SurvivalDesign::Flat { x_exit, .. } => x_exit,
1463            SurvivalDesign::TimeCovariateShared { time_exit, .. } => time_exit,
1464        };
1465        self.design.fill_row(time_mat, i, out);
1466    }
1467
1468    fn fill_derivative_row(&self, i: usize, out: &mut [f64]) {
1469        match &self.design {
1470            SurvivalDesign::Flat { x_derivative, .. } => {
1471                for (dst, &src) in out.iter_mut().zip(x_derivative.row(i).iter()) {
1472                    *dst = src;
1473                }
1474            }
1475            SurvivalDesign::TimeCovariateShared {
1476                time_derivative, ..
1477            } => {
1478                let p_time = time_derivative.ncols();
1479                for j in 0..p_time {
1480                    out[j] = time_derivative[[i, j]];
1481                }
1482                for dst in out.iter_mut().skip(p_time) {
1483                    *dst = 0.0;
1484                }
1485            }
1486        }
1487    }
1488
1489    fn derivative_xt_diag_x(&self, weights: &Array1<f64>) -> Array2<f64> {
1490        match &self.design {
1491            SurvivalDesign::Flat { x_derivative, .. } => fast_xt_diag_x(x_derivative, weights),
1492            SurvivalDesign::TimeCovariateShared {
1493                time_derivative,
1494                covariates,
1495                ..
1496            } => {
1497                let p_time = time_derivative.ncols();
1498                let p_cov = covariates.ncols();
1499                let mut out = Array2::<f64>::zeros((p_time + p_cov, p_time + p_cov));
1500                let time_block = fast_xt_diag_x(time_derivative, weights);
1501                out.slice_mut(ndarray::s![..p_time, ..p_time])
1502                    .assign(&time_block);
1503                out
1504            }
1505        }
1506    }
1507
1508    /// Compute the full p×p Hessian contribution for the interval terms:
1509    ///   H = X_exit^T diag(w_exit) X_exit - X_entry^T diag(w_entry) X_entry
1510    /// using faer-accelerated BLAS on the stored design matrix blocks.
1511    fn interval_hessian_blas(&self, w_exit: &Array1<f64>, w_entry: &Array1<f64>) -> Array2<f64> {
1512        match &self.design {
1513            SurvivalDesign::Flat {
1514                x_entry, x_exit, ..
1515            } => {
1516                let mut h = fast_xt_diag_x(x_exit, w_exit);
1517                h -= &fast_xt_diag_x(x_entry, w_entry);
1518                h
1519            }
1520            SurvivalDesign::TimeCovariateShared {
1521                time_entry,
1522                time_exit,
1523                covariates,
1524                ..
1525            } => {
1526                let p_time = time_exit.ncols();
1527                let p_cov = covariates.ncols();
1528                let p = p_time + p_cov;
1529                let mut h = Array2::<f64>::zeros((p, p));
1530                // time-time block: T_exit^T W_exit T_exit - T_entry^T W_entry T_entry
1531                let tt = {
1532                    let mut block = fast_xt_diag_x(time_exit, w_exit);
1533                    block -= &fast_xt_diag_x(time_entry, w_entry);
1534                    block
1535                };
1536                h.slice_mut(ndarray::s![..p_time, ..p_time]).assign(&tt);
1537                if p_cov > 0 {
1538                    // time-cov block: T_exit^T W_exit C - T_entry^T W_entry C
1539                    let tc = {
1540                        let mut block = fast_xt_diag_y(time_exit, w_exit, covariates);
1541                        block -= &fast_xt_diag_y(time_entry, w_entry, covariates);
1542                        block
1543                    };
1544                    h.slice_mut(ndarray::s![..p_time, p_time..]).assign(&tc);
1545                    h.slice_mut(ndarray::s![p_time.., ..p_time]).assign(&tc.t());
1546                    // cov-cov block: C^T (W_exit - W_entry) C
1547                    let w_diff = w_exit - w_entry;
1548                    let cc = fast_xt_diag_x(covariates, &w_diff);
1549                    h.slice_mut(ndarray::s![p_time.., p_time..]).assign(&cc);
1550                }
1551                h
1552            }
1553        }
1554    }
1555
1556    /// Clamped structural derivative: `max(deriv, floor)` for derivatives
1557    /// above the roundoff tolerance, `None` outside structural monotonicity
1558    /// or for genuinely negative derivatives.
1559    ///
1560    /// Returns `(value, slope)` where `slope` is the exact derivative of the
1561    /// clamp itself: 1 on the identity branch, 0 on the floored branch.
1562    /// Every consumer that differentiates through the structural derivative
1563    /// MUST scale its derivative-channel terms by `slope` — the floored
1564    /// branch is locally constant in β, so gradients and Hessians of
1565    /// `ln(value)` and `1/value` are exactly zero there. Emitting the
1566    /// reciprocal-scale terms of the UNclamped expression (≈1e12 gradient,
1567    /// ≈1e24 curvature at deriv = 5e-13) against a value branch that is flat
1568    /// desynchronizes the objective from its derivatives.
1569    fn stabilized_structural_derivative(&self, deriv: f64) -> Option<(f64, f64)> {
1570        const STRUCTURAL_MONO_ROUNDOFF_TOL: f64 = 1e-7;
1571        const STRUCTURAL_DERIV_FLOOR: f64 = 1e-12;
1572        if !self.structurally_monotonic {
1573            return None;
1574        }
1575        if deriv >= STRUCTURAL_DERIV_FLOOR {
1576            return Some((deriv, 1.0));
1577        }
1578        if deriv >= -STRUCTURAL_MONO_ROUNDOFF_TOL {
1579            return Some((STRUCTURAL_DERIV_FLOOR, 0.0));
1580        }
1581        None
1582    }
1583
1584    fn validate_penalties(
1585        penalties: &PenaltyBlocks,
1586        coefficient_dim: usize,
1587    ) -> Result<(), SurvivalError> {
1588        for block in &penalties.blocks {
1589            if !block.lambda.is_finite() || block.lambda < 0.0 {
1590                return Err(SurvivalError::NonFiniteInput);
1591            }
1592            if block.range.start > block.range.end || block.range.end > coefficient_dim {
1593                return Err(SurvivalError::DimensionMismatch);
1594            }
1595            let block_dim = block.range.end - block.range.start;
1596            if block.matrix.nrows() != block_dim || block.matrix.ncols() != block_dim {
1597                return Err(SurvivalError::DimensionMismatch);
1598            }
1599            if block.matrix.iter().any(|v| !v.is_finite()) {
1600                return Err(SurvivalError::NonFiniteInput);
1601            }
1602        }
1603        Ok(())
1604    }
1605
1606    fn derivative_guard(&self) -> f64 {
1607        if self.structurally_monotonic {
1608            // I-spline basis is monotone by construction when coefficients ≥ 0.
1609            // A derivative of zero (flat hazard) is valid, so the guard only
1610            // rejects genuinely negative derivatives from floating-point noise.
1611            return 0.0;
1612        }
1613        self.monotonicity.tolerance.max(0.0)
1614    }
1615
1616    fn derivative_guard_numerical(&self) -> f64 {
1617        let derivative_guard = self.derivative_guard();
1618        if derivative_guard <= 0.0 {
1619            // For structural monotonicity (guard = 0), tiny negative derivs are
1620            // tolerated because `stabilized_structural_derivative` lifts the
1621            // value back to a small positive floor before any `ln`/`1/deriv`
1622            // use. For *non-structural* monotonicity with tolerance == 0 the
1623            // raw derivative flows straight through into the event-row
1624            // `deriv.ln()` and `1.0 / deriv`, so any non-positive value would
1625            // produce NaN / huge negative weights. Keep the slack only when
1626            // the structural stabilizer is active.
1627            if self.structurally_monotonic {
1628                -1e-10
1629            } else {
1630                1e-12
1631            }
1632        } else {
1633            (derivative_guard - (1e-10_f64).min(0.01 * derivative_guard)).max(1e-12)
1634        }
1635    }
1636
1637    fn interval_increment_guard(&self, h_entry: f64, h_exit: f64) -> f64 {
1638        let scale = h_entry.abs().max(h_exit.abs()).max(1.0);
1639        1e-10 * scale
1640    }
1641
1642    fn structural_time_coefficient_constraints(&self) -> Option<LinearInequalityConstraints> {
1643        if !self.structurally_monotonic {
1644            return None;
1645        }
1646        let p = self.coefficient_dim();
1647        let time_columns = self.structural_time_columns.min(p);
1648        if time_columns == 0 {
1649            return None;
1650        }
1651        // Constrain EVERY time-block coefficient `γ_j ≥ 0`, not only the columns
1652        // whose derivative basis is active at a training row. Each I-spline time
1653        // column is monotone non-decreasing across the whole log-time axis (its
1654        // derivative basis is a non-negative M-spline, verified element-wise in
1655        // `set_structural_monotonicity`), so the cumulative hazard is monotone at
1656        // EVERY evaluation time — including prediction horizons beyond the training
1657        // exit times — iff every time coefficient is non-negative. This whole-block
1658        // constraint is therefore the exact domain-wide structural-monotonicity
1659        // certificate.
1660        //
1661        // An earlier version restricted the constraint to columns with
1662        // `derivative_row[j] > tol` at some training row. Tail I-spline columns —
1663        // whose M-spline support sits beyond the largest training exit time — are
1664        // ≈0 at every training row and were left UNCONSTRAINED. The penalized fit
1665        // could then drive those `γ_j < 0`: monotonicity still held at every
1666        // training row (their basis is ≈0 there) but broke at prediction times in
1667        // the tail column's support, yielding a negative log-cumulative-hazard
1668        // derivative `d(logΛ)/dt` that the Royston-Parmar predictor correctly
1669        // refuses. Constraining the full block closes that tail blind spot at the
1670        // fit rather than clamping the derivative at predict time.
1671        let mut a = Array2::<f64>::zeros((time_columns, p));
1672        let b = Array1::<f64>::zeros(time_columns);
1673        for j in 0..time_columns {
1674            a[[j, j]] = 1.0;
1675        }
1676        Some(LinearInequalityConstraints { a, b })
1677    }
1678
1679    pub fn monotonicity_linear_constraints(&self) -> Option<LinearInequalityConstraints> {
1680        let p = self.coefficient_dim();
1681        const DERIVATIVE_ROW_NORM_TOL: f64 = 1e-12;
1682        if p == 0 {
1683            return None;
1684        }
1685        if self.structurally_monotonic {
1686            return self.structural_time_coefficient_constraints();
1687        }
1688        if let (Some(rows), Some(offsets)) = (
1689            self.monotonicity_constraint_rows.as_ref(),
1690            self.monotonicity_constraint_offsets.as_ref(),
1691        ) {
1692            let activerows: Vec<usize> = (0..rows.nrows())
1693                .filter(|&i| {
1694                    rows.row(i).iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
1695                        > DERIVATIVE_ROW_NORM_TOL
1696                })
1697                .collect();
1698            if activerows.is_empty() {
1699                return None;
1700            }
1701            let mut a = Array2::<f64>::zeros((activerows.len(), p));
1702            let mut b = Array1::<f64>::zeros(activerows.len());
1703            for (r, &i) in activerows.iter().enumerate() {
1704                a.row_mut(r).assign(&rows.row(i));
1705                b[r] = self.derivative_guard() - offsets[i];
1706            }
1707            return Some(compress_positive_collinear_constraints(&a, &b));
1708        }
1709        None
1710    }
1711
1712    pub fn from_engine_inputs(
1713        inputs: SurvivalEngineInputs<'_>,
1714        penalties: PenaltyBlocks,
1715        monotonicity: SurvivalMonotonicityPenalty,
1716        spec: SurvivalSpec,
1717    ) -> Result<Self, SurvivalError> {
1718        Self::from_engine_inputswith_offsets(inputs, None, penalties, monotonicity, spec)
1719    }
1720
1721    fn validate_offsets(
1722        offsets: Option<SurvivalBaselineOffsets<'_>>,
1723        n: usize,
1724    ) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), SurvivalError> {
1725        if let Some(off) = offsets {
1726            if off.eta_entry.len() != n || off.eta_exit.len() != n || off.derivative_exit.len() != n
1727            {
1728                return Err(SurvivalError::DimensionMismatch);
1729            }
1730            if off.eta_entry.iter().any(|v| !v.is_finite())
1731                || off.eta_exit.iter().any(|v| !v.is_finite())
1732                || off.derivative_exit.iter().any(|v| !v.is_finite())
1733            {
1734                return Err(SurvivalError::NonFiniteInput);
1735            }
1736            Ok((
1737                off.eta_entry.to_owned(),
1738                off.eta_exit.to_owned(),
1739                off.derivative_exit.to_owned(),
1740            ))
1741        } else {
1742            Ok((Array1::zeros(n), Array1::zeros(n), Array1::zeros(n)))
1743        }
1744    }
1745
1746    fn validate_common_inputs(
1747        age_entry: &ArrayView1<f64>,
1748        age_exit: &ArrayView1<f64>,
1749        event_target: &ArrayView1<u8>,
1750        event_competing: &ArrayView1<u8>,
1751        sampleweight: &ArrayView1<f64>,
1752    ) -> Result<(), SurvivalError> {
1753        if age_entry.iter().any(|v| !v.is_finite())
1754            || age_exit.iter().any(|v| !v.is_finite())
1755            || sampleweight.iter().any(|v| !v.is_finite() || *v < 0.0)
1756        {
1757            return Err(SurvivalError::NonFiniteInput);
1758        }
1759        // The single-hazard engine's `event_target` contract is binary {0, 1}.
1760        // A code > 1 is a *valid finite multi-cause label* that simply must be
1761        // projected first (any-event for the pooled baseline, cause-specific for
1762        // each block); it is NOT a non-finite input. Report it as such so the
1763        // failure is actionable and never surfaces as the misleading "inputs
1764        // contain non-finite values".
1765        if let Some(&label) = event_target.iter().find(|&&v| v > 1) {
1766            return Err(SurvivalError::EventCodeInvalid {
1767                reason: format!(
1768                    "single-hazard survival engine requires a binary {{0, 1}} event_target, got multi-cause label {label}; competing-risks codes must be projected via pooled_any_event_indicator / cause_specific_event_indicator before construction"
1769                ),
1770            });
1771        }
1772        if let Some(&label) = event_competing.iter().find(|&&v| v > 1) {
1773            return Err(SurvivalError::EventCodeInvalid {
1774                reason: format!(
1775                    "single-hazard survival engine requires a binary {{0, 1}} event_competing, got multi-cause label {label}"
1776                ),
1777            });
1778        }
1779        if event_target
1780            .iter()
1781            .zip(event_competing.iter())
1782            .any(|(&target, &competing)| target > 0 && competing > 0)
1783        {
1784            return Err(SurvivalError::EventCodeInvalid {
1785                reason: "a row cannot be simultaneously a target event and a competing event"
1786                    .to_string(),
1787            });
1788        }
1789        // The "must have at least one target event" requirement is a
1790        // *fittability* check, not a structural one: with all rows censored the
1791        // likelihood has no event score, so any subsequent fit cannot identify
1792        // the hazard and the optimizer spins on a flat landscape.  But the
1793        // structural integrity of the engine — its derivative-guard rejection
1794        // of decreasing cumulative hazards, its monotonicity-collocation
1795        // bookkeeping, its update_state numerics — is well-defined on
1796        // all-censored inputs, and unit tests legitimately exercise those
1797        // structural paths on censored fixtures.  Move the fittability check
1798        // out of construction; production fit dispatchers (e.g.
1799        // `solver::fit_orchestration::materialize_survival`) enforce it on the
1800        // single chokepoint that actually starts an optimization, where
1801        // the failure mode it guards against is reachable.
1802        if age_entry
1803            .iter()
1804            .zip(age_exit.iter())
1805            .any(|(&entry, &exit)| entry < 0.0 || exit <= 0.0)
1806        {
1807            return Err(SurvivalError::NonFiniteInput);
1808        }
1809        Ok::<(), _>(())
1810    }
1811
1812    fn validate_monotonicity_constraints(
1813        rows: Option<ArrayView2<'_, f64>>,
1814        offsets: Option<ArrayView1<'_, f64>>,
1815        coefficient_dim: usize,
1816    ) -> Result<(Option<Array2<f64>>, Option<Array1<f64>>), SurvivalError> {
1817        match (rows, offsets) {
1818            (None, None) => Ok((None, None)),
1819            (Some(rows), Some(offsets)) => {
1820                if rows.ncols() != coefficient_dim
1821                    || rows.nrows() != offsets.len()
1822                    || rows.iter().any(|v| !v.is_finite())
1823                    || offsets.iter().any(|v| !v.is_finite())
1824                {
1825                    return Err(SurvivalError::DimensionMismatch);
1826                }
1827                Ok((Some(rows.to_owned()), Some(offsets.to_owned())))
1828            }
1829            _ => Err(SurvivalError::DimensionMismatch),
1830        }
1831    }
1832
1833    fn finish_construction(
1834        age_entry: ArrayView1<f64>,
1835        age_exit: ArrayView1<f64>,
1836        event_target: ArrayView1<u8>,
1837        sampleweight: ArrayView1<f64>,
1838        design: SurvivalDesign,
1839        offset_eta_entry: Array1<f64>,
1840        offset_eta_exit: Array1<f64>,
1841        offset_derivative_exit: Array1<f64>,
1842        penalties: PenaltyBlocks,
1843        monotonicity: SurvivalMonotonicityPenalty,
1844        monotonicity_constraint_rows: Option<Array2<f64>>,
1845        monotonicity_constraint_offsets: Option<Array1<f64>>,
1846    ) -> Self {
1847        let n = age_entry.len();
1848        Self {
1849            age_entry: age_entry.to_owned(),
1850            age_exit: age_exit.to_owned(),
1851            entry_at_origin: age_entry.mapv(|t| t <= ENTRY_AT_ORIGIN_THRESHOLD),
1852            event_target: event_target.to_owned(),
1853            sampleweight: sampleweight.to_owned(),
1854            design,
1855            offset_eta_entry,
1856            offset_eta_exit,
1857            offset_derivative_exit,
1858            penalties,
1859            monotonicity,
1860            structurally_monotonic: false,
1861            structural_time_columns: 0,
1862            monotonicity_constraint_rows,
1863            monotonicity_constraint_offsets,
1864            workspace: std::sync::Mutex::new(SurvivalWorkspace::new(n)),
1865        }
1866    }
1867
1868    pub fn from_engine_inputswith_offsets(
1869        inputs: SurvivalEngineInputs<'_>,
1870        offsets: Option<SurvivalBaselineOffsets<'_>>,
1871        penalties: PenaltyBlocks,
1872        monotonicity: SurvivalMonotonicityPenalty,
1873        spec: SurvivalSpec,
1874    ) -> Result<Self, SurvivalError> {
1875        if spec == SurvivalSpec::Crude {
1876            return Err(SurvivalError::UnsupportedSpec("crude"));
1877        }
1878        let n = inputs.age_entry.len();
1879        let p = inputs.x_entry.ncols();
1880        if inputs.age_exit.len() != n
1881            || inputs.event_target.len() != n
1882            || inputs.event_competing.len() != n
1883            || inputs.sampleweight.len() != n
1884            || inputs.x_entry.nrows() != n
1885            || inputs.x_exit.nrows() != n
1886            || inputs.x_derivative.nrows() != n
1887            || inputs.x_entry.ncols() != inputs.x_exit.ncols()
1888            || inputs.x_entry.ncols() != inputs.x_derivative.ncols()
1889        {
1890            return Err(SurvivalError::DimensionMismatch);
1891        }
1892        Self::validate_penalties(&penalties, p)?;
1893        Self::validate_common_inputs(
1894            &inputs.age_entry,
1895            &inputs.age_exit,
1896            &inputs.event_target,
1897            &inputs.event_competing,
1898            &inputs.sampleweight,
1899        )?;
1900        if inputs.x_entry.iter().any(|v| !v.is_finite())
1901            || inputs.x_exit.iter().any(|v| !v.is_finite())
1902            || inputs.x_derivative.iter().any(|v| !v.is_finite())
1903        {
1904            return Err(SurvivalError::NonFiniteInput);
1905        }
1906        let (offset_eta_entry, offset_eta_exit, offset_derivative_exit) =
1907            Self::validate_offsets(offsets, n)?;
1908        let (monotonicity_constraint_rows, monotonicity_constraint_offsets) =
1909            Self::validate_monotonicity_constraints(
1910                inputs.monotonicity_constraint_rows,
1911                inputs.monotonicity_constraint_offsets,
1912                p,
1913            )?;
1914
1915        Ok(Self::finish_construction(
1916            inputs.age_entry,
1917            inputs.age_exit,
1918            inputs.event_target,
1919            inputs.sampleweight,
1920            SurvivalDesign::Flat {
1921                x_entry: inputs.x_entry.to_owned(),
1922                x_exit: inputs.x_exit.to_owned(),
1923                x_derivative: inputs.x_derivative.to_owned(),
1924            },
1925            offset_eta_entry,
1926            offset_eta_exit,
1927            offset_derivative_exit,
1928            penalties,
1929            monotonicity,
1930            monotonicity_constraint_rows,
1931            monotonicity_constraint_offsets,
1932        ))
1933    }
1934
1935    pub fn from_time_covariate_inputswith_offsets(
1936        inputs: SurvivalTimeCovarInputs<'_>,
1937        offsets: Option<SurvivalBaselineOffsets<'_>>,
1938        penalties: PenaltyBlocks,
1939        monotonicity: SurvivalMonotonicityPenalty,
1940        spec: SurvivalSpec,
1941    ) -> Result<Self, SurvivalError> {
1942        if spec == SurvivalSpec::Crude {
1943            return Err(SurvivalError::UnsupportedSpec("crude"));
1944        }
1945        let n = inputs.age_entry.len();
1946        let p_time = inputs.time_entry.ncols();
1947        let p_cov = inputs.covariates.ncols();
1948        let p = p_time + p_cov;
1949        if inputs.age_exit.len() != n
1950            || inputs.event_target.len() != n
1951            || inputs.event_competing.len() != n
1952            || inputs.sampleweight.len() != n
1953            || inputs.time_entry.nrows() != n
1954            || inputs.time_exit.nrows() != n
1955            || inputs.time_derivative.nrows() != n
1956            || inputs.covariates.nrows() != n
1957            || inputs.time_entry.ncols() != inputs.time_exit.ncols()
1958            || inputs.time_entry.ncols() != inputs.time_derivative.ncols()
1959        {
1960            return Err(SurvivalError::DimensionMismatch);
1961        }
1962        Self::validate_penalties(&penalties, p)?;
1963        Self::validate_common_inputs(
1964            &inputs.age_entry,
1965            &inputs.age_exit,
1966            &inputs.event_target,
1967            &inputs.event_competing,
1968            &inputs.sampleweight,
1969        )?;
1970        if inputs.time_entry.iter().any(|v| !v.is_finite())
1971            || inputs.time_exit.iter().any(|v| !v.is_finite())
1972            || inputs.time_derivative.iter().any(|v| !v.is_finite())
1973            || inputs.covariates.iter().any(|v| !v.is_finite())
1974        {
1975            return Err(SurvivalError::NonFiniteInput);
1976        }
1977        let (offset_eta_entry, offset_eta_exit, offset_derivative_exit) =
1978            Self::validate_offsets(offsets, n)?;
1979        let (monotonicity_constraint_rows, monotonicity_constraint_offsets) =
1980            Self::validate_monotonicity_constraints(
1981                inputs.monotonicity_constraint_rows,
1982                inputs.monotonicity_constraint_offsets,
1983                p,
1984            )?;
1985
1986        Ok(Self::finish_construction(
1987            inputs.age_entry,
1988            inputs.age_exit,
1989            inputs.event_target,
1990            inputs.sampleweight,
1991            SurvivalDesign::TimeCovariateShared {
1992                time_entry: inputs.time_entry.to_owned(),
1993                time_exit: inputs.time_exit.to_owned(),
1994                time_derivative: inputs.time_derivative.to_owned(),
1995                covariates: inputs.covariates.to_owned(),
1996            },
1997            offset_eta_entry,
1998            offset_eta_exit,
1999            offset_derivative_exit,
2000            penalties,
2001            monotonicity,
2002            monotonicity_constraint_rows,
2003            monotonicity_constraint_offsets,
2004        ))
2005    }
2006
2007    /// Enable/disable monotonic time-block enforcement metadata.
2008    ///
2009    /// Monotonicity is enforced through linear inequality constraints on the
2010    /// derivative design; enabling this records how many leading time columns
2011    /// belong to that constrained block.
2012    /// Overwrite the per-block smoothing parameters `λ_k` in place.
2013    ///
2014    /// Used by the REML smoothing-parameter selection for transformation
2015    /// survival fits (issue #563): the outer optimizer proposes a `ρ = log λ`
2016    /// vector, sets the smoothing blocks' `λ_k` here, and re-runs the inner
2017    /// constrained PIRLS, so the monotone I-spline baseline can adapt its
2018    /// wiggliness instead of being pinned at a fixed seed. `lambdas` must have
2019    /// one entry per penalty block.
2020    pub fn set_penalty_lambdas(&mut self, lambdas: &[f64]) -> Result<(), EstimationError> {
2021        if lambdas.len() != self.penalties.blocks.len() {
2022            crate::bail_invalid_estim!(
2023                "set_penalty_lambdas expects {} lambdas, got {}",
2024                self.penalties.blocks.len(),
2025                lambdas.len()
2026            );
2027        }
2028        for (block, &lambda) in self.penalties.blocks.iter_mut().zip(lambdas.iter()) {
2029            if !lambda.is_finite() || lambda < 0.0 {
2030                crate::bail_invalid_estim!("penalty lambda must be finite and >= 0, got {lambda}");
2031            }
2032            block.lambda = lambda;
2033        }
2034        Ok(())
2035    }
2036
2037    pub fn set_structural_monotonicity(
2038        &mut self,
2039        enabled: bool,
2040        time_columns: usize,
2041    ) -> Result<(), EstimationError> {
2042        let p = self.coefficient_dim();
2043        if time_columns > p {
2044            crate::bail_invalid_estim!(
2045                "structural time columns {} exceed coefficient dimension {}",
2046                time_columns,
2047                p
2048            );
2049        }
2050        if enabled && time_columns == 0 {
2051            crate::bail_invalid_estim!("structural monotonicity requires at least one time column");
2052        }
2053        if enabled {
2054            const STRUCTURAL_DERIV_TOL: f64 = 1e-12;
2055            for (i, &offset) in self.offset_derivative_exit.iter().enumerate() {
2056                if offset < -STRUCTURAL_DERIV_TOL {
2057                    crate::bail_invalid_estim!(
2058                        "structural monotonicity requires nonnegative derivative offsets; found offset_derivative_exit[{i}]={offset:.3e}"
2059                    );
2060                }
2061            }
2062            let mut derivative_row = vec![0.0_f64; p];
2063            for i in 0..self.nrows() {
2064                self.fill_derivative_row(i, &mut derivative_row);
2065                for j in 0..time_columns {
2066                    let v = derivative_row[j];
2067                    if v < -STRUCTURAL_DERIV_TOL {
2068                        crate::bail_invalid_estim!(
2069                            "structural monotonicity requires nonnegative time-derivative basis entries; found x_derivative[{i},{j}]={v:.3e}"
2070                        );
2071                    }
2072                }
2073                for j in time_columns..p {
2074                    let v = derivative_row[j];
2075                    if v.abs() > STRUCTURAL_DERIV_TOL {
2076                        crate::bail_invalid_estim!(
2077                            "structural monotonicity requires zero derivative contribution outside the time block; found x_derivative[{i},{j}]={v:.3e}"
2078                        );
2079                    }
2080                }
2081            }
2082            if let (Some(rows), Some(offsets)) = (
2083                self.monotonicity_constraint_rows.as_ref(),
2084                self.monotonicity_constraint_offsets.as_ref(),
2085            ) {
2086                for (i, &offset) in offsets.iter().enumerate() {
2087                    if offset < -STRUCTURAL_DERIV_TOL {
2088                        crate::bail_invalid_estim!(
2089                            "structural monotonicity requires nonnegative collocation derivative offsets; found monotonicity_constraint_offsets[{i}]={offset:.3e}"
2090                        );
2091                    }
2092                }
2093                for i in 0..rows.nrows() {
2094                    for j in 0..time_columns {
2095                        let v = rows[[i, j]];
2096                        if v < -STRUCTURAL_DERIV_TOL {
2097                            crate::bail_invalid_estim!(
2098                                "structural monotonicity requires nonnegative collocation derivative basis entries; found monotonicity_constraint_rows[{i},{j}]={v:.3e}"
2099                            );
2100                        }
2101                    }
2102                    for j in time_columns..p {
2103                        let v = rows[[i, j]];
2104                        if v.abs() > STRUCTURAL_DERIV_TOL {
2105                            crate::bail_invalid_estim!(
2106                                "structural monotonicity requires zero collocation derivative contribution outside the time block; found monotonicity_constraint_rows[{i},{j}]={v:.3e}"
2107                            );
2108                        }
2109                    }
2110                }
2111            }
2112        }
2113        self.structurally_monotonic = enabled;
2114        self.structural_time_columns = if enabled { time_columns } else { 0 };
2115        Ok(())
2116    }
2117
2118    pub fn update_state(&self, beta: &Array1<f64>) -> Result<WorkingState, EstimationError> {
2119        if beta.len() != self.coefficient_dim() {
2120            crate::bail_invalid_estim!("survival beta dimension mismatch");
2121        }
2122
2123        let n = self.nrows();
2124        let p = self.coefficient_dim();
2125
2126        // Royston-Parmar contract used throughout the engine:
2127        //   eta(t) = log(H(t)), where H(t) is cumulative hazard.
2128        //
2129        // With row-vectors (per subject i):
2130        //   a1_i^T := x_exit_i^T,  a0_i^T := x_entry_i^T,  d_i^T := x_derivative_i^T
2131        // and scalars:
2132        //   eta1_i = a1_i^T beta,  eta0_i = a0_i^T beta,  s_i = d_i^T beta.
2133        //
2134        // The per-subject negative log-likelihood used below is
2135        //   NLL_i(beta) = exp(eta1_i) - exp(eta0_i) - delta_i * (eta1_i + log(s_i)),
2136        // with delta_i = event_target_i.
2137        //
2138        // This is exactly the form whose derivatives are:
2139        //   grad_i = exp(eta1_i) a1_i - exp(eta0_i) a0_i - delta_i * (a1_i + d_i / s_i)
2140        //   Hess_i = exp(eta1_i) a1_i a1_i^T - exp(eta0_i) a0_i a0_i^T
2141        //            + delta_i * (d_i d_i^T) / s_i^2.
2142        //
2143        // Monotonicity is enforced through linear inequality constraints on the
2144        // derivative design. This keeps the baseline smoothing penalty on the
2145        // actual spline coefficients and preserves zero-deviation as beta=0.
2146        //
2147        // The loop below computes exact beta-space derivatives and then adds penalties.
2148        // Total predictor = target offset + learned deviation.
2149        // This is the same architecture used for flexible binary links:
2150        // principled default, plus penalized wiggle/deviation.
2151        let eta_entry = self.entry_dot(beta) + &self.offset_eta_entry;
2152        let eta_exit = self.exit_dot(beta) + &self.offset_eta_exit;
2153        let derivative_raw = self.derivative_dot(beta) + &self.offset_derivative_exit;
2154
2155        let mut nll = 0.0;
2156        let derivative_guard = self.derivative_guard();
2157        let derivative_guard_numerical = self.derivative_guard_numerical();
2158        let mut workspace = self
2159            .workspace
2160            .lock()
2161            .expect("survival workspace mutex was not poisoned by a panicking holder");
2162        workspace.reset(n);
2163        let SurvivalWorkspace {
2164            w_event,
2165            w_event_inv_deriv,
2166            w_event_outer,
2167            w_hess_exit,
2168            w_hess_entry,
2169        } = &mut *workspace;
2170
2171        // Phase 1: Scalar loop — compute per-observation weights, NLL, validation.
2172        for i in 0..n {
2173            let w = self.sampleweight[i];
2174            if w <= 0.0 {
2175                continue;
2176            }
2177            let entry_age = self.age_entry[i];
2178            let exit_age = self.age_exit[i];
2179            if !entry_age.is_finite() || !exit_age.is_finite() || exit_age < entry_age {
2180                crate::bail_invalid_estim!(
2181                    "survival ages must be finite with age_exit >= age_entry"
2182                );
2183            }
2184            let d = f64::from(self.event_target[i]);
2185
2186            let has_entry_interval = !self.entry_at_origin[i];
2187            let interval_scale = if has_entry_interval {
2188                eta_exit[i].max(eta_entry[i])
2189            } else {
2190                eta_exit[i]
2191            };
2192            let h_e_scaled = (eta_exit[i] - interval_scale).exp();
2193            let h_s_scaled = if has_entry_interval {
2194                (eta_entry[i] - interval_scale).exp()
2195            } else {
2196                0.0
2197            };
2198            let interval_scaled = h_e_scaled - h_s_scaled;
2199            let interval = Self::scaled_exp_component(interval_scale, interval_scaled)?;
2200            let (deriv, deriv_slope) = self
2201                .stabilized_structural_derivative(derivative_raw[i])
2202                .unwrap_or((derivative_raw[i], 1.0));
2203            // Monotonicity of η(t) = log H(t) is a structural property of the
2204            // whole Royston-Parmar spline. If d_eta/dt is *strictly negative*
2205            // at any observed exit time, the cumulative hazard H(t) decreases
2206            // there and S(t) is not a valid survival function — both event
2207            // and censored rows have to refuse that case. Event rows further
2208            // need deriv strictly above the numerical guard because their
2209            // NLL contains `deriv.ln()` and `1.0 / deriv`; censored rows do
2210            // not, so a boundary value of exactly zero is feasible there.
2211            let mono_floor = if d > 0.0 {
2212                derivative_guard_numerical
2213            } else {
2214                0.0
2215            };
2216            if !deriv.is_finite() || deriv < mono_floor {
2217                return Err(EstimationError::ParameterConstraintViolation(format!(
2218                    "survival monotonicity violated at row {}: d_eta/dt={:.3e} <= tolerance={:.3e}",
2219                    i, deriv, derivative_guard
2220                )));
2221            }
2222            if has_entry_interval {
2223                let increment_guard = self.interval_increment_guard(h_s_scaled, h_e_scaled);
2224                if interval_scaled + increment_guard < 0.0 {
2225                    return Err(EstimationError::ParameterConstraintViolation(format!(
2226                        "survival cumulative hazard decreased over row {}: H(exit)-H(entry)={:.6e}",
2227                        i, interval
2228                    )));
2229                }
2230            }
2231            nll += w * interval;
2232
2233            // Per-observation weights for BLAS phase.
2234            // scaled_exp_component(interval_scale, h_e_scaled * x[r]) = exp(interval_scale) * h_e_scaled * x[r]
2235            // so the Hessian weight is w * exp(interval_scale) * h_e_scaled = w * exp(eta_exit).
2236            let w_exit_i = w * eta_exit[i].exp();
2237            let w_entry_i = if has_entry_interval {
2238                w * eta_entry[i].exp()
2239            } else {
2240                0.0
2241            };
2242            if !w_exit_i.is_finite() {
2243                crate::bail_invalid_estim!(
2244                    "survival interval term exceeds f64 range at row {i} (w*exp(eta_exit)={w_exit_i:.3e})"
2245                );
2246            }
2247            w_hess_exit[i] = w_exit_i;
2248            w_hess_entry[i] = w_entry_i;
2249
2250            if d > 0.0 {
2251                // `deriv_slope` is the derivative of the structural clamp: on
2252                // the floored branch the NLL's `ln(deriv)` term is locally
2253                // constant in β, so its score and curvature channels vanish.
2254                let inv_deriv = deriv_slope / deriv;
2255                nll += -w * (eta_exit[i] + deriv.ln());
2256                w_event[i] = w;
2257                w_event_inv_deriv[i] = w * inv_deriv;
2258                w_event_outer[i] = w * inv_deriv * inv_deriv;
2259            }
2260        }
2261
2262        // Phase 2: BLAS-accelerated Hessian and gradient via faer.
2263        //   H_interval = X_exit^T diag(w_exit) X_exit - X_entry^T diag(w_entry) X_entry
2264        //   grad_interval = X_exit^T w_exit - X_entry^T w_entry
2265        let mut h = self.interval_hessian_blas(w_hess_exit, w_hess_entry);
2266        // At large smoothing penalties the event-Jacobian score nearly cancels
2267        // the interval score. Compensated row accumulation keeps the final KKT
2268        // residual accurate enough for the outer LAML envelope check.
2269        let mut grad = Array1::<f64>::zeros(p);
2270        let mut grad_comp = Array1::<f64>::zeros(p);
2271        let mut row_exit = vec![0.0_f64; p];
2272        let mut row_entry = vec![0.0_f64; p];
2273        let mut row_derivative = vec![0.0_f64; p];
2274        for i in 0..n {
2275            let w_interval_exit = w_hess_exit[i];
2276            let w_interval_entry = w_hess_entry[i];
2277            let w_event_exit = w_event[i];
2278            let w_event_derivative = w_event_inv_deriv[i];
2279            if w_interval_exit == 0.0
2280                && w_interval_entry == 0.0
2281                && w_event_exit == 0.0
2282                && w_event_derivative == 0.0
2283            {
2284                continue;
2285            }
2286            self.fill_exit_row(i, &mut row_exit);
2287            self.fill_entry_row(i, &mut row_entry);
2288            self.fill_derivative_row(i, &mut row_derivative);
2289            for j in 0..p {
2290                let contribution = w_interval_exit * row_exit[j]
2291                    - w_interval_entry * row_entry[j]
2292                    - w_event_exit * row_exit[j]
2293                    - w_event_derivative * row_derivative[j];
2294                let t = grad[j] + contribution;
2295                if grad[j].abs() >= contribution.abs() {
2296                    grad_comp[j] += (grad[j] - t) + contribution;
2297                } else {
2298                    grad_comp[j] += (contribution - t) + grad[j];
2299                }
2300                grad[j] = t;
2301            }
2302        }
2303        grad += &grad_comp;
2304
2305        h += &self.derivative_xt_diag_x(w_event_outer);
2306
2307        // Norm of the unpenalized score, captured before adding the penalty
2308        // contribution, for the scale-invariant convergence certificate
2309        // (||score||_2 + ||S*beta||_2).
2310        let score_norm = array1_l2_norm(&grad);
2311
2312        let penaltygrad = self.penalties.gradient(beta);
2313        // The WorkingState contract (`gam_solve::pirls::WorkingState`) defines
2314        // `penalty_term` as the FULL quadratic form βᵀSβ: the shared LM objective
2315        // is `½(deviance + penalty_term)` (reweight.rs), so the penalty ENERGY is
2316        // `½·penalty_term`. `PenaltyBlocks::deviance` returns the energy `½βᵀSβ`, so
2317        // the full quadratic is twice it. Storing the energy directly here (as this
2318        // path historically did) made the LM's accept/reject objective read
2319        // `½·deviance + ¼βᵀSβ` — under-penalized by 2× against the `Sβ` step gradient
2320        // in `gradient` below — desyncing objective and step into a non-convergent
2321        // Levenberg–Marquardt crawl at large λ (#2301 defect B). Downstream survival
2322        // consumers that want the energy read `½·penalty_term`.
2323        let penalty_quadratic_form = 2.0 * self.penalties.deviance(beta);
2324        let penaltygrad_norm = array1_l2_norm(&penaltygrad);
2325
2326        let mut totalgrad = grad;
2327        totalgrad += &penaltygrad;
2328
2329        self.penalties.addhessian_inplace(&mut h);
2330        // No coefficient ridge is fused into this objective. Indefinite or
2331        // rank-deficient curvature along the Newton path is the SOLVER's
2332        // problem: the working-model driver applies Levenberg–Marquardt
2333        // damping (H + λD²)δ = −g that vanishes at convergence, so the
2334        // converged estimator is a stationary point of the exact penalized
2335        // likelihood and is invariant under coefficient rescaling.
2336        let log_likelihood = -nll;
2337        let deviance = 2.0 * nll;
2338
2339        Ok(WorkingState {
2340            eta: LinearPredictor::new(eta_exit),
2341            gradient: totalgrad,
2342            hessian: gam_linalg::matrix::SymmetricMatrix::Dense(h),
2343            log_likelihood,
2344            deviance,
2345            penalty_term: penalty_quadratic_form,
2346            firth: gam_solve::pirls::FirthDiagnostics::Inactive,
2347            ridge_used: 0.0,
2348            hessian_curvature: gam_solve::pirls::HessianCurvatureKind::Observed,
2349            gradient_natural_scale: score_norm + penaltygrad_norm,
2350        })
2351    }
2352
2353    /// Compute the third-derivative correction matrix for a given mode response `u_k`.
2354    ///
2355    /// This is the directional derivative of the unpenalized NLL Hessian w.r.t.
2356    /// beta along direction `u_k = -H^{-1} A_k beta_hat`. The returned matrix B
2357    /// satisfies `dH/drho_k = A_k + B`.
2358    ///
2359    /// Called via [`SurvivalDerivProvider`] which adapts the sign convention
2360    /// from the unified `HessianDerivativeProvider` trait (positive `v_k`) to
2361    /// the negated `u_k` used here.
2362    pub(crate) fn survival_hessian_derivative_correction(
2363        &self,
2364        beta: &Array1<f64>,
2365        u_k: &Array1<f64>,
2366    ) -> Result<Array2<f64>, EstimationError> {
2367        let p = beta.len();
2368        let n = self.nrows();
2369
2370        let eta_entry = self.entry_dot(beta) + &self.offset_eta_entry;
2371        let eta_exit = self.exit_dot(beta) + &self.offset_eta_exit;
2372        let deriv_raw = self.derivative_dot(beta) + &self.offset_derivative_exit;
2373        let exp_entry = eta_entry.mapv(f64::exp);
2374        let exp_exit = eta_exit.mapv(f64::exp);
2375        let guard = self.derivative_guard();
2376        let guard_numerical = self.derivative_guard_numerical();
2377
2378        let jac = Array1::<f64>::ones(p);
2379        let curvature = Array1::<f64>::zeros(p);
2380        let third = Array1::<f64>::zeros(p);
2381
2382        let mut row_exit = vec![0.0_f64; p];
2383        let mut row_entry = vec![0.0_f64; p];
2384        let mut row_derivative = vec![0.0_f64; p];
2385        let mut ge = vec![0.0_f64; p];
2386        let mut gs = vec![0.0_f64; p];
2387        let mut gsd = vec![0.0_f64; p];
2388        let mut he = vec![0.0_f64; p];
2389        let mut hs = vec![0.0_f64; p];
2390        let mut hsd = vec![0.0_f64; p];
2391        let mut te = vec![0.0_f64; p];
2392        let mut ts = vec![0.0_f64; p];
2393        let mut tsd = vec![0.0_f64; p];
2394
2395        let mut b_dir = Array2::<f64>::zeros((p, p));
2396
2397        for i in 0..n {
2398            let w_i = self.sampleweight[i];
2399            if w_i <= 0.0 {
2400                continue;
2401            }
2402            let has_entry = !self.entry_at_origin[i];
2403            let mut deta_e = 0.0_f64;
2404            let mut deta_s = 0.0_f64;
2405            let mut ds = 0.0_f64;
2406            self.fill_exit_row(i, &mut row_exit);
2407            self.fill_entry_row(i, &mut row_entry);
2408            self.fill_derivative_row(i, &mut row_derivative);
2409            for j in 0..p {
2410                ge[j] = row_exit[j] * jac[j];
2411                gs[j] = row_entry[j] * jac[j];
2412                gsd[j] = row_derivative[j] * jac[j];
2413                he[j] = row_exit[j] * curvature[j];
2414                hs[j] = row_entry[j] * curvature[j];
2415                hsd[j] = row_derivative[j] * curvature[j];
2416                te[j] = row_exit[j] * third[j];
2417                ts[j] = row_entry[j] * third[j];
2418                tsd[j] = row_derivative[j] * third[j];
2419                deta_e += ge[j] * u_k[j];
2420                if has_entry {
2421                    deta_s += gs[j] * u_k[j];
2422                }
2423                ds += gsd[j] * u_k[j];
2424            }
2425
2426            // Interval part: d/dbeta [ exp(eta) * (g g^T + diag(h)) ][u_k]
2427            for r in 0..p {
2428                let dge_r = he[r] * u_k[r];
2429                let dgs_r = hs[r] * u_k[r];
2430                let dhe_r = te[r] * u_k[r];
2431                let dhs_r = ts[r] * u_k[r];
2432                for c in 0..p {
2433                    let dge_c = he[c] * u_k[c];
2434                    let dgs_c = hs[c] * u_k[c];
2435                    let mut d_h_rc =
2436                        exp_exit[i] * (deta_e * ge[r] * ge[c] + dge_r * ge[c] + ge[r] * dge_c);
2437                    if r == c {
2438                        d_h_rc += exp_exit[i] * (deta_e * he[r] + dhe_r);
2439                    }
2440                    if has_entry {
2441                        d_h_rc -=
2442                            exp_entry[i] * (deta_s * gs[r] * gs[c] + dgs_r * gs[c] + gs[r] * dgs_c);
2443                        if r == c {
2444                            d_h_rc -= exp_entry[i] * (deta_s * hs[r] + dhs_r);
2445                        }
2446                    }
2447                    b_dir[[r, c]] += w_i * d_h_rc;
2448                }
2449            }
2450
2451            // Event part: d/dbeta [ gsd gsd^T / s^2 - diag(he) - diag(hsd / s) ][u_k]
2452            let (s_i, s_slope) = self
2453                .stabilized_structural_derivative(deriv_raw[i])
2454                .unwrap_or((deriv_raw[i], 1.0));
2455            if !s_i.is_finite() {
2456                return Err(EstimationError::ParameterConstraintViolation(format!(
2457                    "survival monotonicity violated in unified trace contraction at row {i}: \
2458                     d_eta/dt={s_i:.3e} <= tolerance={guard:.3e}",
2459                )));
2460            }
2461            if self.event_target[i] > 0 && s_slope != 0.0 {
2462                // On the floored clamp branch (slope 0) the event Hessian
2463                // block is identically zero in a neighborhood of β, so its
2464                // directional derivative vanishes and the whole event part is
2465                // skipped.
2466                if s_i < guard_numerical {
2467                    return Err(EstimationError::ParameterConstraintViolation(format!(
2468                        "survival monotonicity violated in unified trace contraction at row {i}: \
2469                         d_eta/dt={s_i:.3e} <= tolerance={guard:.3e}",
2470                    )));
2471                }
2472                let inv_s = 1.0 / s_i;
2473                let inv_s2 = inv_s * inv_s;
2474                let inv_s3 = inv_s2 * inv_s;
2475                for r in 0..p {
2476                    let dgd_r = hsd[r] * u_k[r];
2477                    let dtsd_r = tsd[r] * u_k[r];
2478                    let dte_r = te[r] * u_k[r];
2479                    for c in 0..p {
2480                        let dgd_c = hsd[c] * u_k[c];
2481                        let mut d_h_rc = (dgd_r * gsd[c] + gsd[r] * dgd_c) * inv_s2
2482                            - 2.0 * gsd[r] * gsd[c] * ds * inv_s3;
2483                        if r == c {
2484                            d_h_rc += -dte_r;
2485                            d_h_rc += -(dtsd_r * inv_s - hsd[r] * ds * inv_s2);
2486                        }
2487                        b_dir[[r, c]] += w_i * d_h_rc;
2488                    }
2489                }
2490            }
2491        }
2492
2493        Ok(b_dir)
2494    }
2495
2496    /// Per-observation gradients of the unpenalized survival NLL with respect
2497    /// to each additive offset channel, at the given β.
2498    ///
2499    /// Contract (Royston-Parmar, eta = log H(t)):
2500    ///
2501    ///   NLL_i(β; o_E, o_X, o_D) = w_i · [
2502    ///       exp(η1_i) − 1{has_entry}·exp(η0_i)
2503    ///       − δ_i · (η1_i + log s_i)
2504    ///   ]
2505    ///
2506    /// with η1_i = a1_iᵀβ + o_X\[i\], η0_i = a0_iᵀβ + o_E\[i\],
2507    ///      s_i  = d_iᵀβ + o_D\[i\].
2508    ///
2509    /// The additive offsets enter each of the three η channels linearly, so
2510    ///   ∂NLL_i/∂o_X\[i\] = w_i · (exp(η1_i) − δ_i)
2511    ///   ∂NLL_i/∂o_E\[i\] = −w_i · exp(η0_i) · 1{has_entry_interval}
2512    ///   ∂NLL_i/∂o_D\[i\] = −w_i · δ_i / s_i         (event-row only)
2513    ///
2514    /// These three arrays are the sampleweight-scaled residuals used to chain
2515    /// `∂NLL/∂offset` into `∂NLL/∂θ` via any closed-form `∂offset/∂θ` map
2516    /// (see `baseline_offset_theta_partials` for parametric baselines). At
2517    /// converged β*, the envelope theorem on the penalized objective gives
2518    ///
2519    ///   d[0.5·(deviance + β*ᵀS_λβ*)] / dθ
2520    ///     = Σᵢ r_X_i·∂o_X_i/∂θ + r_E_i·∂o_E_i/∂θ + r_D_i·∂o_D_i/∂θ
2521    ///
2522    /// exactly (no IFT back-solve required), because β* is a stationary point
2523    /// of the penalized objective wrt β and the penalty has no θ dependence.
2524    ///
2525    /// Rows with `sampleweight[i] ≤ 0` and non-event rows for `r_D` are
2526    /// returned as exact 0.0 so the output can be dot-producted against a
2527    /// per-obs baseline-partials array without a mask.
2528    ///
2529    /// Structural-monotonicity stabilization on `s_i` (see
2530    /// `stabilized_structural_derivative`) is applied identically to the
2531    /// existing `update_state` path so the residual agrees with the
2532    /// NLL that `update_state` evaluates.
2533    pub fn offset_channel_residuals(
2534        &self,
2535        beta: &Array1<f64>,
2536    ) -> Result<OffsetChannelResiduals, EstimationError> {
2537        if beta.len() != self.coefficient_dim() {
2538            crate::bail_invalid_estim!(
2539                "survival beta dimension mismatch in offset_channel_residuals"
2540            );
2541        }
2542        let n = self.nrows();
2543        let eta_entry = self.entry_dot(beta) + &self.offset_eta_entry;
2544        let eta_exit = self.exit_dot(beta) + &self.offset_eta_exit;
2545        let derivative_raw = self.derivative_dot(beta) + &self.offset_derivative_exit;
2546
2547        let derivative_guard_numerical = self.derivative_guard_numerical();
2548        let mut r_exit = Array1::<f64>::zeros(n);
2549        let mut r_entry = Array1::<f64>::zeros(n);
2550        let mut r_deriv = Array1::<f64>::zeros(n);
2551
2552        for i in 0..n {
2553            let w = self.sampleweight[i];
2554            if w <= 0.0 {
2555                continue;
2556            }
2557            let entry_age = self.age_entry[i];
2558            let exit_age = self.age_exit[i];
2559            if !entry_age.is_finite() || !exit_age.is_finite() || exit_age < entry_age {
2560                crate::bail_invalid_estim!(
2561                    "survival ages must be finite with age_exit >= age_entry"
2562                );
2563            }
2564            let has_entry_interval = !self.entry_at_origin[i];
2565            let d = f64::from(self.event_target[i]);
2566            // Phase-1 values matching update_state:
2567            //   w_exit_i  = w · exp(eta_exit)                    → ∂NLL/∂o_X before − δ·w term
2568            //   w_entry_i = w · exp(eta_entry) · 1{has_entry}    → matches −∂NLL/∂o_E sign
2569            let w_exit_i = w * eta_exit[i].exp();
2570            let w_entry_i = if has_entry_interval {
2571                w * eta_entry[i].exp()
2572            } else {
2573                0.0
2574            };
2575            if !w_exit_i.is_finite() {
2576                crate::bail_invalid_estim!(
2577                    "offset_channel_residuals: w*exp(eta_exit)={w_exit_i:.3e} non-finite at row {i}"
2578                );
2579            }
2580            r_exit[i] = w_exit_i - d * w;
2581            r_entry[i] = -w_entry_i;
2582            // Same per-row monotonicity rule as `update_state`: a strictly
2583            // negative derivative at any observed exit time (event or
2584            // censored) falsifies S(t); event rows additionally need
2585            // `deriv > guard` because `1/deriv` enters their score.
2586            let deriv_raw = derivative_raw[i];
2587            let (deriv, deriv_slope) = self
2588                .stabilized_structural_derivative(deriv_raw)
2589                .unwrap_or((deriv_raw, 1.0));
2590            let mono_floor = if d > 0.0 {
2591                derivative_guard_numerical
2592            } else {
2593                0.0
2594            };
2595            if !deriv.is_finite() || deriv < mono_floor {
2596                return Err(EstimationError::ParameterConstraintViolation(format!(
2597                    "offset_channel_residuals: derivative ≤ numerical guard at row {i}: {deriv:.3e}"
2598                )));
2599            }
2600            if d > 0.0 {
2601                // The clamp slope zeroes the residual on the floored branch,
2602                // matching update_state's flat `ln(deriv)` value there.
2603                r_deriv[i] = -w * d * deriv_slope / deriv;
2604            }
2605        }
2606
2607        let right = Array1::<f64>::zeros(r_exit.len());
2608        Ok(OffsetChannelResiduals {
2609            exit: r_exit,
2610            entry: r_entry,
2611            derivative: r_deriv,
2612            right,
2613        })
2614    }
2615
2616    /// Build an [`InnerSolution`](gam_solve::estimate::reml::reml_outer_engine::InnerSolution) from
2617    /// the survival working state, suitable for the unified REML/LAML evaluator.
2618    ///
2619    /// Evaluate the survival outer objective and gradient via the unified REML/LAML
2620    /// evaluator, using the canonical assembly module.
2621    pub fn unified_lamlobjective_and_rhogradient(
2622        &self,
2623        beta: &Array1<f64>,
2624        state: &WorkingState,
2625        rho: &Array1<f64>,
2626    ) -> Result<(f64, Array1<f64>), EstimationError> {
2627        use gam_problem::{EvalMode, PseudoLogdetMode};
2628        use gam_solve::estimate::reml::assembly::InnerAssembly;
2629        use gam_solve::estimate::reml::reml_outer_engine::{
2630            DenseSpectralOperator, DispersionHandling,
2631        };
2632        use gam_solve::estimate::reml::reparameterized_inner::{
2633            RawInnerReparamContext, assemble_reparameterized_inner,
2634        };
2635        use gam_terms::construction::{
2636            canonicalize_penalty_specs, precompute_reparam_invariant_from_canonical,
2637            stable_reparameterizationwith_invariant,
2638        };
2639        use gam_terms::penalty_spec::PenaltySpec;
2640
2641        let p = beta.len();
2642        let active_penalty_blocks: Vec<&PenaltyBlock> = self
2643            .penalties
2644            .blocks
2645            .iter()
2646            .filter(|b| b.lambda > 0.0)
2647            .collect();
2648        if rho.len() != active_penalty_blocks.len() {
2649            crate::bail_invalid_estim!(
2650                "survival LAML rho dimension {} does not match active penalty block count {}",
2651                rho.len(),
2652                active_penalty_blocks.len()
2653            );
2654        }
2655        let k_count = active_penalty_blocks.len();
2656
2657        // The Laplace/LAML envelope is defined at a stationary inner mode.
2658        // Certify that primary precondition before constructing penalty roots,
2659        // reparameterizing, or factorizing the observed Hessian. An arbitrary
2660        // off-mode point can also be indefinite, but it is not yet a candidate
2661        // Laplace mode; reporting or computing the secondary spectral condition
2662        // first both obscures the root refusal and wastes the expensive setup. A
2663        // one-step Newton residual surrogate is not an interchangeable
2664        // criterion when the likelihood Hessian moves with beta: its scalar
2665        // moving-Hessian response requires higher-order rho derivatives that
2666        // the survival provider does not emit. Attaching that partial surrogate
2667        // made the value and gradient different functions at rho=4 (#2491).
2668        //
2669        // Certify the active-set-projected KKT condition in the RAW frame. A
2670        // binding monotonicity constraint contributes r = A^T lambda and must
2671        // be projected out before the stationarity decision. Once certified,
2672        // the exact envelope has no residual term; the transformed assembly
2673        // therefore carries kkt_residual=None deliberately.
2674        let projected_norm = {
2675            let raw = state.gradient.clone();
2676            let projected = match self.monotonicity_linear_constraints() {
2677                Some(constraints) => {
2678                    let constraints = ConstraintSet::Dense(constraints);
2679                    projected_linear_constraint_stationarity_vector(&raw, beta, &constraints, None)
2680                        .ok_or_else(|| {
2681                            EstimationError::InvalidInput(
2682                                "survival LAML could not project the monotonicity KKT residual"
2683                                    .to_string(),
2684                            )
2685                        })?
2686                }
2687                None => raw,
2688            };
2689            array1_l2_norm(&projected)
2690        };
2691        // Accept exactly what the inner solver certified. `certifies_kkt` is the
2692        // producer's own convergence predicate: it accepts under EITHER the
2693        // dimension bound `tol·√(n·p)` or the natural-scale bound
2694        // `tol·(1+‖g‖_scale)`. Re-deriving only the natural-scale half here made
2695        // this gate strictly stronger than the contract PIRLS was run under, so
2696        // every mode that converged via the dimension branch — the binding one
2697        // whenever √(n·p) > 1+scale, i.e. the normal case for these baselines —
2698        // was certified by the solver and then refused here, fatally. Same
2699        // tolerance, same rule, one owner.
2700        if !projected_norm.is_finite()
2701            || !state.certifies_kkt(projected_norm, SURVIVAL_LAML_STATIONARITY_RELATIVE_TOL)
2702        {
2703            // DECLINING is right and stays: a one-step residual surrogate is
2704            // not a differentiable substitute for a Laplace mode, so this
2705            // trial has no LAML value to report. What was wrong is the KIND of
2706            // refusal. `InvalidInput` is a statement about the configuration,
2707            // and `is_trial_point_infeasible` answers `false` for it, so the
2708            // outer lambda-search — whose documented response to "no value
2709            // here" is to mark the point infeasible and step away — aborted
2710            // the whole fit at the first such rho instead (#2531, and #1123's
2711            // own closed guard went red on it). The inner mode's stationarity
2712            // is a property of THIS rho: the same design at a neighbouring
2713            // lambda converges perfectly well, which is why the CLI fits it.
2714            return Err(EstimationError::TrialPointRefused {
2715                reason: format!(
2716                    "survival LAML requires a stationary inner mode: projected KKT residual \
2717                     {projected_norm:.3e} (relative {:.3e}) is not certified by the inner \
2718                     solver's convergence test at tolerance \
2719                     {SURVIVAL_LAML_STATIONARITY_RELATIVE_TOL:.3e}; a one-step residual \
2720                     surrogate is not a differentiable substitute for the Laplace mode",
2721                    state.relative_gradient_norm(projected_norm)
2722                ),
2723            });
2724        }
2725
2726        // λ_k = e^{ρ_k}, in active-block (== ρ) order. Shared by the joint
2727        // normalizer and the Wood reparameterization below.
2728        let lambdas: Vec<f64> = rho.iter().map(|&r| r.exp()).collect();
2729
2730        // --- Raw penalized Hessian + its LAML logdet mode -------------------
2731        // Survival LAML is a Laplace approximation at the fitted inner mode.
2732        // Its observed penalized Hessian must therefore be positive definite on
2733        // the fitted coefficient space. Delayed entry can make trial Hessians
2734        // indefinite; such a trial is not a Laplace mode and is refused instead
2735        // of being converted into a positive-subspace pseudo-objective. The
2736        // exact positive spectrum is also the full IFT system for beta_rho, so
2737        // value, trace, and mode response now use one Hessian (#2491).
2738        let h_dense = state.hessian.to_dense();
2739        let hessian_logdet_mode = PseudoLogdetMode::PositiveDefinite;
2740
2741        // --- Raw per-block penalties, embedded p×p, in ρ order --------------
2742        // Feeds the joint pseudo-logdet `log|Σ_k λ_k S_k|₊` (frame-invariant,
2743        // computed INSIDE the reparam helper from these raw blocks — #2331 R7:
2744        // value/det1/det2 from a single W-factor eigendecomposition, not a
2745        // per-block sum; the survival stabilization ridge is a full-span block
2746        // that overlaps the smoothing blocks, so the joint normalizer is the
2747        // real objective, #2331 Finding 3a).
2748        let s_k_embedded: Vec<Array2<f64>> = active_penalty_blocks
2749            .iter()
2750            .map(|b| {
2751                let mut s = Array2::<f64>::zeros((p, p));
2752                let (rs, re) = (b.range.start, b.range.end);
2753                s.slice_mut(ndarray::s![rs..re, rs..re]).assign(&b.matrix);
2754                s
2755            })
2756            .collect();
2757
2758        // --- Wood (2011) stable reparameterization at the outer LAML seam ---
2759        // (#2331 Inc 2). Build the orthogonal Q_s from the SAME active penalty
2760        // set, in ρ order, so `canonical_transformed[k] ↔ ρ[k]`.
2761        //
2762        // λ-DEPENDENCE / recompute policy:
2763        //   * `canonical_penalties` and `reparam_invariant` (the subspace split /
2764        //     q_pen|q_null basis `qs_base`) depend ONLY on the penalty block
2765        //     matrices+ranges — λ-INDEPENDENT, fixed across the whole outer loop
2766        //     (a future WorkingModelSurvival cache can memoize them; see the cost
2767        //     note in the Inc-2 handoff).
2768        //   * `stable_reparameterizationwith_invariant` embeds λ = e^{ρ} into
2769        //     `e_transformed` / `canonical_transformed`, so it MUST be recomputed
2770        //     at every outer iterate. This is the only λ-dependent piece here.
2771        //
2772        // canonicalize_penalty_spec drops a block only when it is numerically
2773        // rank-0; a λ>0 survival penalty (I-spline 2nd-difference, incl. the
2774        // rank-deficient nullspace ones) is rank>0, so no active block drops. A
2775        // drop would desync the transformed penalty coordinates from ρ, so we
2776        // assert the count is preserved rather than let it slip silently.
2777        let penalty_specs: Vec<PenaltySpec> = active_penalty_blocks
2778            .iter()
2779            .map(|b| PenaltySpec::Block {
2780                local: b.matrix.clone(),
2781                col_range: b.range.clone(),
2782                prior_mean: gam_problem::CoefficientPriorMean::Zero,
2783                structure_hint: None,
2784                op: None,
2785            })
2786            .collect();
2787        let nullspace_dims: Vec<usize> = active_penalty_blocks
2788            .iter()
2789            .map(|b| b.nullspace_dim)
2790            .collect();
2791        let (canonical_penalties, _canonical_nullspace) = canonicalize_penalty_specs(
2792            &penalty_specs,
2793            &nullspace_dims,
2794            p,
2795            "survival LAML seam-A reparameterization",
2796        )
2797        .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
2798        if canonical_penalties.len() != k_count {
2799            return Err(EstimationError::InvalidInput(format!(
2800                "survival LAML reparameterization dropped {} of {} active (λ>0) penalty \
2801                 block(s) as numerically rank-0; cannot align transformed penalty \
2802                 coordinates with ρ",
2803                k_count - canonical_penalties.len(),
2804                k_count
2805            )));
2806        }
2807        let reparam_invariant =
2808            precompute_reparam_invariant_from_canonical(&canonical_penalties, p)
2809                .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
2810        let reparam = stable_reparameterizationwith_invariant(
2811            &canonical_penalties,
2812            &lambdas,
2813            p,
2814            &reparam_invariant,
2815        )
2816        .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
2817
2818        // --- Conjugate the raw inner solution into the Q_s frame ------------
2819        // H′ = Q_sᵀ H Q_s, β̂′ = Q_sᵀ β̂, the joint logdet triple (value/det1/det2),
2820        // and the SurvivalDerivProvider conjugated into the transformed frame.
2821        // The provider is built on the RAW β̂ (it evaluates raw-frame family
2822        // curvature); the helper wraps it so every consumed trace is invariant.
2823        let provider = SurvivalDerivProvider::new(self.clone(), beta.clone());
2824        let ctx = RawInnerReparamContext {
2825            hessian: &h_dense,
2826            beta,
2827            penalties_embedded: &s_k_embedded,
2828            lambdas: &lambdas,
2829        };
2830        let reparam_inner = assemble_reparameterized_inner(
2831            &ctx,
2832            Some(Box::new(provider)),
2833            &reparam,
2834        )
2835        .map_err(EstimationError::InvalidInput)?;
2836
2837        // Hessian operator on the transformed H′. Orthogonal similarity
2838        // preserves strict positive definiteness and the exact spectrum.
2839        // An indefinite inner Hessian here is a property of THIS rho, not of the
2840        // problem: `H_pen = H + S(lambda)` and the active penalty set includes a
2841        // full-span stabilization ridge block, so a neighbouring lambda adds PSD
2842        // curvature on the offending direction and the same design evaluates
2843        // fine. `InvalidInput` is classified FATAL by `is_trial_point_infeasible`,
2844        // so raising it aborted the entire fit at the first indefinite trial
2845        // instead of letting the outer lambda-search mark the point infeasible
2846        // and step away -- on a 6-row delayed-entry design that is the whole
2847        // difference between a fit and
2848        //
2849        //   positive-definite Hessian required for Laplace evaluation:
2850        //   eigenvalue 0 is -1.034960e-2
2851        //
2852        // The sibling refusal 130 lines above already draws this distinction for
2853        // exactly the same reason (#2531); this call site was left behind. The
2854        // positive-definiteness REQUIREMENT is unchanged -- a saddle is still not
2855        // a Laplace mode and is still refused. Only the blast radius of the
2856        // refusal is corrected.
2857        let hop = DenseSpectralOperator::from_symmetric_with_mode(
2858            &reparam_inner.hessian_transformed,
2859            hessian_logdet_mode,
2860        )
2861        .map_err(|reason| EstimationError::TrialPointRefused {
2862            reason: format!(
2863                "survival LAML requires a positive-definite inner Hessian at this rho: {reason}"
2864            ),
2865        })?;
2866
2867        // Penalty coordinates: the per-block TRANSFORMED roots (Q_s frame), the
2868        // single source of truth for penalty roots there. One coordinate per ρ
2869        // block, in order (canonical_transformed is built root-by-root from the
2870        // input penalties, so its order matches ρ). This is exactly the standard
2871        // lane's `reparam_result.canonical_transformed → to_penalty_coordinate`
2872        // path (gam-solve reml/objective.rs build_penalty_coords).
2873        let penalty_coords = reparam
2874            .canonical_transformed
2875            .iter()
2876            .map(|cp| cp.to_penalty_coordinate())
2877            .collect::<Vec<_>>();
2878
2879        // `penalty_term` is the full quadratic βᵀSβ (WorkingState contract),
2880        // which IS the unified evaluator's `penalty_quadratic` (#2301 defect B).
2881        // It is a scalar and invariant under the orthogonal transform
2882        // (β′ᵀS′β′ = βᵀSβ), so it carries over unchanged.
2883        let penalty_quadratic = state.penalty_term;
2884
2885        let result = InnerAssembly {
2886            log_likelihood: state.log_likelihood,
2887            penalty_quadratic,
2888            beta: reparam_inner.beta_transformed,
2889            n_observations: self.nrows(),
2890            hessian_op: std::sync::Arc::new(hop),
2891            // The survival lane prices its logdet on the operator its inner
2892            // solve converged against (#2612).
2893            mode_response_op: None,
2894            penalty_coords,
2895            penalty_logdet: reparam_inner.penalty_logdet,
2896            dispersion: DispersionHandling::Fixed {
2897                phi: 1.0,
2898                include_logdet_h: true,
2899                include_logdet_s: true,
2900            },
2901            rho_curvature_scale: 1.0,
2902            rho_prior: gam_problem::RhoPrior::Flat,
2903            hessian_logdet_correction: 0.0,
2904            penalty_subspace_trace: None,
2905            deriv_provider: reparam_inner.deriv_provider,
2906            firth: None,
2907            nullspace_dim: None,
2908            barrier_config: None,
2909            ext_coords: Vec::new(),
2910            ext_coord_pair_fn: None,
2911            rho_ext_pair_fn: None,
2912            fixed_drift_deriv: None,
2913            contracted_psi_second_order: None,
2914            kkt_residual: None,
2915            active_constraints: None,
2916        }
2917        .evaluate(
2918            rho.as_slice().expect("rho must be contiguous"),
2919            EvalMode::ValueAndGradient,
2920            None,
2921        )
2922        .map_err(EstimationError::InvalidInput)?;
2923
2924        let gradient = result.gradient.unwrap_or_else(|| Array1::zeros(rho.len()));
2925        Ok((result.cost, gradient))
2926    }
2927
2928
2929    /// Self-contained ρ → (LAML value, analytic ρ-gradient) surface for the
2930    /// survival LAML objective.
2931    ///
2932    /// Unlike [`unified_lamlobjective_and_rhogradient`](Self::unified_lamlobjective_and_rhogradient),
2933    /// which takes a *pre-converged* [`WorkingState`] and `β̂` at the evaluated
2934    /// `ρ`, this shim re-converges the inner survival mode internally: it sets
2935    /// the active-block smoothing parameters to `λ = exp(ρ)`, runs the same
2936    /// constrained inner PIRLS that the survival outer loop uses
2937    /// ([`runworking_model_pirls`](gam_solve::pirls::runworking_model_pirls)), then
2938    /// evaluates the unified survival LAML value and analytic ρ-gradient at the
2939    /// re-fitted `β̂(ρ)`. The returned pair is therefore a single-source value+
2940    /// gradient surface that a caller can finite-difference by varying `ρ`
2941    /// alone — the survival counterpart of the GLM path's
2942    /// `evaluate_externalgradient` / `evaluate_externalcost_andridge`.
2943    ///
2944    /// `rho` enumerates the **active** penalty blocks (those with `λ > 0`) in
2945    /// block order, matching the convention of the unified evaluator. `beta0` is
2946    /// the inner warm-start. The behaviour is identical to the existing survival
2947    /// LAML path (set-λ → inner PIRLS → `update_state` → unified LAML); this is a
2948    /// reachability shim, not a new objective.
2949    pub fn evaluate_survival_lamlcost_and_gradient(
2950        &self,
2951        rho: &[f64],
2952        beta0: &Array1<f64>,
2953    ) -> Result<(f64, Array1<f64>), EstimationError> {
2954        let (candidate, beta) = self.reconverge_survival_inner_mode(rho, beta0)?;
2955        // Re-converged β̂(ρ); evaluate the unified survival LAML value and
2956        // analytic ρ-gradient at that mode. The ρ passed to the unified
2957        // evaluator enumerates active blocks in block order, exactly the input
2958        // convention of this shim.
2959        let rho_arr = Array1::from_vec(rho.to_vec());
2960        let state = candidate.update_state(&beta)?;
2961        candidate.unified_lamlobjective_and_rhogradient(&beta, &state, &rho_arr)
2962    }
2963
2964    /// Re-converge the survival inner mode at `λ = exp(ρ)` from warm-start
2965    /// `beta0`, returning the λ-set model candidate and the converged `β̂(ρ)`.
2966    /// This is the shared inner-solve used by
2967    /// [`evaluate_survival_lamlcost_and_gradient`](Self::evaluate_survival_lamlcost_and_gradient):
2968    /// inner PIRLS to a tight relative certificate, followed by a
2969    /// Levenberg–Marquardt / exact-Cholesky stationarity polish that drives the
2970    /// absolute penalized residual `‖S β̂ − ∇ℓ‖` below the FD round-off floor so
2971    /// the envelope ρ-gradient is exact. (Without the polish, PIRLS alone leaves
2972    /// `‖r‖ ~ 1` at large λ where H is ill-conditioned.)
2973    fn reconverge_survival_inner_mode(
2974        &self,
2975        rho: &[f64],
2976        beta0: &Array1<f64>,
2977    ) -> Result<(WorkingModelSurvival, Array1<f64>), EstimationError> {
2978        // Inner-PIRLS settings mirror the survival transformation outer loop's
2979        // constrained inner solve. Tighter convergence than the production
2980        // outer loop so the inner mode is converged well below the FD step's
2981        // round-off floor, making ∇V finite-differentiable in ρ alone.
2982        const SHIM_PIRLS_MAX_ITERATIONS: usize = 600;
2983        const SHIM_PIRLS_CONVERGENCE_TOL: f64 = 1e-12;
2984        const SHIM_PIRLS_MAX_STEP_HALVING: usize = 40;
2985        const SHIM_PIRLS_MIN_STEP_SIZE: f64 = 1e-12;
2986
2987        let active_block_count = self
2988            .penalties
2989            .blocks
2990            .iter()
2991            .filter(|b| b.lambda > 0.0)
2992            .count();
2993        if rho.len() != active_block_count {
2994            crate::bail_invalid_estim!(
2995                "reconverge_survival_inner_mode: rho dimension {} does not match active penalty block count {}",
2996                rho.len(),
2997                active_block_count
2998            );
2999        }
3000        if beta0.len() != self.coefficient_dim() {
3001            crate::bail_invalid_estim!(
3002                "reconverge_survival_inner_mode: beta0 dimension {} does not match coefficient dimension {}",
3003                beta0.len(),
3004                self.coefficient_dim()
3005            );
3006        }
3007        let active_lambdas = gam_problem::checked_exp_log_strengths(rho.iter().copied())?;
3008
3009        // Set λ = exp(ρ) on the active blocks (block order), leaving inactive
3010        // (λ = 0) blocks untouched, then re-converge the inner mode.
3011        let mut candidate = self.clone();
3012        let mut lambdas: Vec<f64> = candidate
3013            .penalties
3014            .blocks
3015            .iter()
3016            .map(|b| b.lambda)
3017            .collect();
3018        let mut active_idx = 0usize;
3019        for (block, lambda) in candidate.penalties.blocks.iter().zip(lambdas.iter_mut()) {
3020            if block.lambda > 0.0 {
3021                *lambda = active_lambdas[active_idx];
3022                active_idx += 1;
3023            }
3024        }
3025        candidate.set_penalty_lambdas(&lambdas)?;
3026
3027        let opts = gam_solve::pirls::WorkingModelPirlsOptions {
3028            max_iterations: SHIM_PIRLS_MAX_ITERATIONS,
3029            convergence_tolerance: SHIM_PIRLS_CONVERGENCE_TOL,
3030            adaptive_kkt_tolerance: None,
3031            max_step_halving: SHIM_PIRLS_MAX_STEP_HALVING,
3032            min_step_size: SHIM_PIRLS_MIN_STEP_SIZE,
3033            firth_bias_reduction: false,
3034            coefficient_lower_bounds: None,
3035            linear_constraints: None,
3036            initial_lm_lambda: None,
3037            arrow_schur: None,
3038        };
3039        let summary = gam_solve::pirls::runworking_model_pirls(
3040            &mut candidate,
3041            Coefficients::new(beta0.clone()),
3042            &opts,
3043            // This shim exists so a caller can finite-difference the survival
3044            // LAML surface in rho. When such a check disagrees with the
3045            // analytic gradient, the inner trajectory that produced beta-hat(rho)
3046            // is the first thing to inspect, so trace it instead of discarding
3047            // it.
3048            Some(&mut |info: &gam_solve::pirls::WorkingModelIterationInfo| {
3049                log::trace!(
3050                    "[survival LAML shim] inner PIRLS iter={} deviance={:.6e} \
3051                     |grad|={:.3e} step={:.3e} halvings={}",
3052                    info.iteration,
3053                    info.deviance,
3054                    info.gradient_norm,
3055                    info.step_size,
3056                    info.step_halving
3057                );
3058            }),
3059        )?;
3060        let mut beta = summary.beta.as_ref().to_owned();
3061
3062        // PIRLS exits on a RELATIVE KKT / deviance-plateau certificate, which can leave
3063        // an ABSOLUTE penalized stationarity residual r = S beta_hat - grad_ell of order
3064        // 0.1-1 (the score scales as O(sqrt(n))). The unified LAML gradient uses the
3065        // envelope theorem, exact only at r = 0; a residual that large leaks <r, beta_dot>
3066        // into the objective<->gradient consistency, AND the IFT envelope correction is
3067        // only leading-order in r, so it cannot make the analytic gradient the exact
3068        // derivative of the (re-converged, non-smooth-in-r) value surface either. The
3069        // robust cure is to drive the inner to TRUE stationarity (||r|| ~ 1e-11) so the
3070        // envelope is exactly valid and the IFT term is ~1e-22 — which it is at small
3071        // lambda, but a plain undamped Newton-polish STALLS at large lambda (rho=4..8):
3072        // there the intercept-direction curvature exp(eta)*n is large while the penalized
3073        // time block is lambda*S, so H is ill-conditioned and an undamped step neither
3074        // decreases ||r|| nor stays feasible, leaving ||r|| ~ 3e-2.
3075        //
3076        // Levenberg–Marquardt damping fixes this: solve (H + mu*diag(H)) delta = r,
3077        // accept on a genuine ||r||^2 decrease (Gauss–Newton on the stationarity system,
3078        // whose Jacobian is the penalized Hessian H), shrink mu on success and grow it on
3079        // rejection. The diagonal (Marquardt) scaling makes the damping curvature-aware so
3080        // the stiff time block and the soft intercept are damped commensurately. This
3081        // reliably reaches ||r|| below the FD-step round-off floor across the whole
3082        // rho = [-0.5 .. 8] range exercised by the consistency gates.
3083        {
3084            const POLISH_MAX_ITERS: usize = 400;
3085            const POLISH_TOL: f64 = 1e-13;
3086            // Armijo sufficient-decrease constant and backtracking factor —
3087            // the shared opt tuning constants; only the halving
3088            // budget (80, deeper than the shared 60) is site-specific.
3089            const ARMIJO_C: f64 = constants::ARMIJO_C1;
3090            const BACKTRACK: f64 = constants::BACKTRACK_CONTRACTION;
3091            const MAX_BACKTRACK: usize = 80;
3092            let p = beta.len();
3093            // Penalized inner objective f(β) = −ℓ(β) + ½β'Sβ whose gradient is
3094            // exactly `state.gradient` and whose UNDAMPED Hessian is exactly
3095            // `state.hessian`. `update_state` exposes the pieces directly; the
3096            // Levenberg–Marquardt shift below exists only while solving a step.
3097            // `penalty_term` is the full quadratic βᵀSβ (WorkingState contract), so
3098            // the ½β'Sβ energy is `0.5 * penalty_term` (#2301 defect B).
3099            let penalized_objective =
3100                |st: &WorkingState| -> f64 { -st.log_likelihood + 0.5 * st.penalty_term };
3101            for _ in 0..POLISH_MAX_ITERS {
3102                let st = match candidate.update_state(&beta) {
3103                    Ok(st) => st,
3104                    Err(_) => break,
3105                };
3106                let r = st.gradient.clone();
3107                let r_norm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
3108                if !r_norm.is_finite() || r_norm < POLISH_TOL {
3109                    break;
3110                }
3111                let h = st.hessian.to_dense();
3112                let f0 = penalized_objective(&st);
3113                // Newton DIRECTION d = −H⁻¹r on the convex penalized survival
3114                // likelihood, found via a Levenberg–Marquardt-regularized solve
3115                // so an ill-conditioned H (h_diag ratio ~2400 at β₀≈4.6, where
3116                // exp(η) is huge) cannot produce a garbage direction whose
3117                // quadratic form rᵀH⁻¹r loses its sign. If even the regularized
3118                // Newton direction is not a sufficient descent direction, fall
3119                // back to STEEPEST DESCENT d = −r, which is ALWAYS a descent
3120                // direction on the convex objective (∇fᵀ(−r) = −‖r‖² < 0). The
3121                // line search below is on the OBJECTIVE VALUE (not ‖r‖), so any
3122                // descent direction makes monotone progress; near the optimum
3123                // the (lightly regularized) Newton step recovers fast local
3124                // convergence. This is globally convergent for the convex
3125                // penalized survival NLL — driving ‖r‖ below the FD round-off
3126                // floor so the envelope ρ-gradient equals the finite difference.
3127                let h_scale = (0..p)
3128                    .map(|d| h[[d, d]].abs())
3129                    .fold(0.0_f64, f64::max)
3130                    .max(1.0);
3131                // Solve (H + λI) step = r by an EXACT Cholesky factorization
3132                // (faer Llt), NOT the DenseSpectralOperator: the spectral
3133                // operator clamps tiny/negative eigenvalues, which on the
3134                // catastrophically ill-conditioned boundary Hessian (cond ~2400,
3135                // exp(η) huge at β₀≈4.6) corrupts the solve so badly that
3136                // rᵀH⁻¹r lost its sign and the previous polish broke on iter 0.
3137                // Cholesky succeeds iff H+λI is SPD; sweeping λ up from 0 finds
3138                // the smallest SPD shift, and for an SPD system rᵀ(H+λI)⁻¹r > 0
3139                // EXACTLY (Cholesky is backward-stable, no clamping), so the
3140                // Newton direction is a guaranteed descent direction.
3141                // The attempt certifies a DESCENT direction, not mere
3142                // factorability: Cholesky must succeed, the solve must stay
3143                // finite, and the directional derivative must clear the
3144                // sign floor. ∇fᵀd = rᵀ(−step) = −r·(H+λI)⁻¹r < 0 exactly
3145                // for SPD systems.
3146                let try_lm = |lambda_lm: f64| -> Option<(Array1<f64>, f64)> {
3147                    let mut h_reg = h.clone();
3148                    for d in 0..p {
3149                        h_reg[[d, d]] += lambda_lm;
3150                    }
3151                    let factor =
3152                        gam_linalg::faer_ndarray::FaerCholesky::cholesky(&h_reg, faer::Side::Lower)
3153                            .ok()?;
3154                    let candidate_step = factor.solvevec(&r);
3155                    if candidate_step.iter().any(|v| !v.is_finite()) {
3156                        return None;
3157                    }
3158                    let dd = -r.dot(&candidate_step);
3159                    (dd.is_finite() && dd < -1e-14 * r_norm * r_norm)
3160                        .then_some((candidate_step, dd))
3161                };
3162                // Bare λ=0 attempt first, then 17 decades from 1e-11·h_scale
3163                // (the pre-migration `1e-12·h_scale·10^pow` for pow = 1..18).
3164                let (step, dir_deriv) = try_lm(0.0)
3165                    .or_else(|| {
3166                        escalate_ridge(RidgeSchedule::geometric(1e-11 * h_scale, 17), try_lm)
3167                            .ok()
3168                            .map(|success| success.value)
3169                    })
3170                    .unwrap_or_else(|| {
3171                        // Steepest-descent fallback: d = −r ⇒ step = +r (we step
3172                        // β − step), ∇fᵀd = −‖r‖² < 0.
3173                        (r.clone(), -r_norm * r_norm)
3174                    });
3175                // Accept on EITHER a sufficient objective decrease (Armijo,
3176                // the global-convergence guarantee on the convex objective)
3177                // OR a strict residual-norm decrease. Near the solution the
3178                // penalized objective is flat to f64 roundoff (f0 ≈ ft), so a
3179                // pure-Armijo test backtracks α→0 and crawls (the asymmetric
3180                // ρ=3.99999 stall: 200 iters at 3.7e-7 vs 12 iters at the
3181                // other two ρ). The ‖r‖-decrease arm lets the exact Cholesky
3182                // Newton step (α=1) through, restoring quadratic convergence
3183                // to ~1e-12 symmetrically across all three FD points so the
3184                // centered FD of the value surface is itself exact.
3185                //
3186                // The dual criterion reads BOTH the trial objective and the
3187                // trial residual norm, so the decision lives inside the trial
3188                // closure: a rejected candidate returns `Ok(None)` (shrink)
3189                // exactly like an invalid `update_state`, and the accept
3190                // predicate is the constant `true`.
3191                let accepted = match backtracking_line_search::<_, Infallible>(
3192                    BacktrackConfig {
3193                        contraction: BACKTRACK,
3194                        max_steps: MAX_BACKTRACK,
3195                        ..BacktrackConfig::default()
3196                    },
3197                    |alpha| {
3198                        let trial = &beta - &(alpha * &step);
3199                        let Ok(ts) = candidate.update_state(&trial) else {
3200                            return Ok(None);
3201                        };
3202                        let ft = penalized_objective(&ts);
3203                        let tn = ts.gradient.iter().map(|v| v * v).sum::<f64>().sqrt();
3204                        let armijo_ok = ft.is_finite() && ft <= f0 + ARMIJO_C * alpha * dir_deriv;
3205                        let residual_ok = tn.is_finite() && tn < r_norm;
3206                        Ok((armijo_ok || residual_ok).then_some((ft, trial)))
3207                    },
3208                    // The accept/reject decision is made inside the trial
3209                    // closure above, which returns `Ok(None)` to shrink. What
3210                    // is left for this predicate is the contract that closure
3211                    // promises: it only yields a candidate whose objective is
3212                    // finite (both `armijo_ok` and `residual_ok` require it).
3213                    // Checking it here enforces that promise at the seam where
3214                    // `opt` turns a trial into an accepted step.
3215                    |_, value| value.is_finite(),
3216                ) {
3217                    Ok(result) => result,
3218                    Err(never) => match never {},
3219                };
3220                let Some(ls) = accepted else {
3221                    break;
3222                };
3223                beta = ls.payload;
3224            }
3225        }
3226
3227        Ok((candidate, beta))
3228    }
3229}
3230
3231/// Derivative provider that adapts survival third-derivative Hessian corrections
3232/// to the unified [`HessianDerivativeProvider`](gam_solve::estimate::reml::reml_outer_engine::HessianDerivativeProvider)
3233/// trait.
3234///
3235/// The unified trait supplies `v_k = H^{-1}(A_k beta_hat)` (positive sign),
3236/// whereas the survival engine's
3237/// [`survival_hessian_derivative_correction`](WorkingModelSurvival::survival_hessian_derivative_correction)
3238/// expects `u_k = -v_k`. This provider handles the sign conversion.
3239pub(crate) struct SurvivalDerivProvider {
3240    model: WorkingModelSurvival,
3241    beta: Array1<f64>,
3242}
3243
3244impl SurvivalDerivProvider {
3245    pub(crate) fn new(model: WorkingModelSurvival, beta: Array1<f64>) -> Self {
3246        Self { model, beta }
3247    }
3248}
3249
3250impl gam_solve::estimate::reml::reml_outer_engine::HessianDerivativeProvider
3251    for SurvivalDerivProvider
3252{
3253    fn hessian_derivative_correction(
3254        &self,
3255        v_k: &Array1<f64>,
3256    ) -> Result<Option<Array2<f64>>, String> {
3257        // The trait provides v_k = H^{-1}(A_k beta_hat) (positive).
3258        // The survival method expects u_k = -H^{-1} A_k beta_hat = -v_k.
3259        let u_k = -v_k;
3260        match self
3261            .model
3262            .survival_hessian_derivative_correction(&self.beta, &u_k)
3263        {
3264            Ok(correction) => Ok(Some(correction)),
3265            Err(e) => Err(e.to_string()),
3266        }
3267    }
3268
3269    fn has_corrections(&self) -> bool {
3270        true
3271    }
3272}
3273
3274#[derive(Debug, Clone)]
3275pub struct CrudeRiskResult {
3276    pub risk: f64,
3277    pub diseasegradient: Array1<f64>,
3278    pub mortalitygradient: Array1<f64>,
3279}
3280
3281#[derive(Debug, Clone)]
3282pub struct CompetingRisksCifResult {
3283    /// Cumulative incidence per endpoint. `cif[ep][[row, time_idx]]` is the
3284    /// probability that cause `ep` occurred by `times[time_idx]` for sample `row`.
3285    /// Stored one matrix per endpoint so it is ergonomic to index per-cause and
3286    /// natural to construct from the per-endpoint cumulative-hazard inputs.
3287    pub cif: Vec<Array2<f64>>,
3288    pub overall_survival: Array2<f64>,
3289}
3290
3291/// Subject-count threshold below which competing-risks CIF assembly stays on the
3292/// serial path. The per-row work (a `n_times`-long prefix-sum recurrence with a
3293/// handful of `exp`/`exp_m1` per element) is cheap, so small panels avoid rayon
3294/// fan-out overhead; large panels (the #1082 quality-test sizes) amortize it.
3295const COMPETING_RISKS_CIF_PARALLEL_ROW_MIN: usize = 256;
3296
3297pub fn assemble_competing_risks_cif(
3298    times: ArrayView1<'_, f64>,
3299    cumulative_hazard: ArrayView3<'_, f64>,
3300) -> Result<CompetingRisksCifResult, SurvivalError> {
3301    let (n_endpoints, n_rows, n_times) = cumulative_hazard.dim();
3302    if n_endpoints == 0 {
3303        return Err(SurvivalError::DimensionMismatch);
3304    }
3305    let endpoint_hazards = cumulative_hazard
3306        .axis_iter(Axis(0))
3307        .map(|view| view.to_owned())
3308        .collect::<Vec<_>>();
3309    assemble_competing_risks_cif_from_endpoints(times, &endpoint_hazards).and_then(|result| {
3310        if result.overall_survival.dim() != (n_rows, n_times) {
3311            Err(SurvivalError::DimensionMismatch)
3312        } else {
3313            Ok(result)
3314        }
3315    })
3316}
3317
3318pub fn assemble_competing_risks_cif_from_endpoints(
3319    times: ArrayView1<'_, f64>,
3320    cumulative_hazards: &[Array2<f64>],
3321) -> Result<CompetingRisksCifResult, SurvivalError> {
3322    let n_endpoints = cumulative_hazards.len();
3323    if n_endpoints == 0 || times.is_empty() {
3324        return Err(SurvivalError::DimensionMismatch);
3325    }
3326    let (n_rows, n_times) = cumulative_hazards[0].dim();
3327    if n_rows == 0 || n_times == 0 || times.len() != n_times {
3328        return Err(SurvivalError::DimensionMismatch);
3329    }
3330    if times.iter().any(|time| !time.is_finite() || *time < 0.0) {
3331        return Err(SurvivalError::InvalidTimeGrid);
3332    }
3333    if times
3334        .iter()
3335        .zip(times.iter().skip(1))
3336        .any(|(previous, current)| current <= previous)
3337    {
3338        return Err(SurvivalError::InvalidTimeGrid);
3339    }
3340    for endpoint_hazard in cumulative_hazards {
3341        if endpoint_hazard.dim() != (n_rows, n_times) {
3342            return Err(SurvivalError::DimensionMismatch);
3343        }
3344        if endpoint_hazard.iter().any(|value| !value.is_finite()) {
3345            return Err(SurvivalError::NonFiniteInput);
3346        }
3347    }
3348
3349    let max_abs_hazard = cumulative_hazards
3350        .iter()
3351        .flat_map(|endpoint_hazard| endpoint_hazard.iter())
3352        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
3353    let monotone_tolerance = 1.0e-10_f64 * max_abs_hazard.max(1.0);
3354    let mut cif: Vec<Array2<f64>> = (0..n_endpoints)
3355        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
3356        .collect();
3357    let mut overall_survival = Array2::<f64>::zeros((n_rows, n_times));
3358
3359    // Per-row CIF assembly. The TIME axis is a sequential prefix-sum recurrence
3360    // (`previous_*` carry forward across `time_idx`) and MUST stay ordered, so it
3361    // is left as the inner serial loop. The ROW (subject) axis is fully
3362    // independent: every `previous_*`/`increments` buffer is allocated fresh per
3363    // row, no state crosses rows, and each row writes only its own disjoint
3364    // output slices. The per-row result is byte-identical regardless of which
3365    // thread runs it, so we fan the outer row loop out over rayon and write the
3366    // owned per-row buffers back serially in row order (deterministic, bit-exact
3367    // vs. the serial implementation).
3368    //
3369    // `cif_flat` is endpoint-major: `cif_flat[endpoint * n_times + time_idx]`.
3370    let assemble_row = |row: usize| -> Result<(Vec<f64>, Vec<f64>), SurvivalError> {
3371        let mut cif_flat = vec![0.0_f64; n_endpoints * n_times];
3372        let mut surv_row = vec![0.0_f64; n_times];
3373        let mut previous_cif = vec![0.0_f64; n_endpoints];
3374        let mut previous_cumulative = vec![0.0_f64; n_endpoints];
3375        let mut increments = vec![0.0_f64; n_endpoints];
3376        let mut previous_total_cumulative = 0.0_f64;
3377        for time_idx in 0..n_times {
3378            let mut total_increment = 0.0_f64;
3379            for endpoint in 0..n_endpoints {
3380                let current = cumulative_hazards[endpoint][[row, time_idx]];
3381                if current < -monotone_tolerance {
3382                    return Err(SurvivalError::NonMonotoneCumulativeHazard);
3383                }
3384                let raw_increment = current - previous_cumulative[endpoint];
3385                if raw_increment < -monotone_tolerance {
3386                    return Err(SurvivalError::NonMonotoneCumulativeHazard);
3387                }
3388                let increment = raw_increment.max(0.0);
3389                increments[endpoint] = increment;
3390                total_increment += increment;
3391                previous_cumulative[endpoint] += increment;
3392            }
3393
3394            let survival_left = (-previous_total_cumulative).exp();
3395            let interval_failure = -(-total_increment).exp_m1();
3396            for endpoint in 0..n_endpoints {
3397                if total_increment > 0.0 {
3398                    previous_cif[endpoint] +=
3399                        survival_left * interval_failure * increments[endpoint] / total_increment;
3400                }
3401                cif_flat[endpoint * n_times + time_idx] = previous_cif[endpoint].clamp(0.0, 1.0);
3402            }
3403            previous_total_cumulative += total_increment;
3404            // Derive `S(t)` from the stored cause-specific CIFs at this time so
3405            // that the competing-risks closure identity
3406            //   Σ_k F_k(t) + S(t) = 1
3407            // holds bit-exactly. Computing `S` independently as
3408            // `exp(-Σ_k H_k(t))` and then comparing against the (clamped, ratio-
3409            // split) Σ F_k introduces O(machine-eps) closure error because the
3410            // float increments
3411            //   ΔF_k = S_left·(1-exp(-ΔH))·ΔH_k/ΔH_total
3412            // do not sum to `S_left - S_new` bit-exactly. By summing the stored
3413            // CIFs in the same left-fold order as `slice.iter().sum::<f64>()`
3414            // and defining `S := 1.0 - Σ F_k`, the IEEE-754 round-trip
3415            //   (1.0 - f) + f
3416            // restores the identity for finite f ∈ [0, 1]. The mathematically
3417            // consistent survival value `exp(-H_total)` is still tracked up to
3418            // ulp-level precision because the ΔF_k construction matches
3419            // `S_left - S_new` to leading order.
3420            let mut fsum_at_t = 0.0_f64;
3421            for endpoint in 0..n_endpoints {
3422                fsum_at_t += cif_flat[endpoint * n_times + time_idx];
3423            }
3424            surv_row[time_idx] = (1.0_f64 - fsum_at_t).clamp(0.0, 1.0);
3425        }
3426        Ok((cif_flat, surv_row))
3427    };
3428
3429    // Nesting guard (`rayon::current_thread_index().is_none()`) keeps us from
3430    // oversubscribing when this routine is itself called from inside a rayon
3431    // worker, and the row-count gate keeps small inputs on the serial path.
3432    let rows: Vec<(Vec<f64>, Vec<f64>)> = if n_rows >= COMPETING_RISKS_CIF_PARALLEL_ROW_MIN
3433        && rayon::current_thread_index().is_none()
3434    {
3435        use rayon::prelude::*;
3436        (0..n_rows)
3437            .into_par_iter()
3438            .map(assemble_row)
3439            .collect::<Result<_, _>>()?
3440    } else {
3441        (0..n_rows).map(assemble_row).collect::<Result<_, _>>()?
3442    };
3443
3444    for (row, (cif_flat, surv_row)) in rows.into_iter().enumerate() {
3445        for endpoint in 0..n_endpoints {
3446            for time_idx in 0..n_times {
3447                cif[endpoint][[row, time_idx]] = cif_flat[endpoint * n_times + time_idx];
3448            }
3449        }
3450        for time_idx in 0..n_times {
3451            overall_survival[[row, time_idx]] = surv_row[time_idx];
3452        }
3453    }
3454
3455    Ok(CompetingRisksCifResult {
3456        cif,
3457        overall_survival,
3458    })
3459}
3460
3461/// `(node, weight)` pairs of the `n`-point Gauss-Legendre rule on `[-1, 1]`;
3462/// the canonical generator lives in `gam-math` (previously triplicated
3463/// across gam-terms / gam-model-kernels / gam-models).
3464fn compute_gauss_legendre_nodes(n: usize) -> Vec<(f64, f64)> {
3465    let (nodes, weights) = gam_math::special::gauss_legendre(n);
3466    nodes.into_iter().zip(weights).collect()
3467}
3468
3469fn gauss_legendre_quadrature() -> &'static [(f64, f64)] {
3470    // `LazyLock` (not `OnceLock::get_or_init`) so first init never parks a
3471    // caller on the OS condvar from inside a rayon worker. The competing-risks
3472    // CIF assembler in this file dispatches `into_par_iter` and the
3473    // codebase-level lint (`tests/once_lock_get_or_init_not_inside_parallel_regions.rs`)
3474    // forbids the lazy `OnceLock` accessor in any rayon-adjacent file.
3475    static CACHE: LazyLock<Vec<(f64, f64)>> = LazyLock::new(|| compute_gauss_legendre_nodes(40));
3476    &CACHE
3477}
3478
3479/// Engine-level crude risk quadrature with exact delta-method gradients.
3480///
3481/// This routine owns the numerical integration and gradient accumulation math:
3482/// - It integrates `h_d(u) * S_total(u | t0)` over `[t0, t1]` by high-order
3483///   Gauss-Legendre quadrature.
3484/// - It computes gradients w.r.t. disease and mortality coefficients:
3485///   d Risk / d beta_d and d Risk / d beta_m.
3486///
3487/// The adapter provides the domain-specific point evaluator callback `eval_at`,
3488/// which fills design rows and returns:
3489/// - instantaneous disease hazard h_d(u) at age `u`,
3490/// - cumulative disease hazard `H_d(u)`,
3491/// - cumulative mortality hazard `H_m(u)`.
3492///
3493/// The callback must fill the following arrays (one entry per coefficient):
3494/// - `design_d[j]`: partial derivative of the linear predictor eta_d w.r.t. beta_j
3495///   at time u, i.e. x_j(u) = d eta_d(u) / d beta_j.
3496/// - `deriv_d[j]`: partial derivative of the TIME DERIVATIVE of eta_d w.r.t. beta_j
3497///   at time u, i.e. x_dot_j(u) = d/d(beta_j) [d eta_d(u)/du].
3498/// - `design_m[j]`: same as design_d but for the mortality linear predictor eta_m.
3499///
3500/// This keeps domain/data wiring out of `gam` while centralizing the
3501/// integration engine in one place.
3502pub fn calculate_crude_risk_quadrature<F>(
3503    t0: f64,
3504    t1: f64,
3505    breakpoints: &[f64],
3506    h_dis_t0: f64,
3507    h_mor_t0: f64,
3508    design_d_t0: ArrayView1<'_, f64>,
3509    design_m_t0: ArrayView1<'_, f64>,
3510    mut eval_at: F,
3511) -> Result<CrudeRiskResult, SurvivalError>
3512where
3513    F: FnMut(
3514        f64,
3515        &mut Array1<f64>,
3516        &mut Array1<f64>,
3517        &mut Array1<f64>,
3518    ) -> Result<(f64, f64, f64), SurvivalError>,
3519{
3520    let coeff_len_d = design_d_t0.len();
3521    let coeff_len_m = design_m_t0.len();
3522    if coeff_len_d == 0 || coeff_len_m == 0 {
3523        return Err(SurvivalError::InvalidIntegrationSetup);
3524    }
3525    if !t0.is_finite()
3526        || !t1.is_finite()
3527        || !h_dis_t0.is_finite()
3528        || !h_mor_t0.is_finite()
3529        || design_d_t0.iter().any(|v| !v.is_finite())
3530        || design_m_t0.iter().any(|v| !v.is_finite())
3531    {
3532        return Err(SurvivalError::NonFiniteInput);
3533    }
3534    if t1 <= t0 {
3535        return Ok(CrudeRiskResult {
3536            risk: 0.0,
3537            diseasegradient: Array1::zeros(coeff_len_d),
3538            mortalitygradient: Array1::zeros(coeff_len_m),
3539        });
3540    }
3541
3542    let mut sorted_breaks: Vec<f64> = breakpoints
3543        .iter()
3544        .copied()
3545        .filter(|x| x.is_finite() && *x >= t0 && *x <= t1)
3546        .collect();
3547    sorted_breaks.push(t0);
3548    sorted_breaks.push(t1);
3549    sorted_breaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3550    sorted_breaks.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
3551    if sorted_breaks.len() < 2 {
3552        return Err(SurvivalError::InvalidIntegrationSetup);
3553    }
3554
3555    let mut total_risk = 0.0;
3556    let mut diseasegradient = Array1::zeros(coeff_len_d);
3557    let mut mortalitygradient = Array1::zeros(coeff_len_m);
3558    let nodesweights = gauss_legendre_quadrature();
3559
3560    let mut design_d = Array1::<f64>::zeros(coeff_len_d);
3561    let mut deriv_d = Array1::<f64>::zeros(coeff_len_d);
3562    let mut design_m = Array1::<f64>::zeros(coeff_len_m);
3563
3564    for segment in sorted_breaks.windows(2) {
3565        let a = segment[0];
3566        let b = segment[1];
3567        let center = 0.5 * (b + a);
3568        let halfwidth = 0.5 * (b - a);
3569        if halfwidth <= 0.0 {
3570            continue;
3571        }
3572
3573        for &(x, w) in nodesweights {
3574            let u = center + halfwidth * x;
3575            let (inst_hazard_d, hazard_d, hazard_m) =
3576                eval_at(u, &mut design_d, &mut deriv_d, &mut design_m)?;
3577            if !inst_hazard_d.is_finite() || !hazard_d.is_finite() || !hazard_m.is_finite() {
3578                return Err(SurvivalError::NonFiniteInput);
3579            }
3580            if inst_hazard_d <= 0.0 {
3581                return Err(SurvivalError::NonPositiveHazard);
3582            }
3583
3584            if hazard_d < h_dis_t0 || hazard_m < h_mor_t0 {
3585                return Err(SurvivalError::NonMonotoneCumulativeHazard);
3586            }
3587
3588            let h_dis_cond = hazard_d - h_dis_t0;
3589            let h_mor_cond = hazard_m - h_mor_t0;
3590            let s_total = (-(h_dis_cond + h_mor_cond)).exp();
3591
3592            total_risk += w * inst_hazard_d * s_total * halfwidth;
3593
3594            // d Risk / d beta_d:
3595            //   integral [ d h_d * S_total - h_d * S_total * d H_d ] du
3596            // Contract: design_d[j] = x_j(u) = ∂_{β_j} η_d(u)
3597            //           deriv_d[j]  = ẋ_j(u) = ∂_{β_j} η̇_d(u)
3598            // Then ∂_{β_j} h_d = h_d · x_j + H_d · ẋ_j
3599            let weight = w * s_total * halfwidth;
3600            for j in 0..coeff_len_d {
3601                let d_inst_hazard = inst_hazard_d * design_d[j] + hazard_d * deriv_d[j];
3602                let d_hazard_cond = hazard_d * design_d[j] - h_dis_t0 * design_d_t0[j];
3603                let g = d_inst_hazard - inst_hazard_d * d_hazard_cond;
3604                diseasegradient[j] += weight * g;
3605            }
3606
3607            // d Risk / d beta_m:
3608            //   -integral h_d * S_total * d H_m(u|t0) du
3609            let weight = w * inst_hazard_d * s_total * halfwidth;
3610            for j in 0..coeff_len_m {
3611                let g = -hazard_m * design_m[j] + h_mor_t0 * design_m_t0[j];
3612                mortalitygradient[j] += weight * g;
3613            }
3614        }
3615    }
3616
3617    Ok(CrudeRiskResult {
3618        risk: total_risk,
3619        diseasegradient,
3620        mortalitygradient,
3621    })
3622}
3623
3624impl PirlsWorkingModel for WorkingModelSurvival {
3625    fn update(&mut self, beta: &Coefficients) -> Result<WorkingState, EstimationError> {
3626        self.update_state(beta)
3627    }
3628}
3629
3630#[cfg(test)]
3631mod tests {
3632    use super::*;
3633    use ndarray::{Array1, Array2, Array3, array, s};
3634
3635    #[test]
3636    fn saved_cause_specific_alo_matches_independent_closed_form() {
3637        let eta_exit = 0.4_f64;
3638        let eta_entry = -0.3_f64;
3639        let derivative_exit = 1.7_f64;
3640        let weight = 2.2_f64;
3641        let geometry = cause_specific_survival_alo_row_geometry(CauseSpecificSurvivalAloRowInput {
3642            eta_exit,
3643            eta_entry,
3644            derivative_exit,
3645            prior_weight: weight,
3646            entry_active: true,
3647            event: true,
3648        })
3649        .expect("valid cause-specific row");
3650        let expected_nll =
3651            weight * (eta_exit.exp() - eta_entry.exp() - eta_exit - derivative_exit.ln());
3652        let expected_score = [
3653            weight * (eta_exit.exp() - 1.0),
3654            -weight * eta_entry.exp(),
3655            -weight / derivative_exit,
3656        ];
3657        let expected_hessian = [
3658            [weight * eta_exit.exp(), 0.0, 0.0],
3659            [0.0, -weight * eta_entry.exp(), 0.0],
3660            [0.0, 0.0, weight / derivative_exit.powi(2)],
3661        ];
3662        assert!((geometry.negative_log_likelihood - expected_nll).abs() <= 2.0e-14);
3663        for row in 0..3 {
3664            assert!((geometry.nll_score[row] - expected_score[row]).abs() <= 2.0e-14);
3665            for column in 0..3 {
3666                assert!(
3667                    (geometry.observed_hessian[row][column] - expected_hessian[row][column]).abs()
3668                        <= 2.0e-14
3669                );
3670            }
3671        }
3672        let score_meat = geometry.nll_score[0] * geometry.nll_score[0];
3673        assert!(
3674            (geometry.observed_hessian[0][0] - score_meat).abs() > 1.0e-2,
3675            "survival observed W and empirical score meat C must remain separate"
3676        );
3677    }
3678
3679    /// #932 production single-source parity for the cause-specific Royston-Parmar
3680    /// derivative tower. The earlier cutover added only a gam-math oracle that
3681    /// replicated the production `w_exit`/`w_entry`/`w_derivative` weight formulas
3682    /// verbatim; this module INVOKES production
3683    /// (`evaluate_cause_specific_block`, `cause_specific_hessian_directional_derivative`,
3684    /// `cause_specific_hessian_second_directional_derivative`) and pins each
3685    /// channel against the universal gam-math jet at ≤1e-9, plus an independent
3686    /// central-difference witness of the live third/fourth against the live lower
3687    /// order. The generic production program and every specialized channel are
3688    /// emitted from the same row declaration.
3689    mod jet_cause_specific_production_parity {
3690        use super::*;
3691        use gam_math::jet_tower::{
3692            program_fourth_contracted, program_row_kernel, program_third_contracted,
3693        };
3694
3695        /// A single-row cause-specific block with the design collapsed to the 3×3
3696        /// identity (`x_exit = e0`, `x_entry = e1`, `x_derivative = e2`, zero
3697        /// offsets), so β directly parameterises `(η1, η0, s)` and a coefficient
3698        /// direction IS the predictor-space direction — pinning the per-row
3699        /// β-space kernels against the jet's predictor-space contractions with no
3700        /// design projection in the way.
3701        fn identity_block(w: f64, has_entry: bool, event: bool) -> CauseSpecificRoystonParmarBlock {
3702            let age_entry = if has_entry { 1.0 } else { 0.0 };
3703            CauseSpecificRoystonParmarBlock {
3704                age_entry: array![age_entry],
3705                age_exit: array![2.0],
3706                event_target: array![if event { 1u8 } else { 0u8 }],
3707                sampleweight: array![w],
3708                x_entry: array![[0.0, 1.0, 0.0]],
3709                x_exit: array![[1.0, 0.0, 0.0]],
3710                x_derivative: array![[0.0, 0.0, 1.0]],
3711                offset_eta_entry: array![0.0],
3712                offset_eta_exit: array![0.0],
3713                offset_derivative_exit: array![0.0],
3714                derivative_floor: 0.0,
3715                structural_time_columns: 0,
3716            }
3717        }
3718
3719        fn close(hand: f64, jet: f64, tol: f64, label: &str) {
3720            let band = tol + tol * hand.abs().max(jet.abs());
3721            assert!(
3722                (hand - jet).abs() <= band,
3723                "{label}: hand {hand:+.15e} vs jet {jet:+.15e} (|Δ|={:.3e} band {band:.3e})",
3724                (hand - jet).abs()
3725            );
3726        }
3727
3728        const JET_TOL: f64 = 1e-9;
3729
3730        fn run_corner(has_entry: bool, event: bool) {
3731            // β = (η1, η0, s); s > 0 for the event ln-derivative term.
3732            let beta = array![0.4_f64, -0.3_f64, 1.3_f64];
3733            let d_beta = array![0.7_f64, -0.5_f64, 0.6_f64];
3734            let v_beta = array![-0.2_f64, 0.8_f64, -0.4_f64];
3735            let w = 1.4_f64;
3736            let block = identity_block(w, has_entry, event);
3737            let prog = crate::survival::CauseSpecificRowProgram::new(
3738                [beta[0], beta[1], beta[2]],
3739                w,
3740                has_entry,
3741                event,
3742            );
3743            let label = format!("entry={has_entry} event={event}");
3744
3745            // ── Value / gradient / Hessian: LIVE evaluate vs jet ──────────────
3746            let (ll, grad, hess) =
3747                evaluate_cause_specific_block(&block, &beta).expect("evaluate block");
3748            let (jet_v, jet_g, jet_h) = program_row_kernel(&prog, 0).expect("jet kernel");
3749            close(jet_v, -ll, JET_TOL, &format!("{label} value"));
3750            for a in 0..3 {
3751                close(jet_g[a], -grad[a], JET_TOL, &format!("{label} grad[{a}]"));
3752                for b in 0..3 {
3753                    close(
3754                        jet_h[a][b],
3755                        hess[[a, b]],
3756                        JET_TOL,
3757                        &format!("{label} H[{a}][{b}]"),
3758                    );
3759                }
3760            }
3761
3762            // ── Third: LIVE directional derivative vs jet ─────────────────────
3763            let dh = cause_specific_hessian_directional_derivative(&block, &beta, &d_beta)
3764                .expect("live third");
3765            let dir = [d_beta[0], d_beta[1], d_beta[2]];
3766            let jet_t3 = program_third_contracted(&prog, 0, &dir).expect("jet third");
3767            for a in 0..3 {
3768                for b in 0..3 {
3769                    close(
3770                        jet_t3[a][b],
3771                        dh[[a, b]],
3772                        JET_TOL,
3773                        &format!("{label} third[{a}][{b}]"),
3774                    );
3775                }
3776            }
3777
3778            // ── Fourth: LIVE second directional derivative vs jet ─────────────
3779            let d2h = cause_specific_hessian_second_directional_derivative(
3780                &block, &beta, &d_beta, &v_beta,
3781            )
3782            .expect("live fourth");
3783            let uu = [d_beta[0], d_beta[1], d_beta[2]];
3784            let vv = [v_beta[0], v_beta[1], v_beta[2]];
3785            let jet_t4 = program_fourth_contracted(&prog, 0, &uu, &vv).expect("jet fourth");
3786            for a in 0..3 {
3787                for b in 0..3 {
3788                    close(
3789                        jet_t4[a][b],
3790                        d2h[[a, b]],
3791                        JET_TOL,
3792                        &format!("{label} fourth[{a}][{b}]"),
3793                    );
3794                }
3795            }
3796
3797            // ── Independent FD witness (NO jet) ───────────────────────────────
3798            // ∂_d_beta H via central difference of the LIVE evaluate Hessian.
3799            let h_fd = 1e-5;
3800            let bp = &beta + &(&d_beta * h_fd);
3801            let bm = &beta - &(&d_beta * h_fd);
3802            let (_, _, hp) = evaluate_cause_specific_block(&block, &bp).expect("evaluate +");
3803            let (_, _, hm) = evaluate_cause_specific_block(&block, &bm).expect("evaluate -");
3804            for a in 0..3 {
3805                for b in 0..3 {
3806                    let fd = (hp[[a, b]] - hm[[a, b]]) / (2.0 * h_fd);
3807                    close(dh[[a, b]], fd, 1e-5, &format!("{label} FD third[{a}][{b}]"));
3808                }
3809            }
3810            // ∂_v of the LIVE third (fixed direction d_beta) vs the LIVE fourth.
3811            let dhp = cause_specific_hessian_directional_derivative(
3812                &block,
3813                &bp_along(&beta, &v_beta, h_fd),
3814                &d_beta,
3815            )
3816            .expect("live third +");
3817            let dhm = cause_specific_hessian_directional_derivative(
3818                &block,
3819                &bm_along(&beta, &v_beta, h_fd),
3820                &d_beta,
3821            )
3822            .expect("live third -");
3823            for a in 0..3 {
3824                for b in 0..3 {
3825                    let fd = (dhp[[a, b]] - dhm[[a, b]]) / (2.0 * h_fd);
3826                    close(
3827                        d2h[[a, b]],
3828                        fd,
3829                        1e-5,
3830                        &format!("{label} FD fourth[{a}][{b}]"),
3831                    );
3832                }
3833            }
3834        }
3835
3836        fn bp_along(beta: &Array1<f64>, v: &Array1<f64>, h: f64) -> Array1<f64> {
3837            beta + &(v * h)
3838        }
3839        fn bm_along(beta: &Array1<f64>, v: &Array1<f64>, h: f64) -> Array1<f64> {
3840            beta - &(v * h)
3841        }
3842
3843        /// The LIVE cause-specific value / gradient / Hessian / third / fourth hand
3844        /// tower reproduces the universal gam-math jet at ≤1e-9, and the live
3845        /// third/fourth reproduce an independent central-difference of the live
3846        /// lower order — across all four (event × entry) corners that gate the
3847        /// entry and event predictor channels on and off.
3848        #[test]
3849        fn cause_specific_live_tower_matches_jet_and_fd() {
3850            for &has_entry in &[false, true] {
3851                for &event in &[false, true] {
3852                    run_corner(has_entry, event);
3853                }
3854            }
3855        }
3856
3857        /// Diagnostic throughput comparison for the cause-specific
3858        /// Royston-Parmar row. The
3859        /// production structure-compiled order-2/third/fourth lowerings
3860        /// ([`cause_specific_row_order2`] / [`cause_specific_row_third_contracted`]
3861        /// / [`cause_specific_row_fourth_contracted`], all emitted from the one
3862        /// [`cause_specific_row`] declaration) are timed against the generic
3863        /// gam-math forward-mode jet tower ([`program_row_kernel`] /
3864        /// [`program_third_contracted`] / [`program_fourth_contracted`]) — the
3865        /// generic automatic-differentiation implementation. This is not the
3866        /// #932 strongest-hand acceptance gate; that independent, semantically
3867        /// identical comparison lives in `cause_specific_codegen_perf`.
3868        /// The third/fourth contracted directional channels feed the live
3869        /// exact-Newton HVP (`exact_newton_hessian_*directional_derivative`), so
3870        /// timing only order-2 left the higher-order production jets unmeasured.
3871        /// Emits the generic-tower time over production time per derivative
3872        /// channel.
3873        ///
3874        /// The batch mixes all four (entry × event) activity corners with distinct
3875        /// per-row predictors, so the optimizer cannot hoist the pure row call out
3876        /// of the sweep, and the finite checksum over every returned channel keeps
3877        /// the whole sweep live without `std::hint::black_box`.
3878        #[test]
3879        fn release_measure_cause_specific_vs_generic_tower_diagnostic() {
3880            use std::time::Instant;
3881
3882            const ROWS: usize = 512;
3883            let mut rows: Vec<([f64; 3], f64, bool, bool)> = Vec::with_capacity(ROWS);
3884            for idx in 0..ROWS {
3885                let f = idx as f64;
3886                let eta_exit = 1.6 * (f * 0.17 + 0.3).sin() - 0.4 * (f * 0.09).cos();
3887                let eta_entry = 1.1 * (f * 0.13 + 0.7).cos() + 0.35 * (f * 0.05).sin();
3888                // Strictly positive spline derivative for the event ln-derivative term.
3889                let derivative = 0.5 + 0.45 * (f * 0.31 + 0.2).sin().abs();
3890                let weight = 0.6 + 0.4 * (f * 0.07 + 1.0).sin().abs();
3891                let entry_active = idx % 2 == 0;
3892                let event = (idx / 2) % 2 == 0;
3893                rows.push((
3894                    [eta_exit, eta_entry, derivative],
3895                    weight,
3896                    entry_active,
3897                    event,
3898                ));
3899            }
3900            let programs: Vec<crate::survival::CauseSpecificRowProgram> = rows
3901                .iter()
3902                .map(|&(primary, weight, entry_active, event)| {
3903                    crate::survival::CauseSpecificRowProgram::new(
3904                        primary,
3905                        weight,
3906                        entry_active,
3907                        event,
3908                    )
3909                })
3910                .collect();
3911            // Distinct per-row contraction directions for the third/fourth
3912            // channels, kept off the primary axes so no direction degenerates.
3913            let dir_u: Vec<[f64; 3]> = (0..ROWS)
3914                .map(|idx| {
3915                    let f = idx as f64;
3916                    [
3917                        0.7 * (f * 0.23 + 0.4).cos() - 0.2 * (f * 0.03).sin(),
3918                        -0.6 * (f * 0.29 + 0.1).sin() + 0.25 * (f * 0.15).cos(),
3919                        0.5 * (f * 0.19 + 0.6).cos() - 0.3 * (f * 0.08).sin(),
3920                    ]
3921                })
3922                .collect();
3923            let dir_v: Vec<[f64; 3]> = (0..ROWS)
3924                .map(|idx| {
3925                    let f = idx as f64;
3926                    [
3927                        -0.5 * (f * 0.21 + 0.9).sin() + 0.3 * (f * 0.06).cos(),
3928                        0.8 * (f * 0.27 + 0.5).cos() - 0.15 * (f * 0.04).sin(),
3929                        0.4 * (f * 0.13 + 0.3).sin() - 0.2 * (f * 0.11).cos(),
3930                    ]
3931                })
3932                .collect();
3933
3934            // Warm both paths and pin equal V/G/H plus equal contracted
3935            // third/fourth so each timed pair measures equal work.
3936            for (idx, (row, program)) in rows.iter().zip(programs.iter()).enumerate() {
3937                let (primary, weight, entry_active, event) = *row;
3938                let atom = cause_specific_row_order2(
3939                    primary[0],
3940                    primary[1],
3941                    primary[2],
3942                    weight,
3943                    entry_active,
3944                    event,
3945                );
3946                let (tower_value, tower_gradient, tower_hessian) =
3947                    program_row_kernel(program, 0).expect("tower warm kernel");
3948                close(
3949                    atom.value(),
3950                    tower_value,
3951                    JET_TOL,
3952                    "release-measure value parity",
3953                );
3954                let production_gradient = atom.gradient();
3955                for a in 0..3 {
3956                    close(
3957                        production_gradient[a],
3958                        tower_gradient[a],
3959                        JET_TOL,
3960                        "release-measure gradient parity",
3961                    );
3962                    for b in 0..3 {
3963                        close(
3964                            atom.hessian_at(a, b),
3965                            tower_hessian[a][b],
3966                            JET_TOL,
3967                            "release-measure hessian parity",
3968                        );
3969                    }
3970                }
3971                let production_third = cause_specific_row_third_contracted(
3972                    primary[0],
3973                    primary[1],
3974                    primary[2],
3975                    weight,
3976                    entry_active,
3977                    event,
3978                    &dir_u[idx],
3979                );
3980                let tower_third =
3981                    program_third_contracted(program, 0, &dir_u[idx]).expect("tower warm third");
3982                let production_fourth = cause_specific_row_fourth_contracted(
3983                    primary[0],
3984                    primary[1],
3985                    primary[2],
3986                    weight,
3987                    entry_active,
3988                    event,
3989                    &dir_u[idx],
3990                    &dir_v[idx],
3991                );
3992                let tower_fourth = program_fourth_contracted(program, 0, &dir_u[idx], &dir_v[idx])
3993                    .expect("tower warm fourth");
3994                for a in 0..3 {
3995                    for b in 0..3 {
3996                        close(
3997                            production_third[a][b],
3998                            tower_third[a][b],
3999                            JET_TOL,
4000                            "release-measure third parity",
4001                        );
4002                        close(
4003                            production_fourth[a][b],
4004                            tower_fourth[a][b],
4005                            JET_TOL,
4006                            "release-measure fourth parity",
4007                        );
4008                    }
4009                }
4010            }
4011
4012            let best_secs = |sweep: &mut dyn FnMut() -> f64| -> f64 {
4013                let mut best = f64::INFINITY;
4014                for _ in 0..5 {
4015                    let started = Instant::now();
4016                    let checksum = sweep();
4017                    assert!(
4018                        checksum.is_finite(),
4019                        "cause-specific release-measure checksum must stay finite"
4020                    );
4021                    best = best.min(started.elapsed().as_secs_f64());
4022                }
4023                best
4024            };
4025
4026            let mut production_sweep = || {
4027                let mut checksum = 0.0_f64;
4028                for &(primary, weight, entry_active, event) in &rows {
4029                    let atom = cause_specific_row_order2(
4030                        primary[0],
4031                        primary[1],
4032                        primary[2],
4033                        weight,
4034                        entry_active,
4035                        event,
4036                    );
4037                    checksum += atom.value() + atom.gradient()[0] + atom.hessian_at(0, 0);
4038                }
4039                checksum
4040            };
4041            let production_secs = best_secs(&mut production_sweep);
4042
4043            let mut tower_sweep = || {
4044                let mut checksum = 0.0_f64;
4045                for program in &programs {
4046                    let (value, gradient, hessian) =
4047                        program_row_kernel(program, 0).expect("tower kernel");
4048                    checksum += value + gradient[0] + hessian[0][0];
4049                }
4050                checksum
4051            };
4052            let tower_secs = best_secs(&mut tower_sweep);
4053
4054            let mut production_third_sweep = || {
4055                let mut checksum = 0.0_f64;
4056                for (idx, &(primary, weight, entry_active, event)) in rows.iter().enumerate() {
4057                    let third = cause_specific_row_third_contracted(
4058                        primary[0],
4059                        primary[1],
4060                        primary[2],
4061                        weight,
4062                        entry_active,
4063                        event,
4064                        &dir_u[idx],
4065                    );
4066                    checksum += third[0][0] + third[0][1] + third[1][1];
4067                }
4068                checksum
4069            };
4070            let production_third_secs = best_secs(&mut production_third_sweep);
4071            let mut tower_third_sweep = || {
4072                let mut checksum = 0.0_f64;
4073                for (idx, program) in programs.iter().enumerate() {
4074                    let third = program_third_contracted(program, 0, &dir_u[idx])
4075                        .expect("tower third kernel");
4076                    checksum += third[0][0] + third[0][1] + third[1][1];
4077                }
4078                checksum
4079            };
4080            let tower_third_secs = best_secs(&mut tower_third_sweep);
4081
4082            let mut production_fourth_sweep = || {
4083                let mut checksum = 0.0_f64;
4084                for (idx, &(primary, weight, entry_active, event)) in rows.iter().enumerate() {
4085                    let fourth = cause_specific_row_fourth_contracted(
4086                        primary[0],
4087                        primary[1],
4088                        primary[2],
4089                        weight,
4090                        entry_active,
4091                        event,
4092                        &dir_u[idx],
4093                        &dir_v[idx],
4094                    );
4095                    checksum += fourth[0][0] + fourth[0][1] + fourth[1][1];
4096                }
4097                checksum
4098            };
4099            let production_fourth_secs = best_secs(&mut production_fourth_sweep);
4100            let mut tower_fourth_sweep = || {
4101                let mut checksum = 0.0_f64;
4102                for (idx, program) in programs.iter().enumerate() {
4103                    let fourth = program_fourth_contracted(program, 0, &dir_u[idx], &dir_v[idx])
4104                        .expect("tower fourth kernel");
4105                    checksum += fourth[0][0] + fourth[0][1] + fourth[1][1];
4106                }
4107                checksum
4108            };
4109            let tower_fourth_secs = best_secs(&mut tower_fourth_sweep);
4110
4111            for (channel, production_secs, tower_secs) in [
4112                ("order2", production_secs, tower_secs),
4113                ("third", production_third_secs, tower_third_secs),
4114                ("fourth", production_fourth_secs, tower_fourth_secs),
4115            ] {
4116                let production_ns = production_secs * 1e9 / ROWS as f64;
4117                let tower_ns = tower_secs * 1e9 / ROWS as f64;
4118                eprintln!(
4119                    "CAUSE-SPECIFIC-RELEASE-932 channel={channel} rows={ROWS} \
4120                     production_ns={production_ns:.3} generic_tower_ns={tower_ns:.3} \
4121                     generic_over_production={:.6}",
4122                    tower_ns / production_ns,
4123                );
4124            }
4125        }
4126    }
4127
4128    #[test]
4129    fn competing_risks_cif_constant_hazard_matches_closed_form() {
4130        let times = array![0.0, 2.0, 5.0, 10.0];
4131        let disease_rates = [0.12, 0.06];
4132        let death_rates = [0.05, 0.02];
4133        let cumulative = Array3::from_shape_fn((2, 2, times.len()), |(endpoint, row, time_idx)| {
4134            let rate = if endpoint == 0 {
4135                disease_rates[row]
4136            } else {
4137                death_rates[row]
4138            };
4139            rate * times[time_idx]
4140        });
4141
4142        let result =
4143            assemble_competing_risks_cif(times.view(), cumulative.view()).expect("assemble CIF");
4144
4145        for row in 0..2 {
4146            let total_rate = disease_rates[row] + death_rates[row];
4147            for time_idx in 0..times.len() {
4148                let failure = 1.0 - (-total_rate * times[time_idx]).exp();
4149                let expected_disease = disease_rates[row] / total_rate * failure;
4150                let expected_death = death_rates[row] / total_rate * failure;
4151                assert!((result.cif[0][[row, time_idx]] - expected_disease).abs() < 1e-12);
4152                assert!((result.cif[1][[row, time_idx]] - expected_death).abs() < 1e-12);
4153                assert!(
4154                    (result.cif[0][[row, time_idx]]
4155                        + result.cif[1][[row, time_idx]]
4156                        + result.overall_survival[[row, time_idx]]
4157                        - 1.0)
4158                        .abs()
4159                        < 1e-12
4160                );
4161            }
4162        }
4163    }
4164
4165    #[test]
4166    fn competing_risks_cif_rejects_nonmonotone_hazards() {
4167        let times = array![0.0, 1.0, 2.0];
4168        let cumulative = Array3::from_shape_vec((1, 1, 3), vec![0.0, 0.2, 0.1]).expect("shape");
4169        let err = assemble_competing_risks_cif(times.view(), cumulative.view())
4170            .expect_err("nonmonotone cumulative hazard should be rejected");
4171        assert!(matches!(err, SurvivalError::NonMonotoneCumulativeHazard));
4172    }
4173
4174    #[test]
4175    fn competing_risks_cif_plateaus_and_three_causes_conserve_probability() {
4176        let times = array![0.0, 1.0, 3.0, 7.0, 12.0];
4177        let cumulative = Array3::from_shape_vec(
4178            (3, 2, 5),
4179            vec![
4180                // cause 1
4181                0.0, 0.2, 0.2, 0.5, 1.1, 0.0, 0.0, 0.4, 0.4, 0.9, // cause 2
4182                0.0, 0.1, 0.3, 0.3, 0.7, 0.0, 0.2, 0.2, 0.8, 0.8, // cause 3
4183                0.0, 0.0, 0.2, 0.6, 0.6, 0.0, 0.1, 0.5, 0.5, 1.5,
4184            ],
4185        )
4186        .expect("shape");
4187
4188        let result =
4189            assemble_competing_risks_cif(times.view(), cumulative.view()).expect("assemble CIF");
4190
4191        for row in 0..2 {
4192            for time_idx in 0..times.len() {
4193                let total_cif = result.cif[0][[row, time_idx]]
4194                    + result.cif[1][[row, time_idx]]
4195                    + result.cif[2][[row, time_idx]];
4196                assert!(
4197                    (total_cif + result.overall_survival[[row, time_idx]] - 1.0).abs() < 1e-12,
4198                    "probability mass mismatch at row={row}, time_idx={time_idx}"
4199                );
4200                assert!((0.0..=1.0).contains(&result.overall_survival[[row, time_idx]]));
4201                for cause in 0..3 {
4202                    assert!((0.0..=1.0).contains(&result.cif[cause][[row, time_idx]]));
4203                    if time_idx > 0 {
4204                        assert!(
4205                            result.cif[cause][[row, time_idx]] + 1e-12
4206                                >= result.cif[cause][[row, time_idx - 1]],
4207                            "CIF decreased for cause={cause}, row={row}, time_idx={time_idx}"
4208                        );
4209                    }
4210                }
4211            }
4212        }
4213
4214        // Cause 1 is flat between t=1 and t=3 for row 0, but other causes
4215        // fail in that interval; its CIF must remain exactly flat.
4216        assert_eq!(result.cif[0][[0, 1]], result.cif[0][[0, 2]]);
4217        // All causes are flat between t=3 and t=7 for row 1 except cause 2;
4218        // causes 1 and 3 must not move.
4219        assert_eq!(result.cif[0][[1, 2]], result.cif[0][[1, 3]]);
4220        assert_eq!(result.cif[2][[1, 2]], result.cif[2][[1, 3]]);
4221    }
4222
4223    #[test]
4224    fn competing_risks_cif_rejects_bad_time_grids_and_nonfinite_hazards() {
4225        let cumulative = Array3::zeros((2, 1, 2));
4226
4227        for times in [array![0.0, 0.0], array![1.0, 0.5], array![-1.0, 1.0]] {
4228            let err = assemble_competing_risks_cif(times.view(), cumulative.view())
4229                .expect_err("bad time grid should be rejected");
4230            assert!(matches!(err, SurvivalError::InvalidTimeGrid));
4231        }
4232
4233        let times = array![0.0, 1.0];
4234        let nonfinite = Array3::from_shape_vec((1, 1, 2), vec![0.0, f64::NAN]).expect("shape");
4235        let err = assemble_competing_risks_cif(times.view(), nonfinite.view())
4236            .expect_err("nonfinite hazard should be rejected");
4237        assert!(matches!(err, SurvivalError::NonFiniteInput));
4238    }
4239
4240    #[test]
4241    fn competing_risks_cif_extreme_hazards_remain_bounded() {
4242        let times = array![0.0, 1.0, 2.0];
4243        let cumulative =
4244            Array3::from_shape_vec((2, 1, 3), vec![0.0, 500.0, 1000.0, 0.0, 250.0, 1000.0])
4245                .expect("shape");
4246
4247        let result =
4248            assemble_competing_risks_cif(times.view(), cumulative.view()).expect("assemble CIF");
4249
4250        for value in result
4251            .cif
4252            .iter()
4253            .flat_map(|m| m.iter())
4254            .chain(result.overall_survival.iter())
4255        {
4256            assert!(value.is_finite());
4257            assert!((0.0..=1.0).contains(value));
4258        }
4259        assert!((result.cif[0][[0, 2]] + result.cif[1][[0, 2]] - 1.0).abs() < 1e-12);
4260        assert_eq!(result.overall_survival[[0, 2]], 0.0);
4261    }
4262
4263    fn toy_penalties() -> PenaltyBlocks {
4264        let s = array![[2.0, 0.5], [0.5, 3.0]];
4265        PenaltyBlocks::new(vec![PenaltyBlock {
4266            matrix: s,
4267            lambda: 1.7,
4268            range: 1..3,
4269            nullspace_dim: 0,
4270        }])
4271    }
4272
4273    fn survival_inputs<'a>(
4274        age_entry: &'a Array1<f64>,
4275        age_exit: &'a Array1<f64>,
4276        event_target: &'a Array1<u8>,
4277        event_competing: &'a Array1<u8>,
4278        sampleweight: &'a Array1<f64>,
4279        x_entry: &'a Array2<f64>,
4280        x_exit: &'a Array2<f64>,
4281        x_derivative: &'a Array2<f64>,
4282    ) -> SurvivalEngineInputs<'a> {
4283        SurvivalEngineInputs {
4284            age_entry: age_entry.view(),
4285            age_exit: age_exit.view(),
4286            event_target: event_target.view(),
4287            event_competing: event_competing.view(),
4288            sampleweight: sampleweight.view(),
4289            x_entry: x_entry.view(),
4290            x_exit: x_exit.view(),
4291            x_derivative: x_derivative.view(),
4292            monotonicity_constraint_rows: None,
4293            monotonicity_constraint_offsets: None,
4294        }
4295    }
4296
4297    fn survival_model(
4298        inputs: SurvivalEngineInputs<'_>,
4299        penalties: PenaltyBlocks,
4300        monotonicity: SurvivalMonotonicityPenalty,
4301        spec: SurvivalSpec,
4302    ) -> Result<WorkingModelSurvival, SurvivalError> {
4303        WorkingModelSurvival::from_engine_inputs(inputs, penalties, monotonicity, spec)
4304    }
4305
4306    fn survival_model_with_offsets(
4307        inputs: SurvivalEngineInputs<'_>,
4308        offsets: Option<SurvivalBaselineOffsets<'_>>,
4309        penalties: PenaltyBlocks,
4310        monotonicity: SurvivalMonotonicityPenalty,
4311        spec: SurvivalSpec,
4312    ) -> Result<WorkingModelSurvival, SurvivalError> {
4313        WorkingModelSurvival::from_engine_inputswith_offsets(
4314            inputs,
4315            offsets,
4316            penalties,
4317            monotonicity,
4318            spec,
4319        )
4320    }
4321
4322    #[test]
4323    fn penaltyhessian_matchesgradient_jacobian() {
4324        let penalties = toy_penalties();
4325        let beta = array![10.0, -0.3, 1.2, 7.0];
4326
4327        let grad = penalties.gradient(&beta);
4328        let h = penalties.hessian(beta.len());
4329        let b_block = beta.slice(s![1..3]).to_owned();
4330        let expected = 1.7 * array![[2.0, 0.5], [0.5, 3.0]].dot(&b_block);
4331
4332        assert!((grad[1] - expected[0]).abs() < 1e-12);
4333        assert!((grad[2] - expected[1]).abs() < 1e-12);
4334        assert!((h[[1, 1]] - 1.7 * 2.0).abs() < 1e-12);
4335        assert!((h[[1, 2]] - 1.7 * 0.5).abs() < 1e-12);
4336        assert!((h[[2, 1]] - 1.7 * 0.5).abs() < 1e-12);
4337        assert!((h[[2, 2]] - 1.7 * 3.0).abs() < 1e-12);
4338    }
4339
4340    #[test]
4341    fn penaltygradient_matches_deviance_finite_difference() {
4342        let penalties = toy_penalties();
4343        let beta = array![10.0, -0.3, 1.2, 7.0];
4344        let grad = penalties.gradient(&beta);
4345        let eps = 1e-7;
4346
4347        for idx in 0..beta.len() {
4348            let mut plus = beta.clone();
4349            let mut minus = beta.clone();
4350            plus[idx] += eps;
4351            minus[idx] -= eps;
4352            let fd = (penalties.deviance(&plus) - penalties.deviance(&minus)) / (2.0 * eps);
4353            assert_eq!(
4354                grad[idx].signum(),
4355                fd.signum(),
4356                "gradient/deviance sign mismatch at idx={idx}: grad={} fd={fd}",
4357                grad[idx]
4358            );
4359            assert!(
4360                (grad[idx] - fd).abs() < 1e-6,
4361                "gradient/deviance mismatch at idx={idx}: grad={} fd={fd}",
4362                grad[idx]
4363            );
4364        }
4365    }
4366
4367    #[test]
4368    fn zero_offsets_match_default_survival_state() {
4369        let age_entry = array![1.0_f64, 2.0_f64];
4370        let age_exit = array![2.0_f64, 3.5_f64];
4371        let event_target = array![1u8, 0u8];
4372        let event_competing = array![0u8, 0u8];
4373        let sampleweight = array![1.0, 1.0];
4374        let x_entry = array![[1.0, age_entry[0].ln()], [1.0, age_entry[1].ln()]];
4375        let x_exit = array![[1.0, age_exit[0].ln()], [1.0, age_exit[1].ln()]];
4376        let x_derivative = array![[0.0, 1.0 / age_exit[0]], [0.0, 1.0 / age_exit[1]]];
4377        let penalties = PenaltyBlocks::new(Vec::new());
4378        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4379        let beta = array![-1.0, 0.8];
4380
4381        let base = survival_model(
4382            survival_inputs(
4383                &age_entry,
4384                &age_exit,
4385                &event_target,
4386                &event_competing,
4387                &sampleweight,
4388                &x_entry,
4389                &x_exit,
4390                &x_derivative,
4391            ),
4392            penalties.clone(),
4393            mono,
4394            SurvivalSpec::Net,
4395        )
4396        .expect("construct base survival model");
4397
4398        let zero_offsets = survival_model_with_offsets(
4399            survival_inputs(
4400                &age_entry,
4401                &age_exit,
4402                &event_target,
4403                &event_competing,
4404                &sampleweight,
4405                &x_entry,
4406                &x_exit,
4407                &x_derivative,
4408            ),
4409            Some(SurvivalBaselineOffsets {
4410                eta_entry: array![0.0, 0.0].view(),
4411                eta_exit: array![0.0, 0.0].view(),
4412                derivative_exit: array![0.0, 0.0].view(),
4413            }),
4414            penalties,
4415            mono,
4416            SurvivalSpec::Net,
4417        )
4418        .expect("construct offset survival model");
4419
4420        let state_base = base.update_state(&beta).expect("base state");
4421        let statezero = zero_offsets.update_state(&beta).expect("zero-offset state");
4422        assert!((state_base.deviance - statezero.deviance).abs() < 1e-12);
4423        assert!(
4424            state_base
4425                .gradient
4426                .iter()
4427                .zip(statezero.gradient.iter())
4428                .all(|(a, b)| (a - b).abs() < 1e-12)
4429        );
4430    }
4431
4432    #[test]
4433    fn competing_risk_cause_labels_collapse_to_pooled_baseline_indicator() {
4434        // Regression for #378: the joint competing-risks Weibull path seeds a
4435        // shared single-hazard baseline working model from the dataset's event
4436        // *labels* {0 = censored, 1 = cause 1, 2 = cause 2}. The single-hazard
4437        // engine's `event_target` contract is binary {0, 1}, so feeding the raw
4438        // cause labels straight through used to bail out of construction via the
4439        // `event_target > 1` guard and surface as the misleading
4440        // `SurvivalError::NonFiniteInput` ("inputs contain non-finite values"),
4441        // even though every input value is finite. The fix (a) reports a
4442        // multi-cause label as the actionable `EventCodeInvalid`, never the
4443        // misleading "non-finite", and (b) projects cause labels to the
4444        // any-event {0, 1} indicator via the single-source-of-truth
4445        // `pooled_any_event_indicator` before constructing the pooled baseline.
4446        // This pins both halves of that contract.
4447        let age_entry = array![0.0_f64, 0.0, 0.0, 0.0];
4448        let age_exit = array![1.2_f64, 0.8, 2.1, 1.5];
4449        // Competing-risks cause labels: censored, cause 1, cause 2, censored.
4450        let cause_labels = array![0u8, 1u8, 2u8, 0u8];
4451        let event_competing = Array1::<u8>::zeros(cause_labels.len());
4452        let sampleweight = array![1.0_f64, 1.0, 1.0, 1.0];
4453        let x_entry = array![
4454            [1.0, age_entry[0].max(1e-8).ln()],
4455            [1.0, age_entry[1].max(1e-8).ln()],
4456            [1.0, age_entry[2].max(1e-8).ln()],
4457            [1.0, age_entry[3].max(1e-8).ln()],
4458        ];
4459        let x_exit = array![
4460            [1.0, age_exit[0].ln()],
4461            [1.0, age_exit[1].ln()],
4462            [1.0, age_exit[2].ln()],
4463            [1.0, age_exit[3].ln()],
4464        ];
4465        let x_derivative = array![
4466            [0.0, 1.0 / age_exit[0]],
4467            [0.0, 1.0 / age_exit[1]],
4468            [0.0, 1.0 / age_exit[2]],
4469            [0.0, 1.0 / age_exit[3]],
4470        ];
4471        let penalties = PenaltyBlocks::new(Vec::new());
4472        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4473
4474        // Raw cause labels {0,1,2} violate the single-hazard binary contract and
4475        // must be rejected -- but as an *actionable* `EventCodeInvalid`, NOT the
4476        // misleading `NonFiniteInput`: the labels are finite, they merely need
4477        // projecting. (The old fix left this surfacing as "non-finite".)
4478        let raw = survival_model(
4479            survival_inputs(
4480                &age_entry,
4481                &age_exit,
4482                &cause_labels,
4483                &event_competing,
4484                &sampleweight,
4485                &x_entry,
4486                &x_exit,
4487                &x_derivative,
4488            ),
4489            penalties.clone(),
4490            mono,
4491            SurvivalSpec::Net,
4492        );
4493        assert!(
4494            matches!(raw, Err(SurvivalError::EventCodeInvalid { .. })),
4495            "raw competing-risks cause labels must be rejected as EventCodeInvalid (not NonFiniteInput), got {raw:?}"
4496        );
4497
4498        // The pooled-baseline projection the workflow now performs through the
4499        // single source of truth: any observed event (any cause) -> {0, 1}.
4500        let any_event = pooled_any_event_indicator(cause_labels.view());
4501        assert_eq!(any_event, array![0u8, 1u8, 1u8, 0u8]);
4502        // And the per-cause projection that seeds each cause-specific block.
4503        assert_eq!(
4504            cause_specific_event_indicator(cause_labels.view(), 1),
4505            array![0u8, 1u8, 0u8, 0u8]
4506        );
4507        assert_eq!(
4508            cause_specific_event_indicator(cause_labels.view(), 2),
4509            array![0u8, 0u8, 1u8, 0u8]
4510        );
4511        let model = survival_model(
4512            survival_inputs(
4513                &age_entry,
4514                &age_exit,
4515                &any_event,
4516                &event_competing,
4517                &sampleweight,
4518                &x_entry,
4519                &x_exit,
4520                &x_derivative,
4521            ),
4522            penalties,
4523            mono,
4524            SurvivalSpec::Net,
4525        )
4526        .expect("pooled any-event baseline model must construct from competing-risks data");
4527
4528        // The constructed pooled baseline must yield a finite working state, so
4529        // the downstream baseline-seeding PIRLS loop has something to optimize.
4530        let beta = array![-1.0_f64, 0.8];
4531        let state = model.update_state(&beta).expect("pooled baseline state");
4532        assert!(
4533            state.deviance.is_finite(),
4534            "pooled baseline deviance must be finite, got {}",
4535            state.deviance
4536        );
4537        assert!(
4538            state.gradient.iter().all(|g| g.is_finite()),
4539            "pooled baseline gradient must be finite"
4540        );
4541    }
4542
4543    #[test]
4544    fn offset_channel_residuals_match_central_fd_of_nll() {
4545        // Three observations: two events (non-origin entry and origin entry)
4546        // and one censored row. This exercises every nonzero channel at least
4547        // once: r_exit from all rows, r_entry only from the first (has entry
4548        // interval), r_derivative only from events.
4549        let age_entry = array![0.5_f64, 0.0, 0.3];
4550        let age_exit = array![1.4_f64, 1.0, 2.0];
4551        let event_target = array![1u8, 1u8, 0u8];
4552        let event_competing = array![0u8, 0u8, 0u8];
4553        let sampleweight = array![1.0_f64, 2.5, 0.7];
4554        let x_entry = array![
4555            [1.0, age_entry[0].ln()],
4556            [1.0, age_entry[1].max(1e-8).ln()],
4557            [1.0, age_entry[2].ln()]
4558        ];
4559        let x_exit = array![
4560            [1.0, age_exit[0].ln()],
4561            [1.0, age_exit[1].ln()],
4562            [1.0, age_exit[2].ln()]
4563        ];
4564        let x_derivative = array![
4565            [0.0, 1.0 / age_exit[0]],
4566            [0.0, 1.0 / age_exit[1]],
4567            [0.0, 1.0 / age_exit[2]]
4568        ];
4569        // Baseline offsets chosen so η_entry, η_exit, s are all comfortably
4570        // away from overflow / monotonicity-violation boundaries.
4571        let o_entry = array![0.2_f64, 0.0, 0.1];
4572        let o_exit = array![0.4_f64, 0.5, 0.7];
4573        let o_deriv = array![0.3_f64, 0.8, 0.5];
4574        let penalties = PenaltyBlocks::new(Vec::new());
4575        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4576        let beta = array![-0.7_f64, 0.6];
4577
4578        let build = |o_e: &Array1<f64>, o_x: &Array1<f64>, o_d: &Array1<f64>| {
4579            survival_model_with_offsets(
4580                survival_inputs(
4581                    &age_entry,
4582                    &age_exit,
4583                    &event_target,
4584                    &event_competing,
4585                    &sampleweight,
4586                    &x_entry,
4587                    &x_exit,
4588                    &x_derivative,
4589                ),
4590                Some(SurvivalBaselineOffsets {
4591                    eta_entry: o_e.view(),
4592                    eta_exit: o_x.view(),
4593                    derivative_exit: o_d.view(),
4594                }),
4595                penalties.clone(),
4596                mono,
4597                SurvivalSpec::Net,
4598            )
4599            .expect("model build")
4600        };
4601
4602        let base = build(&o_entry, &o_exit, &o_deriv);
4603        let resid = base
4604            .offset_channel_residuals(&beta)
4605            .expect("offset residuals");
4606        assert_eq!(resid.exit.len(), 3);
4607        assert_eq!(resid.entry.len(), 3);
4608        assert_eq!(resid.derivative.len(), 3);
4609
4610        // NLL equals half the deviance returned by update_state; that is the
4611        // exact unpenalized loss whose offset partials r_{X,E,D} encode.
4612        let nll = |m: &WorkingModelSurvival| 0.5 * m.update_state(&beta).expect("state").deviance;
4613        let h = 1e-6;
4614
4615        // Row 1 (origin entry, event=1) has no entry interval, so r_entry[1]
4616        // must be exactly 0. Row 2 (censored) has r_deriv[2] exactly 0. Check
4617        // those identities before FD comparison on the nonzero elements.
4618        assert_eq!(resid.entry[1], 0.0);
4619        assert_eq!(resid.derivative[2], 0.0);
4620
4621        for i in 0..3 {
4622            // exit channel: perturb o_exit[i] alone.
4623            {
4624                let mut op = o_exit.clone();
4625                let mut om = o_exit.clone();
4626                op[i] += h;
4627                om[i] -= h;
4628                let fd = (nll(&build(&o_entry, &op, &o_deriv))
4629                    - nll(&build(&o_entry, &om, &o_deriv)))
4630                    / (2.0 * h);
4631                assert!(
4632                    (resid.exit[i] - fd).abs() < 1e-6,
4633                    "∂NLL/∂o_X[{i}]: analytic={:.6e} fd={:.6e}",
4634                    resid.exit[i],
4635                    fd
4636                );
4637            }
4638            // entry channel: only row 0 has an entry interval; for rows with
4639            // entry_at_origin the offset contributes nothing to NLL and FD
4640            // must also be exactly 0 to numerical precision.
4641            {
4642                let mut op = o_entry.clone();
4643                let mut om = o_entry.clone();
4644                op[i] += h;
4645                om[i] -= h;
4646                let fd = (nll(&build(&op, &o_exit, &o_deriv))
4647                    - nll(&build(&om, &o_exit, &o_deriv)))
4648                    / (2.0 * h);
4649                assert!(
4650                    (resid.entry[i] - fd).abs() < 1e-6,
4651                    "∂NLL/∂o_E[{i}]: analytic={:.6e} fd={:.6e}",
4652                    resid.entry[i],
4653                    fd
4654                );
4655            }
4656            // derivative channel: only event rows contribute.
4657            {
4658                let mut op = o_deriv.clone();
4659                let mut om = o_deriv.clone();
4660                op[i] += h;
4661                om[i] -= h;
4662                let fd = (nll(&build(&o_entry, &o_exit, &op))
4663                    - nll(&build(&o_entry, &o_exit, &om)))
4664                    / (2.0 * h);
4665                assert!(
4666                    (resid.derivative[i] - fd).abs() < 1e-6,
4667                    "∂NLL/∂o_D[{i}]: analytic={:.6e} fd={:.6e}",
4668                    resid.derivative[i],
4669                    fd
4670                );
4671            }
4672        }
4673    }
4674
4675    #[test]
4676    fn offset_channel_residuals_respect_zero_sampleweight() {
4677        let age_entry = array![1.0_f64, 2.0];
4678        let age_exit = array![2.0_f64, 3.5];
4679        let event_target = array![1u8, 1u8];
4680        let event_competing = array![0u8, 0u8];
4681        let sampleweight = array![0.0_f64, 1.2]; // row 0 is excluded by weight
4682        let x_entry = array![[1.0, age_entry[0].ln()], [1.0, age_entry[1].ln()]];
4683        let x_exit = array![[1.0, age_exit[0].ln()], [1.0, age_exit[1].ln()]];
4684        let x_derivative = array![[0.0, 1.0 / age_exit[0]], [0.0, 1.0 / age_exit[1]]];
4685        let penalties = PenaltyBlocks::new(Vec::new());
4686        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4687        let beta = array![-1.0_f64, 0.8];
4688
4689        let model = survival_model_with_offsets(
4690            survival_inputs(
4691                &age_entry,
4692                &age_exit,
4693                &event_target,
4694                &event_competing,
4695                &sampleweight,
4696                &x_entry,
4697                &x_exit,
4698                &x_derivative,
4699            ),
4700            Some(SurvivalBaselineOffsets {
4701                eta_entry: array![0.0_f64, 0.1].view(),
4702                eta_exit: array![0.0_f64, 0.2].view(),
4703                derivative_exit: array![0.0_f64, 0.1].view(),
4704            }),
4705            penalties,
4706            mono,
4707            SurvivalSpec::Net,
4708        )
4709        .expect("model");
4710        let r = model.offset_channel_residuals(&beta).expect("resid");
4711        // Row 0 (sampleweight=0) must contribute zero in every channel.
4712        assert_eq!(r.exit[0], 0.0);
4713        assert_eq!(r.entry[0], 0.0);
4714        assert_eq!(r.derivative[0], 0.0);
4715        // Row 1 must still carry a nonzero exit-channel residual.
4716        assert!(r.exit[1] != 0.0);
4717    }
4718
4719    #[test]
4720    fn offset_channel_residuals_reject_beta_dim_mismatch() {
4721        let age_entry = array![1.0_f64];
4722        let age_exit = array![2.0_f64];
4723        let event_target = array![1u8];
4724        let event_competing = array![0u8];
4725        let sampleweight = array![1.0_f64];
4726        let x_entry = array![[1.0, 0.0]];
4727        let x_exit = array![[1.0, 0.7]];
4728        let x_derivative = array![[0.0, 0.5]];
4729        let penalties = PenaltyBlocks::new(Vec::new());
4730        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4731        let model = survival_model(
4732            survival_inputs(
4733                &age_entry,
4734                &age_exit,
4735                &event_target,
4736                &event_competing,
4737                &sampleweight,
4738                &x_entry,
4739                &x_exit,
4740                &x_derivative,
4741            ),
4742            penalties,
4743            mono,
4744            SurvivalSpec::Net,
4745        )
4746        .expect("model");
4747        let bad_beta = array![0.0_f64]; // should be length 2
4748        let err = model
4749            .offset_channel_residuals(&bad_beta)
4750            .expect_err("mismatch must error");
4751        match err {
4752            EstimationError::InvalidInput(msg) => {
4753                assert!(msg.contains("beta dimension mismatch"), "msg={msg}")
4754            }
4755            other => panic!("expected InvalidInput, got {other:?}"),
4756        }
4757    }
4758
4759    #[test]
4760    fn crudespec_is_rejected_by_one_hazard_engine() {
4761        let age_entry = array![1.0_f64];
4762        let age_exit = array![2.0_f64];
4763        let event_target = array![0u8];
4764        let event_competing = array![1u8];
4765        let sampleweight = array![1.0];
4766        let x_entry = array![[0.1]];
4767        let x_exit = array![[0.4]];
4768        let x_derivative = array![[1.0]];
4769        let penalties = PenaltyBlocks::new(Vec::new());
4770        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4771
4772        let err = survival_model(
4773            survival_inputs(
4774                &age_entry,
4775                &age_exit,
4776                &event_target,
4777                &event_competing,
4778                &sampleweight,
4779                &x_entry,
4780                &x_exit,
4781                &x_derivative,
4782            ),
4783            penalties,
4784            mono,
4785            SurvivalSpec::Crude,
4786        )
4787        .expect_err("crude fitting should be rejected by the one-hazard engine");
4788        assert!(matches!(err, SurvivalError::UnsupportedSpec("crude")));
4789    }
4790
4791    #[test]
4792    fn nonstructural_models_require_explicit_monotonicity_collocation() {
4793        let age_entry = array![1.0_f64, 1.5_f64];
4794        let age_exit = array![2.0_f64, 2.5_f64];
4795        let event_target = array![0u8, 0u8];
4796        let event_competing = array![0u8, 1u8];
4797        let sampleweight = array![1.0, 1.0];
4798        let x_entry = array![[0.2], [0.1]];
4799        let x_exit = array![[0.3], [0.2]];
4800        let x_derivative = array![[1.0], [1.0]];
4801
4802        let model = survival_model(
4803            survival_inputs(
4804                &age_entry,
4805                &age_exit,
4806                &event_target,
4807                &event_competing,
4808                &sampleweight,
4809                &x_entry,
4810                &x_exit,
4811                &x_derivative,
4812            ),
4813            PenaltyBlocks::new(Vec::new()),
4814            SurvivalMonotonicityPenalty { tolerance: 0.0 },
4815            SurvivalSpec::Net,
4816        )
4817        .expect("construct censored survival model");
4818
4819        assert!(
4820            model.monotonicity_linear_constraints().is_none(),
4821            "non-structural survival models must not fabricate rowwise monotonicity constraints"
4822        );
4823    }
4824
4825    #[test]
4826    fn decreasing_interval_is_rejectedwithout_target_events() {
4827        let age_entry = array![1.0_f64];
4828        let age_exit = array![2.0_f64];
4829        let event_target = array![0u8];
4830        let event_competing = array![0u8];
4831        let sampleweight = array![1.0];
4832        let x_entry = array![[0.5]];
4833        let x_exit = array![[0.0]];
4834        let x_derivative = array![[1.0]];
4835
4836        let model = survival_model(
4837            survival_inputs(
4838                &age_entry,
4839                &age_exit,
4840                &event_target,
4841                &event_competing,
4842                &sampleweight,
4843                &x_entry,
4844                &x_exit,
4845                &x_derivative,
4846            ),
4847            PenaltyBlocks::new(Vec::new()),
4848            SurvivalMonotonicityPenalty { tolerance: 0.0 },
4849            SurvivalSpec::Net,
4850        )
4851        .expect("construct censored survival model");
4852
4853        let err = model
4854            .update_state(&array![1.0])
4855            .expect_err("decreasing cumulative hazard increment should be rejected");
4856        assert!(
4857            err.to_string().contains("cumulative hazard decreased"),
4858            "unexpected error: {err}"
4859        );
4860    }
4861
4862    fn smooth_crude_risk(beta_d: f64, beta_m: f64) -> CrudeRiskResult {
4863        calculate_crude_risk_quadrature(
4864            0.0,
4865            1.0,
4866            &[0.0, 1.0],
4867            beta_d.exp(),
4868            beta_m.exp(),
4869            array![1.0].view(),
4870            array![1.0].view(),
4871            |u, design_d, deriv_d, design_m| {
4872                let cumulative_d = beta_d.exp() * (1.0 + 0.2 * u);
4873                let cumulative_m = beta_m.exp() * (1.0 + 0.1 * u);
4874                let inst_hazard_d = 0.2 * beta_d.exp();
4875                design_d[0] = 1.0;
4876                // η_d = β_d + ln(1 + 0.2u), so η̇_d = 0.2/(1+0.2u)
4877                // which does not depend on β_d → ∂_{β_d} η̇_d = 0
4878                deriv_d[0] = 0.0;
4879                design_m[0] = 1.0;
4880                Ok((inst_hazard_d, cumulative_d, cumulative_m))
4881            },
4882        )
4883        .expect("smooth crude-risk quadrature should succeed")
4884    }
4885
4886    #[test]
4887    fn crude_riskgradient_matches_monotoneobjective() {
4888        let beta_d = -0.2_f64;
4889        let beta_m = -0.5_f64;
4890        let result = smooth_crude_risk(beta_d, beta_m);
4891        let eps = 1e-6;
4892
4893        let fd_d = (smooth_crude_risk(beta_d + eps, beta_m).risk
4894            - smooth_crude_risk(beta_d - eps, beta_m).risk)
4895            / (2.0 * eps);
4896        let fd_m = (smooth_crude_risk(beta_d, beta_m + eps).risk
4897            - smooth_crude_risk(beta_d, beta_m - eps).risk)
4898            / (2.0 * eps);
4899
4900        assert!(
4901            (result.diseasegradient[0] - fd_d).abs() < 1e-5,
4902            "disease gradient mismatch for monotone crude risk: analytic={} fd={fd_d}",
4903            result.diseasegradient[0]
4904        );
4905        assert!(
4906            (result.mortalitygradient[0] - fd_m).abs() < 1e-5,
4907            "mortality gradient mismatch for monotone crude risk: analytic={} fd={fd_m}",
4908            result.mortalitygradient[0]
4909        );
4910    }
4911
4912    #[test]
4913    fn survival_working_state_is_ridge_free() {
4914        let age_entry = array![1.0_f64, 2.0_f64];
4915        let age_exit = array![2.0_f64, 3.5_f64];
4916        let event_target = array![1u8, 0u8];
4917        let event_competing = array![0u8, 0u8];
4918        let sampleweight = array![1.0, 1.0];
4919        let x_entry = array![[1.0, age_entry[0].ln()], [1.0, age_entry[1].ln()]];
4920        let x_exit = array![[1.0, age_exit[0].ln()], [1.0, age_exit[1].ln()]];
4921        let x_derivative = array![[0.0, 1.0 / age_exit[0]], [0.0, 1.0 / age_exit[1]]];
4922        let penalties = PenaltyBlocks::new(vec![PenaltyBlock {
4923            matrix: array![[2.0]],
4924            lambda: 1.7,
4925            range: 1..2,
4926            nullspace_dim: 0,
4927        }]);
4928        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
4929        let beta = array![-1.2, 0.4];
4930
4931        let model = survival_model(
4932            survival_inputs(
4933                &age_entry,
4934                &age_exit,
4935                &event_target,
4936                &event_competing,
4937                &sampleweight,
4938                &x_entry,
4939                &x_exit,
4940                &x_derivative,
4941            ),
4942            penalties.clone(),
4943            mono,
4944            SurvivalSpec::Net,
4945        )
4946        .expect("construct survival model");
4947
4948        let state = model.update_state(&beta).expect("survival state");
4949        assert_eq!(
4950            state.ridge_used, 0.0,
4951            "survival objective must not fuse a coefficient ridge"
4952        );
4953        // `penalty_term` is the full quadratic βᵀSβ = 2·(½βᵀSβ energy) (#2301 defect B).
4954        let expected_penalty = 2.0 * penalties.deviance(&beta);
4955        assert!(
4956            (state.penalty_term - expected_penalty).abs() < 1e-12,
4957            "penalty_term mismatch: state={} expected={}",
4958            state.penalty_term,
4959            expected_penalty
4960        );
4961    }
4962
4963    #[test]
4964    fn negative_penalty_lambda_is_rejected() {
4965        let age_entry = array![1.0_f64];
4966        let age_exit = array![2.0_f64];
4967        let event_target = array![1u8];
4968        let event_competing = array![0u8];
4969        let sampleweight = array![1.0];
4970        let x_entry = array![[1.0, 0.0]];
4971        let x_exit = array![[1.0, 0.5]];
4972        let x_derivative = array![[0.0, 1.0]];
4973        let penalties = PenaltyBlocks::new(vec![PenaltyBlock {
4974            matrix: array![[1.0]],
4975            lambda: -0.1,
4976            range: 1..2,
4977            nullspace_dim: 0,
4978        }]);
4979
4980        let err = survival_model(
4981            survival_inputs(
4982                &age_entry,
4983                &age_exit,
4984                &event_target,
4985                &event_competing,
4986                &sampleweight,
4987                &x_entry,
4988                &x_exit,
4989                &x_derivative,
4990            ),
4991            penalties,
4992            SurvivalMonotonicityPenalty { tolerance: 0.0 },
4993            SurvivalSpec::Net,
4994        )
4995        .expect_err("negative lambda must be rejected");
4996
4997        assert!(matches!(err, SurvivalError::NonFiniteInput));
4998    }
4999
5000    #[test]
5001    fn penalty_block_range_and_shapemust_match_coefficients() {
5002        let age_entry = array![1.0_f64];
5003        let age_exit = array![2.0_f64];
5004        let event_target = array![1u8];
5005        let event_competing = array![0u8];
5006        let sampleweight = array![1.0];
5007        let x_entry = array![[1.0, 0.0]];
5008        let x_exit = array![[1.0, 0.5]];
5009        let x_derivative = array![[0.0, 1.0]];
5010        let penalties = PenaltyBlocks::new(vec![PenaltyBlock {
5011            matrix: array![[1.0]],
5012            lambda: 0.5,
5013            range: 0..2,
5014            nullspace_dim: 0,
5015        }]);
5016
5017        let err = survival_model(
5018            survival_inputs(
5019                &age_entry,
5020                &age_exit,
5021                &event_target,
5022                &event_competing,
5023                &sampleweight,
5024                &x_entry,
5025                &x_exit,
5026                &x_derivative,
5027            ),
5028            penalties,
5029            SurvivalMonotonicityPenalty { tolerance: 1e-8 },
5030            SurvivalSpec::Net,
5031        )
5032        .expect_err("penalty block geometry must match coefficient support");
5033
5034        assert!(matches!(err, SurvivalError::DimensionMismatch));
5035    }
5036
5037    #[test]
5038    fn survivalgradient_matches_ridge_free_objective_fd() {
5039        let age_entry = array![1.0_f64, 2.0_f64, 3.0_f64];
5040        let age_exit = array![2.0_f64, 3.5_f64, 4.0_f64];
5041        let event_target = array![1u8, 0u8, 1u8];
5042        let event_competing = array![0u8, 0u8, 0u8];
5043        let sampleweight = array![1.0, 1.0, 1.0];
5044        let x_entry = array![
5045            [1.0, age_entry[0].ln()],
5046            [1.0, age_entry[1].ln()],
5047            [1.0, age_entry[2].ln()]
5048        ];
5049        let x_exit = array![
5050            [1.0, age_exit[0].ln()],
5051            [1.0, age_exit[1].ln()],
5052            [1.0, age_exit[2].ln()]
5053        ];
5054        let x_derivative = array![
5055            [0.0, 1.0 / age_exit[0]],
5056            [0.0, 1.0 / age_exit[1]],
5057            [0.0, 1.0 / age_exit[2]]
5058        ];
5059        let penalties = PenaltyBlocks::new(Vec::new());
5060        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5061        let beta = array![-1.0, 3.0];
5062
5063        let model = survival_model(
5064            survival_inputs(
5065                &age_entry,
5066                &age_exit,
5067                &event_target,
5068                &event_competing,
5069                &sampleweight,
5070                &x_entry,
5071                &x_exit,
5072                &x_derivative,
5073            ),
5074            penalties,
5075            mono,
5076            SurvivalSpec::Net,
5077        )
5078        .expect("construct survival model");
5079
5080        let state = model.update_state(&beta).expect("state at beta");
5081        let eps = 1e-7;
5082        for j in 0..beta.len() {
5083            let mut plus = beta.clone();
5084            let mut minus = beta.clone();
5085            plus[j] += eps;
5086            minus[j] -= eps;
5087            let state_plus = model.update_state(&plus).expect("state at beta + eps");
5088            let state_minus = model.update_state(&minus).expect("state at beta - eps");
5089            let obj_plus = 0.5 * (state_plus.deviance + state_plus.penalty_term);
5090            let obj_minus = 0.5 * (state_minus.deviance + state_minus.penalty_term);
5091            let fd = (obj_plus - obj_minus) / (2.0 * eps);
5092            assert_eq!(
5093                state.gradient[j].signum(),
5094                fd.signum(),
5095                "objective/gradient sign mismatch at j={j}: grad={} fd={fd}",
5096                state.gradient[j]
5097            );
5098            assert!(
5099                (state.gradient[j] - fd).abs() < 1e-5,
5100                "objective/gradient mismatch at j={j}: grad={} fd={fd}",
5101                state.gradient[j]
5102            );
5103        }
5104    }
5105
5106    fn laml_fd_test_model(lambda: f64) -> WorkingModelSurvival {
5107        // 20-subject survival fixture with mean-centered log-age time
5108        // covariate, balanced events/censorings, and moderate hazard levels.
5109        // The fixture is large enough that the observed-information Hessian
5110        // is well-conditioned at the MLE so PIRLS reaches the 1e-10 KKT
5111        // tolerance in well under 80 iterations from the starting beta used
5112        // below.
5113        let age_entry: Array1<f64> = Array1::from(vec![
5114            30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 32.0, 37.0, 42.0, 47.0, 52.0, 57.0, 62.0,
5115            34.0, 39.0, 44.0, 49.0, 54.0, 59.0,
5116        ]);
5117        let age_exit: Array1<f64> = Array1::from(vec![
5118            45.0, 48.0, 55.0, 58.0, 62.0, 66.0, 68.0, 47.0, 52.0, 53.0, 55.0, 60.0, 63.0, 70.0,
5119            48.0, 51.0, 58.0, 62.0, 66.0, 69.0,
5120        ]);
5121        let event_target = Array1::from(vec![
5122            1u8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0,
5123        ]);
5124        let event_competing = Array1::<u8>::zeros(age_entry.len());
5125        let sampleweight = Array1::from_elem(age_entry.len(), 1.0_f64);
5126        let n = age_entry.len();
5127        let ln_age_mean: f64 = {
5128            let mut sum = 0.0;
5129            for i in 0..n {
5130                sum += age_entry[i].ln() + age_exit[i].ln();
5131            }
5132            sum / (2.0 * n as f64)
5133        };
5134        let mut x_entry = Array2::<f64>::zeros((n, 2));
5135        let mut x_exit = Array2::<f64>::zeros((n, 2));
5136        let mut x_derivative = Array2::<f64>::zeros((n, 2));
5137        for i in 0..n {
5138            x_entry[[i, 0]] = 1.0;
5139            x_exit[[i, 0]] = 1.0;
5140            x_entry[[i, 1]] = age_entry[i].ln() - ln_age_mean;
5141            x_exit[[i, 1]] = age_exit[i].ln() - ln_age_mean;
5142            x_derivative[[i, 0]] = 0.0;
5143            x_derivative[[i, 1]] = 1.0 / age_exit[i];
5144        }
5145        let penalties = PenaltyBlocks::new(vec![
5146            PenaltyBlock {
5147                matrix: array![[3.0]],
5148                lambda: 0.0,
5149                range: 0..1,
5150                nullspace_dim: 0,
5151            },
5152            PenaltyBlock {
5153                matrix: array![[2.5]],
5154                lambda,
5155                range: 1..2,
5156                nullspace_dim: 0,
5157            },
5158        ]);
5159        survival_model(
5160            survival_inputs(
5161                &age_entry,
5162                &age_exit,
5163                &event_target,
5164                &event_competing,
5165                &sampleweight,
5166                &x_entry,
5167                &x_exit,
5168                &x_derivative,
5169            ),
5170            penalties,
5171            SurvivalMonotonicityPenalty { tolerance: 1e-8 },
5172            SurvivalSpec::Net,
5173        )
5174        .expect("construct LAML FD survival model")
5175    }
5176
5177    fn laml_test_logdet_h(state: &WorkingState) -> f64 {
5178        use gam_problem::PseudoLogdetMode;
5179        use gam_solve::estimate::reml::reml_outer_engine::{
5180            DenseSpectralOperator, HessianFactorization,
5181        };
5182
5183        DenseSpectralOperator::from_symmetric_with_mode(
5184            &state.hessian.to_dense(),
5185            PseudoLogdetMode::PositiveDefinite,
5186        )
5187        .expect("positive-definite fitted survival Hessian")
5188        .logdet()
5189    }
5190
5191    /// Decompose the survival LAML rho chain rule into independently
5192    /// finite-differentiable identities. This prevents a value/gradient failure
5193    /// from being "fixed" by changing a sign at the final trace: the mode
5194    /// response, likelihood-Hessian directional derivative, and total Hessian
5195    /// drift must each agree before their scalar contraction is trusted.
5196    #[test]
5197    fn survival_laml_mode_response_and_hessian_drift_match_finite_differences() {
5198        use gam_linalg::faer_ndarray::FaerCholesky;
5199        use gam_problem::PseudoLogdetMode;
5200        use gam_solve::estimate::reml::reml_outer_engine::{
5201            DenseSpectralOperator, HessianFactorization,
5202        };
5203
5204        const RHO: f64 = 4.0;
5205        const RHO_STEP: f64 = 1.0e-5;
5206        const BETA_STEP: f64 = 1.0e-5;
5207        const REL_TOL: f64 = 2.0e-4;
5208
5209        let beta0 = array![-2.5_f64, 1.0];
5210        let model = laml_fd_test_model(1.0);
5211        let (center_model, beta_hat) = model
5212            .reconverge_survival_inner_mode(&[RHO], &beta0)
5213            .expect("reconverge survival mode at rho=4");
5214        let center_state = center_model
5215            .update_state(&beta_hat)
5216            .expect("center survival state");
5217        let h_center = center_state.hessian.to_dense();
5218        let p = beta_hat.len();
5219
5220        // A = dS/d rho = lambda S for the single active penalty block.
5221        let active: Vec<&PenaltyBlock> = center_model
5222            .penalties
5223            .blocks
5224            .iter()
5225            .filter(|block| block.lambda > 0.0)
5226            .collect();
5227        assert_eq!(active.len(), 1, "fixture must have one active penalty block");
5228        let block = active[0];
5229        let mut a = Array2::<f64>::zeros((p, p));
5230        for i in 0..block.matrix.nrows() {
5231            for j in 0..block.matrix.ncols() {
5232                a[[block.range.start + i, block.range.start + j]] =
5233                    block.lambda * block.matrix[[i, j]];
5234            }
5235        }
5236
5237        // IFT identity: beta_rho = -H^-1 A beta.
5238        let factor = h_center
5239            .cholesky(faer::Side::Lower)
5240            .expect("center Hessian Cholesky");
5241        let v = factor.solvevec(&a.dot(&beta_hat));
5242        let u = -&v;
5243        let (plus_model, beta_plus) = model
5244            .reconverge_survival_inner_mode(&[RHO + RHO_STEP], &beta_hat)
5245            .expect("rho-plus survival mode");
5246        let (minus_model, beta_minus) = model
5247            .reconverge_survival_inner_mode(&[RHO - RHO_STEP], &beta_hat)
5248            .expect("rho-minus survival mode");
5249        let beta_fd = (&beta_plus - &beta_minus) / (2.0 * RHO_STEP);
5250
5251        // Family identity: C = D_beta H_lik[u]. The penalty is fixed in this
5252        // beta-direction FD, so it cancels from H(beta+h u)-H(beta-h u).
5253        let correction = center_model
5254            .survival_hessian_derivative_correction(&beta_hat, &u)
5255            .expect("analytic survival Hessian correction");
5256        let beta_dir_plus = &beta_hat + &u.mapv(|value| BETA_STEP * value);
5257        let beta_dir_minus = &beta_hat - &u.mapv(|value| BETA_STEP * value);
5258        let h_beta_plus = center_model
5259            .update_state(&beta_dir_plus)
5260            .expect("beta-direction plus state")
5261            .hessian
5262            .to_dense();
5263        let h_beta_minus = center_model
5264            .update_state(&beta_dir_minus)
5265            .expect("beta-direction minus state")
5266            .hessian
5267            .to_dense();
5268        let correction_fd = (&h_beta_plus - &h_beta_minus) / (2.0 * BETA_STEP);
5269
5270        // Total identity along the re-converged surface: H_rho = A + C.
5271        let state_plus = plus_model
5272            .update_state(&beta_plus)
5273            .expect("rho-plus state");
5274        let state_minus = minus_model
5275            .update_state(&beta_minus)
5276            .expect("rho-minus state");
5277        let total_fd =
5278            (state_plus.hessian.to_dense() - state_minus.hessian.to_dense()) / (2.0 * RHO_STEP);
5279        let total_analytic = &a + &correction;
5280        let total_sign_reversed = &a - &correction;
5281
5282        let relative_vector_error = |actual: &Array1<f64>, expected: &Array1<f64>| {
5283            let difference = actual
5284                .iter()
5285                .zip(expected.iter())
5286                .map(|(&lhs, &rhs)| (lhs - rhs) * (lhs - rhs))
5287                .sum::<f64>()
5288                .sqrt();
5289            let scale = expected.iter().map(|value| value * value).sum::<f64>().sqrt();
5290            difference / scale.max(1.0e-12)
5291        };
5292        let relative_matrix_error = |actual: &Array2<f64>, expected: &Array2<f64>| {
5293            let difference = actual
5294                .iter()
5295                .zip(expected.iter())
5296                .map(|(&lhs, &rhs)| (lhs - rhs) * (lhs - rhs))
5297                .sum::<f64>()
5298                .sqrt();
5299            let scale = expected.iter().map(|value| value * value).sum::<f64>().sqrt();
5300            difference / scale.max(1.0e-12)
5301        };
5302
5303        let mode_error = relative_vector_error(&u, &beta_fd);
5304        let correction_error = relative_matrix_error(&correction, &correction_fd);
5305        let correction_reversed_error = relative_matrix_error(&(-&correction), &correction_fd);
5306        let total_error = relative_matrix_error(&total_analytic, &total_fd);
5307        let total_reversed_error = relative_matrix_error(&total_sign_reversed, &total_fd);
5308
5309        // Scalar value decomposition names which LAML term moves on the same
5310        // rho perturbation: T1 = 0.5(deviance+penalty), T2 = 0.5 log|H|,
5311        // T3 = -0.5 log|lambda S|_+ (rank one, hence derivative -0.5).
5312        let t1_plus = 0.5 * (state_plus.deviance + state_plus.penalty_term);
5313        let t1_minus = 0.5 * (state_minus.deviance + state_minus.penalty_term);
5314        let t1_fd = (t1_plus - t1_minus) / (2.0 * RHO_STEP);
5315        let t2_fd = 0.5
5316            * (laml_test_logdet_h(&state_plus) - laml_test_logdet_h(&state_minus))
5317            / (2.0 * RHO_STEP);
5318        let t3_fd = -0.5_f64;
5319
5320        // Contract the already-verified A and C matrices through the exact
5321        // positive-definite kernel used by production. The operator's solve is
5322        // the same full-space IFT solve certified above, so an identifiable
5323        // low-curvature direction cannot disappear between value and gradient.
5324        let positive_hop = DenseSpectralOperator::from_symmetric_with_mode(
5325            &h_center,
5326            PseudoLogdetMode::PositiveDefinite,
5327        )
5328        .expect("positive-definite operator at the fitted survival mode");
5329        let half_trace_a = 0.5 * positive_hop.trace_hinv_product(&a);
5330        let half_trace_c = 0.5 * positive_hop.trace_hinv_product(&correction);
5331        let t1_analytic = 0.5 * beta_hat.dot(&a.dot(&beta_hat));
5332        let expected_gradient = t1_analytic + half_trace_a + half_trace_c - 0.5;
5333        let rho = array![RHO];
5334        let (_, public_gradient) = center_model
5335            .unified_lamlobjective_and_rhogradient(&beta_hat, &center_state, &rho)
5336            .expect("public survival LAML gradient at fitted mode");
5337
5338        eprintln!(
5339            "survival rho-chain decomposition: mode_error={mode_error:.6e} \
5340             correction_error={correction_error:.6e} correction_reversed_error={correction_reversed_error:.6e} \
5341             total_error={total_error:.6e} total_reversed_error={total_reversed_error:.6e} \
5342             t1_fd={t1_fd:+.12e} t2_fd={t2_fd:+.12e} t3_fd={t3_fd:+.12e} \
5343             t1_analytic={t1_analytic:+.12e} half_trace_a={half_trace_a:+.12e} \
5344             half_trace_c={half_trace_c:+.12e} expected_gradient={expected_gradient:+.12e} \
5345             public_gradient={:?} beta_analytic={:?} beta_fd={:?} correction={:?} correction_fd={:?} \
5346             total_analytic={:?} total_fd={:?}",
5347            public_gradient.to_vec(),
5348            u.to_vec(),
5349            beta_fd.to_vec(),
5350            correction,
5351            correction_fd,
5352            total_analytic,
5353            total_fd,
5354        );
5355
5356        let public_gradient_error = (public_gradient[0] - expected_gradient).abs()
5357            / expected_gradient.abs().max(1.0);
5358        assert!(
5359            mode_error <= REL_TOL
5360                && correction_error <= REL_TOL
5361                && total_error <= REL_TOL
5362                && public_gradient_error <= REL_TOL,
5363            "survival rho chain-rule identity failed: mode_error={mode_error:.6e}, \
5364             correction_error={correction_error:.6e} (sign-reversed={correction_reversed_error:.6e}), \
5365             total_error={total_error:.6e} (sign-reversed={total_reversed_error:.6e}), \
5366             public_gradient_error={public_gradient_error:.6e}"
5367        );
5368    }
5369
5370    /// Two-ACTIVE-block variant of [`laml_fd_test_model`] (both penalty blocks
5371    /// carry `λ > 0`), so the active-ρ vector is 2-dimensional. This lets the
5372    /// FD gate probe the survival-transformation outer stall's structure — one
5373    /// coordinate driven to the over-smoothing rail, the other free — which the
5374    /// existing single-active-block interior FD tests never reach (#2298 / task
5375    /// #12).
5376    fn laml_rail_fd_test_model(lambda0: f64, lambda1: f64) -> WorkingModelSurvival {
5377        let age_entry: Array1<f64> = Array1::from(vec![
5378            30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 32.0, 37.0, 42.0, 47.0, 52.0, 57.0, 62.0,
5379            34.0, 39.0, 44.0, 49.0, 54.0, 59.0,
5380        ]);
5381        let age_exit: Array1<f64> = Array1::from(vec![
5382            45.0, 48.0, 55.0, 58.0, 62.0, 66.0, 68.0, 47.0, 52.0, 53.0, 55.0, 60.0, 63.0, 70.0,
5383            48.0, 51.0, 58.0, 62.0, 66.0, 69.0,
5384        ]);
5385        let event_target = Array1::from(vec![
5386            1u8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0,
5387        ]);
5388        let event_competing = Array1::<u8>::zeros(age_entry.len());
5389        let sampleweight = Array1::from_elem(age_entry.len(), 1.0_f64);
5390        let n = age_entry.len();
5391        let ln_age_mean: f64 = {
5392            let mut sum = 0.0;
5393            for i in 0..n {
5394                sum += age_entry[i].ln() + age_exit[i].ln();
5395            }
5396            sum / (2.0 * n as f64)
5397        };
5398        let mut x_entry = Array2::<f64>::zeros((n, 2));
5399        let mut x_exit = Array2::<f64>::zeros((n, 2));
5400        let mut x_derivative = Array2::<f64>::zeros((n, 2));
5401        for i in 0..n {
5402            x_entry[[i, 0]] = 1.0;
5403            x_exit[[i, 0]] = 1.0;
5404            x_entry[[i, 1]] = age_entry[i].ln() - ln_age_mean;
5405            x_exit[[i, 1]] = age_exit[i].ln() - ln_age_mean;
5406            x_derivative[[i, 0]] = 0.0;
5407            x_derivative[[i, 1]] = 1.0 / age_exit[i];
5408        }
5409        let penalties = PenaltyBlocks::new(vec![
5410            PenaltyBlock {
5411                matrix: array![[3.0]],
5412                lambda: lambda0,
5413                range: 0..1,
5414                nullspace_dim: 0,
5415            },
5416            PenaltyBlock {
5417                matrix: array![[2.5]],
5418                lambda: lambda1,
5419                range: 1..2,
5420                nullspace_dim: 0,
5421            },
5422        ]);
5423        survival_model(
5424            survival_inputs(
5425                &age_entry,
5426                &age_exit,
5427                &event_target,
5428                &event_competing,
5429                &sampleweight,
5430                &x_entry,
5431                &x_exit,
5432                &x_derivative,
5433            ),
5434            penalties,
5435            SurvivalMonotonicityPenalty { tolerance: 1e-8 },
5436            SurvivalSpec::Net,
5437        )
5438        .expect("construct two-active-block rail LAML FD model")
5439    }
5440
5441    /// Rail-regime FD arbiter for the survival-transformation outer stall
5442    /// (#2298 / task #12). The outer BFGS dies at `rho_checkpoint[0] ≈ 7.3948`
5443    /// with `|Pg|=0.34` on the FREE coordinate, and the objective is gradient-
5444    /// only (no analytic outer Hessian), so a value↔gradient desync there is
5445    /// uncertifiable. This gate central-differences the analytic ρ-gradient at
5446    /// that exact railed checkpoint and asserts PER COORDINATE, so `rho[0]`
5447    /// (railed) and `rho[1]` (free, the suspected leaker) are separately visible.
5448    ///
5449    /// Contract notes:
5450    /// * NO frozen θ̂ cache: `evaluate_survival_lamlcost_and_gradient` re-converges
5451    ///   β̂(ρ) internally at every ρ±h, so each FD leg is a full re-fit.
5452    /// * The neighbour-ρ evaluations double as the A-vs-B discriminator for the
5453    ///   stall: an inner-solve `Err` at a probe ρ panics the `.expect` below
5454    ///   (⇒ line search starved by inner Errs), whereas a finite-but-wrong FD
5455    ///   trips the per-coordinate assert (⇒ outer LAML value↔gradient desync).
5456    /// * The inner Hessian conditioning is printed to split hypothesis (a) an
5457    ///   envelope/∂β∂ρ leak at ill-conditioned H from (b) a logdet value/deriv
5458    ///   split. The per-term FD grid (½·d(dev+pen), ½·dlogdetH, −½·dlogdetS)
5459    ///   names which term carries the disagreement.
5460    ///
5461    /// Scope: this fixture uses full-rank 1×1 penalties, so if it passes with a
5462    /// well-conditioned inner H, the real desync needs the ISpline 2nd-difference
5463    /// nullspace structure of the transformation baseline — which this gate then
5464    /// localizes to the outer term rather than the inner mode.
5465    #[test]
5466    fn survival_laml_rho_gradient_matches_fd_at_the_over_smoothing_rail() {
5467        use gam_linalg::faer_ndarray::FaerEigh;
5468
5469        const RAIL_RHO0: f64 = 7.394829814011909;
5470        const FREE_RHO1: f64 = -2.45;
5471        const FD_STEP: f64 = 1.0e-4;
5472
5473        let beta0 = array![-2.5_f64, 1.0];
5474        let rho = array![RAIL_RHO0, FREE_RHO1];
5475        let model = laml_rail_fd_test_model(RAIL_RHO0.exp(), FREE_RHO1.exp());
5476
5477        // Analytic value + ρ-gradient at the re-converged inner mode.
5478        let (value, analytic) = model
5479            .evaluate_survival_lamlcost_and_gradient(
5480                rho.as_slice().expect("contiguous rho"),
5481                &beta0,
5482            )
5483            .expect("rail LAML analytic value+gradient (inner solve must converge at the rail)");
5484
5485        // Inner-Hessian conditioning at the railed checkpoint (splits a vs b).
5486        let (rail_model, beta_hat) = model
5487            .reconverge_survival_inner_mode(rho.as_slice().expect("contiguous rho"), &beta0)
5488            .expect("reconverge inner mode at the rail");
5489        let state = rail_model
5490            .update_state(&beta_hat)
5491            .expect("inner state at the rail");
5492        let h_dense = state.hessian.to_dense();
5493        let (evals, _) = h_dense.eigh(faer::Side::Lower).expect("eigh at rail");
5494        let min_ev = evals.iter().copied().fold(f64::INFINITY, f64::min);
5495        let max_ev = evals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
5496        let cond = max_ev / min_ev.abs().max(f64::MIN_POSITIVE);
5497
5498        // Per-term VALUE finite differences, so a red run names the culprit term
5499        // without a second round. T1 = ½(dev+pen), T2 = ½·logdetH, T3 = −½·logdetS.
5500        let term_values = |r: &Array1<f64>| -> (f64, f64, f64) {
5501            let (cand, b) = model
5502                .reconverge_survival_inner_mode(r.as_slice().expect("contiguous rho"), &beta0)
5503                .expect("reconverge for per-term FD");
5504            let st = cand.update_state(&b).expect("state for per-term FD");
5505            let t1 = 0.5 * (st.deviance + st.penalty_term);
5506            let t2 = 0.5 * laml_test_logdet_h(&st);
5507            // Active 1×1 blocks: log|λ_k S_k| = ρ_k + ln(matrix_k). rank each = 1.
5508            let t3 = -0.5 * (r[0] + 3.0_f64.ln() + r[1] + 2.5_f64.ln());
5509            (t1, t2, t3)
5510        };
5511
5512        let mut fd = vec![0.0_f64; rho.len()];
5513        let mut fd_terms = vec![(0.0_f64, 0.0_f64, 0.0_f64); rho.len()];
5514        for j in 0..rho.len() {
5515            let mut plus = rho.clone();
5516            plus[j] += FD_STEP;
5517            let mut minus = rho.clone();
5518            minus[j] -= FD_STEP;
5519            let fp = model
5520                .evaluate_survival_lamlcost_and_gradient(
5521                    plus.as_slice().expect("contiguous rho"),
5522                    &beta0,
5523                )
5524                .expect("rail LAML f+ (probe ρ inner solve must converge)")
5525                .0;
5526            let fm = model
5527                .evaluate_survival_lamlcost_and_gradient(
5528                    minus.as_slice().expect("contiguous rho"),
5529                    &beta0,
5530                )
5531                .expect("rail LAML f- (probe ρ inner solve must converge)")
5532                .0;
5533            fd[j] = (fp - fm) / (2.0 * FD_STEP);
5534            let (p1, p2, p3) = term_values(&plus);
5535            let (m1, m2, m3) = term_values(&minus);
5536            fd_terms[j] = (
5537                (p1 - m1) / (2.0 * FD_STEP),
5538                (p2 - m2) / (2.0 * FD_STEP),
5539                (p3 - m3) / (2.0 * FD_STEP),
5540            );
5541        }
5542
5543        eprintln!(
5544            "[RAIL-FD] rho=[{RAIL_RHO0:.9}, {FREE_RHO1}] value={value:.9} inner_H min_ev={min_ev:.3e} max_ev={max_ev:.3e} cond={cond:.3e}"
5545        );
5546        // (a2) amplification witness (#2298): the logdet ρ-gradient is the tiny
5547        // difference of two ~rank-magnitude traces (`half_dlogdetH ≈ −neg_half_dlogdetS`),
5548        // so `max(|canceling term|)/|gradient|` is the catastrophic-cancellation
5549        // conditioning number that makes |Pg| host-arithmetic-dependent at the rail.
5550        // Recording it here (pre-fix ~2e4 on the railed coordinate) means the fused-
5551        // trace reformulation's round will show this DROP to O(1) as a witness diff.
5552        let mut amplification = vec![0.0_f64; rho.len()];
5553        for j in 0..rho.len() {
5554            let (dt1, dt2, dt3) = fd_terms[j];
5555            amplification[j] = dt2.abs().max(dt3.abs()) / analytic[j].abs().max(f64::MIN_POSITIVE);
5556            let amp = amplification[j];
5557            eprintln!(
5558                "[RAIL-FD] rho[{j}]: analytic_total={:.6e} fd_total={:.6e} abs_err={:.3e} amplification={amp:.3e} | fd_terms: half_d(dev+pen)={dt1:.6e} half_dlogdetH={dt2:.6e} neg_half_dlogdetS={dt3:.6e}",
5559                analytic[j],
5560                fd[j],
5561                (analytic[j] - fd[j]).abs()
5562            );
5563        }
5564
5565        for j in 0..rho.len() {
5566            let tol = 1.0e-4 * (1.0 + analytic[j].abs().max(fd[j].abs()));
5567            assert!(
5568                (analytic[j] - fd[j]).abs() <= tol,
5569                "survival LAML ρ-gradient desync at coordinate {j} in the over-smoothing rail regime: \
5570                 analytic={:.6e} fd={:.6e} (inner H cond={cond:.3e}); see the per-term [RAIL-FD] grid above",
5571                analytic[j],
5572                fd[j],
5573            );
5574        }
5575
5576        // Generous ceiling for now (no O(1) threshold until the fused-trace fix
5577        // lands); this only records that the amplification is finite/bounded on a
5578        // green gate. The railed coordinate is expected here to be ~2e4 pre-fix.
5579        let max_amplification = amplification
5580            .iter()
5581            .copied()
5582            .fold(0.0_f64, f64::max);
5583        assert!(
5584            max_amplification.is_finite() && max_amplification <= 1.0e8,
5585            "rail logdet-gradient amplification ratio not finite/bounded: {max_amplification:.3e}"
5586        );
5587    }
5588
5589    /// Interior-ρ companion to the rail gate (#2298). Away from the over-smoothing
5590    /// rail the two logdet traces do NOT cancel, so the analytic ρ-gradient must
5591    /// match FD tightly and the amplification is O(1). This is the value baseline
5592    /// the (a2) fused-trace reformulation must PRESERVE exactly — the reformulation
5593    /// changes only rounding at the rail, never the derivative value — so post-fix
5594    /// this doubles as the old-vs-new equality anchor at a non-cancelling point.
5595    #[test]
5596    fn survival_laml_rho_gradient_matches_fd_at_interior_rho() {
5597        const INTERIOR_RHO0: f64 = 0.3;
5598        const INTERIOR_RHO1: f64 = -0.5;
5599        const FD_STEP: f64 = 1.0e-4;
5600
5601        let beta0 = array![-2.5_f64, 1.0];
5602        let rho = array![INTERIOR_RHO0, INTERIOR_RHO1];
5603        let model = laml_rail_fd_test_model(INTERIOR_RHO0.exp(), INTERIOR_RHO1.exp());
5604        let (_value, analytic) = model
5605            .evaluate_survival_lamlcost_and_gradient(
5606                rho.as_slice().expect("contiguous rho"),
5607                &beta0,
5608            )
5609            .expect("interior LAML analytic value+gradient");
5610
5611        for j in 0..rho.len() {
5612            let mut plus = rho.clone();
5613            plus[j] += FD_STEP;
5614            let mut minus = rho.clone();
5615            minus[j] -= FD_STEP;
5616            let fp = model
5617                .evaluate_survival_lamlcost_and_gradient(
5618                    plus.as_slice().expect("contiguous rho"),
5619                    &beta0,
5620                )
5621                .expect("interior LAML f+")
5622                .0;
5623            let fm = model
5624                .evaluate_survival_lamlcost_and_gradient(
5625                    minus.as_slice().expect("contiguous rho"),
5626                    &beta0,
5627                )
5628                .expect("interior LAML f-")
5629                .0;
5630            let fd = (fp - fm) / (2.0 * FD_STEP);
5631            let tol = 1.0e-4 * (1.0 + analytic[j].abs().max(fd.abs()));
5632            assert!(
5633                (analytic[j] - fd).abs() <= tol,
5634                "interior survival LAML ρ-gradient mismatch at coordinate {j}: \
5635                 analytic={:.6e} fd={:.6e}",
5636                analytic[j],
5637                fd,
5638            );
5639        }
5640    }
5641
5642    #[test]
5643    fn survival_solver_damping_converges_undamped_objective() {
5644        let rho = -0.35_f64;
5645        let model = laml_fd_test_model(rho.exp());
5646        let beta0 = array![-2.5_f64, 1.0];
5647        let (converged_model, beta) = model
5648            .reconverge_survival_inner_mode(&[rho], &beta0)
5649            .expect("converge survival mode with solver-only damping");
5650        let state = converged_model
5651            .update_state(&beta)
5652            .expect("evaluate undamped objective at converged mode");
5653
5654        assert_eq!(
5655            state.ridge_used, 0.0,
5656            "solver damping must not enter the converged statistical objective"
5657        );
5658        let undamped_stationarity = array1_l2_norm(&state.gradient);
5659        assert!(
5660            undamped_stationarity <= 1.0e-9,
5661            "solver must converge the undamped objective; ||gradient||={undamped_stationarity:.3e}"
5662        );
5663    }
5664
5665    #[test]
5666    fn laml_gradient_and_objective_ignore_inactive_penalty_prefix_blocks() {
5667        // The core claim under test: the survival LAML rho-gradient and the
5668        // documented LAML objective enumerate only penalty blocks whose
5669        // lambda is actually active (> 0). An inactive prefix block must
5670        // therefore contribute neither a log|lambda * S| term to the
5671        // objective nor an entry to the rho-gradient vector.
5672        //
5673        // Evaluate the enumeration identity at the fitted inner mode. LAML is
5674        // defined only there; an arbitrary fixed beta would test an algebraic
5675        // surrogate that production now deliberately refuses (#2491).
5676        let rho0 = -0.35_f64;
5677        let beta0 = array![-2.5_f64, 1.0];
5678        let model = laml_fd_test_model(rho0.exp());
5679        let (model, beta) = model
5680            .reconverge_survival_inner_mode(&[rho0], &beta0)
5681            .expect("converge inner mode for LAML prefix-skip test");
5682        let state = model
5683            .update_state(&beta)
5684            .expect("state for LAML prefix-skip test");
5685
5686        // Sanity: the fixture has two penalty blocks; the first has
5687        // lambda = 0 (inactive prefix) and the second has lambda > 0
5688        // (active). If a future refactor flips this ordering, the prefix
5689        // skip being exercised here would silently become an identity test.
5690        assert_eq!(model.penalties.blocks.len(), 2);
5691        assert_eq!(model.penalties.blocks[0].lambda, 0.0);
5692        assert!(model.penalties.blocks[1].lambda > 0.0);
5693
5694        let rho = Array1::from_iter(
5695            model
5696                .penalties
5697                .blocks
5698                .iter()
5699                .filter(|b| b.lambda > 0.0)
5700                .map(|b| b.lambda.ln()),
5701        );
5702        assert_eq!(
5703            rho.len(),
5704            1,
5705            "fixture should expose exactly one active penalty block for the rho vector"
5706        );
5707
5708        let (obj, grad) = model
5709            .unified_lamlobjective_and_rhogradient(&beta, &state, &rho)
5710            .expect("survival LAML objective and gradient");
5711
5712        let expected = 0.5 * (state.deviance + state.penalty_term)
5713            + 0.5 * laml_test_logdet_h(&state)
5714            - 0.5 * (rho0 + 2.5_f64.ln());
5715        assert_eq!(
5716            grad.len(),
5717            1,
5718            "rho-gradient must match the active-penalty count, not the full block list"
5719        );
5720        assert!(
5721            (obj - expected).abs() < 1e-10,
5722            "survival LAML objective mismatch with inactive prefix block: obj={obj} expected={expected}",
5723        );
5724        assert!(
5725            grad[0].is_finite(),
5726            "rho-gradient must be finite: {}",
5727            grad[0]
5728        );
5729    }
5730
5731    #[test]
5732    fn survival_laml_refuses_nonstationary_inner_state() {
5733        let rho0 = -0.35_f64;
5734        let beta0 = array![-2.5_f64, 1.0];
5735        let model = laml_fd_test_model(rho0.exp());
5736        let (model, beta_hat) = model
5737            .reconverge_survival_inner_mode(&[rho0], &beta0)
5738            .expect("converge reference survival mode");
5739
5740        // Move along the intercept only, preserving derivative feasibility while
5741        // making the likelihood score unambiguously nonstationary.
5742        let mut beta_off_mode = beta_hat;
5743        beta_off_mode[0] += 0.25;
5744        let state = model
5745            .update_state(&beta_off_mode)
5746            .expect("off-mode state remains in the survival domain");
5747        let rho = array![rho0];
5748        let error = model
5749            .unified_lamlobjective_and_rhogradient(&beta_off_mode, &state, &rho)
5750            .expect_err("LAML must refuse a nonstationary inner state");
5751        assert!(
5752            error
5753                .to_string()
5754                .contains("survival LAML requires a stationary inner mode"),
5755            "unexpected off-mode refusal: {error}"
5756        );
5757    }
5758
5759    #[test]
5760    fn structural_monotonicgradient_matchesobjectivefd() {
5761        let age_entry = array![1.0_f64, 1.3_f64, 1.8_f64];
5762        let age_exit = array![1.6_f64, 2.1_f64, 2.7_f64];
5763        let event_target = array![1u8, 0u8, 1u8];
5764        let event_competing = array![0u8, 0u8, 0u8];
5765        let sampleweight = array![1.0, 1.0, 1.0];
5766
5767        // Time block has 3 structural-monotone columns.
5768        // Final column is a covariate, left unconstrained.
5769        let x_entry = array![
5770            [1.0, 0.2, 0.05, -0.7],
5771            [1.0, 0.5, 0.20, 0.1],
5772            [1.0, 0.9, 0.60, 1.2]
5773        ];
5774        let x_exit = array![
5775            [1.0, 0.4, 0.16, -0.7],
5776            [1.0, 0.8, 0.64, 0.1],
5777            [1.0, 1.1, 1.21, 1.2]
5778        ];
5779        let x_derivative = array![
5780            [0.0, 0.8, 0.64, 0.0],
5781            [0.0, 0.7, 1.12, 0.0],
5782            [0.0, 0.6, 1.32, 0.0]
5783        ];
5784        let penalties = PenaltyBlocks::new(Vec::new());
5785        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5786        let mut model = survival_model(
5787            survival_inputs(
5788                &age_entry,
5789                &age_exit,
5790                &event_target,
5791                &event_competing,
5792                &sampleweight,
5793                &x_entry,
5794                &x_exit,
5795                &x_derivative,
5796            ),
5797            penalties,
5798            mono,
5799            SurvivalSpec::Net,
5800        )
5801        .expect("construct structural survival model");
5802        model
5803            .set_structural_monotonicity(true, 3)
5804            .expect("enable structural monotonicity");
5805        let constraints = model
5806            .monotonicity_linear_constraints()
5807            .expect("structural derivative constraints");
5808        // The structural certificate constrains the ENTIRE time block, one row
5809        // per time column, including column 0 whose derivative basis is
5810        // identically zero at every training row. Restricting the rows to
5811        // columns active at some training row is the tail blind spot
5812        // `2cf771160` closed: an I-spline column whose M-spline support sits
5813        // outside the training exit times is ~0 on every row, so a row-activity
5814        // filter leaves it free to go negative and monotonicity then breaks at
5815        // prediction times inside its support. Every time column is asserted
5816        // here so the domain-wide contract is what this fixture pins.
5817        assert_eq!(constraints.a.nrows(), 3);
5818        assert_eq!(constraints.a.ncols(), 4);
5819        assert_eq!(constraints.a.row(0).to_vec(), vec![1.0, 0.0, 0.0, 0.0]);
5820        assert_eq!(constraints.a.row(1).to_vec(), vec![0.0, 1.0, 0.0, 0.0]);
5821        assert_eq!(constraints.a.row(2).to_vec(), vec![0.0, 0.0, 1.0, 0.0]);
5822        assert!(constraints.b.iter().all(|&v| v.abs() <= 1e-12));
5823
5824        let beta = array![0.2, 0.2, 0.1, 0.2];
5825        let state = model.update_state(&beta).expect("state at structural beta");
5826        let eps = 1e-7;
5827        for j in 0..beta.len() {
5828            let mut plus = beta.clone();
5829            let mut minus = beta.clone();
5830            plus[j] += eps;
5831            minus[j] -= eps;
5832            let state_plus = model.update_state(&plus).expect("state at beta + eps");
5833            let state_minus = model.update_state(&minus).expect("state at beta - eps");
5834            let obj_plus = 0.5 * (state_plus.deviance + state_plus.penalty_term);
5835            let obj_minus = 0.5 * (state_minus.deviance + state_minus.penalty_term);
5836            let fd = (obj_plus - obj_minus) / (2.0 * eps);
5837            assert_eq!(
5838                state.gradient[j].signum(),
5839                fd.signum(),
5840                "structural objective/gradient sign mismatch at j={j}: grad={} fd={fd}",
5841                state.gradient[j]
5842            );
5843            assert!(
5844                (state.gradient[j] - fd).abs() < 2e-5,
5845                "structural objective/gradient mismatch at j={j}: grad={} fd={fd}",
5846                state.gradient[j]
5847            );
5848        }
5849    }
5850
5851    #[test]
5852    fn structural_monotonic_lamlgradient_returns_finitevalues() {
5853        let age_entry = array![1.0_f64, 1.2_f64];
5854        let age_exit = array![1.5_f64, 2.0_f64];
5855        let event_target = array![1u8, 0u8];
5856        let event_competing = array![0u8, 0u8];
5857        let sampleweight = array![1.0, 1.0];
5858        let x_entry = array![[1.0, 0.2, -0.5], [1.0, 0.4, 0.2]];
5859        let x_exit = array![[1.0, 0.5, -0.5], [1.0, 0.8, 0.2]];
5860        let x_derivative = array![[0.0, 0.9, 0.0], [0.0, 0.7, 0.0]];
5861        let penalties = PenaltyBlocks::new(Vec::new());
5862        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5863        let mut model = survival_model(
5864            survival_inputs(
5865                &age_entry,
5866                &age_exit,
5867                &event_target,
5868                &event_competing,
5869                &sampleweight,
5870                &x_entry,
5871                &x_exit,
5872                &x_derivative,
5873            ),
5874            penalties,
5875            mono,
5876            SurvivalSpec::Net,
5877        )
5878        .expect("construct structural survival model");
5879        model
5880            .set_structural_monotonicity(true, 2)
5881            .expect("enable structural monotonicity");
5882        // One simple penalty block to exercise rho-gradient path.
5883        model.penalties = PenaltyBlocks::new(vec![PenaltyBlock {
5884            matrix: array![[1.0]],
5885            lambda: 0.7,
5886            range: 1..2,
5887            nullspace_dim: 0,
5888        }]);
5889        let beta0 = array![0.2, 0.2, 0.1];
5890        let rho = Array1::from_iter(
5891            model
5892                .penalties
5893                .blocks
5894                .iter()
5895                .filter(|b| b.lambda > 0.0)
5896                .map(|b| b.lambda.ln()),
5897        );
5898        let (model, beta) = model
5899            .reconverge_survival_inner_mode(
5900                rho.as_slice().expect("contiguous structural rho"),
5901                &beta0,
5902            )
5903            .expect("converge structural survival mode");
5904        let state = model.update_state(&beta).expect("state at structural mode");
5905        let (obj, grad) = model
5906            .unified_lamlobjective_and_rhogradient(&beta, &state, &rho)
5907            .expect("laml gradient should work in structural mode");
5908        assert!(obj.is_finite());
5909        assert_eq!(grad.len(), 1);
5910        assert!(grad[0].is_finite());
5911    }
5912
5913    #[test]
5914    fn structural_monotonicity_switches_to_tiny_derivative_guard_constraints() {
5915        let age_entry = array![1.0_f64];
5916        let age_exit = array![2.0_f64];
5917        let event_target = array![1u8];
5918        let event_competing = array![0u8];
5919        let sampleweight = array![1.0];
5920        let x_entry = array![[0.0]];
5921        let x_exit = array![[0.2]];
5922        let x_derivative = array![[1.0]];
5923
5924        let penalties = PenaltyBlocks::new(Vec::new());
5925        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
5926        let mut model = survival_model(
5927            survival_inputs(
5928                &age_entry,
5929                &age_exit,
5930                &event_target,
5931                &event_competing,
5932                &sampleweight,
5933                &x_entry,
5934                &x_exit,
5935                &x_derivative,
5936            ),
5937            penalties,
5938            mono,
5939            SurvivalSpec::Net,
5940        )
5941        .expect("construct structural survival model");
5942
5943        let beta = array![-3.0];
5944        assert!(
5945            model.update_state(&beta).is_err(),
5946            "negative derivative coefficient should violate derivative guard"
5947        );
5948
5949        model
5950            .set_structural_monotonicity(true, 1)
5951            .expect("enable structural monotonicity");
5952        let constraints = model
5953            .monotonicity_linear_constraints()
5954            .expect("structural derivative constraints");
5955        assert_eq!(constraints.a.nrows(), 1);
5956        assert_eq!(constraints.a.ncols(), 1);
5957        assert!((constraints.a[[0, 0]] - 1.0).abs() <= 1e-12);
5958        // Structural monotonicity uses derivative_guard() == 0.0
5959        assert!(constraints.b[0].abs() <= 1e-12);
5960        let state = model
5961            .update_state(&array![1e-6])
5962            .expect("small positive derivative coefficient should remain feasible");
5963        assert!(state.deviance.is_finite());
5964    }
5965
5966    #[test]
5967    fn derivative_offset_must_clear_nonstructural_monotonicity_threshold() {
5968        let age_entry = array![1.0_f64];
5969        let age_exit = array![2.0_f64];
5970        let event_target = array![1u8];
5971        let event_competing = array![0u8];
5972        let sampleweight = array![1.0];
5973        let x_entry = array![[1.0, 0.0]];
5974        let x_exit = array![[1.0, 0.0]];
5975        let x_derivative = array![[0.0, 0.0]];
5976        let penalties = PenaltyBlocks::new(Vec::new());
5977        let monotonicity = SurvivalMonotonicityPenalty { tolerance: 3.0 };
5978        let eta_entry_offset = array![0.0];
5979        let eta_exit_offset = array![0.0];
5980        let derivative_offset_below_guard = array![2.0];
5981        let derivative_offset_above_guard = array![3.1];
5982        let offsets_below_guard = SurvivalBaselineOffsets {
5983            eta_entry: eta_entry_offset.view(),
5984            eta_exit: eta_exit_offset.view(),
5985            derivative_exit: derivative_offset_below_guard.view(),
5986        };
5987        let offsets_above_guard = SurvivalBaselineOffsets {
5988            eta_entry: eta_entry_offset.view(),
5989            eta_exit: eta_exit_offset.view(),
5990            derivative_exit: derivative_offset_above_guard.view(),
5991        };
5992
5993        let model_below_guard = survival_model_with_offsets(
5994            survival_inputs(
5995                &age_entry,
5996                &age_exit,
5997                &event_target,
5998                &event_competing,
5999                &sampleweight,
6000                &x_entry,
6001                &x_exit,
6002                &x_derivative,
6003            ),
6004            Some(offsets_below_guard),
6005            penalties.clone(),
6006            monotonicity,
6007            SurvivalSpec::Net,
6008        )
6009        .expect("construct model with derivative offset below guard");
6010        let err = model_below_guard
6011            .update_state(&array![0.0, 0.0])
6012            .expect_err("derivative offset below guard should be rejected");
6013        let err_text = err.to_string();
6014        assert!(
6015            err_text.contains("d_eta/dt=2.000e0") && err_text.contains("tolerance=3.000e0"),
6016            "expected derivative guard rejection to report the offset-driven derivative: {err_text}"
6017        );
6018
6019        let model_above_guard = survival_model_with_offsets(
6020            survival_inputs(
6021                &age_entry,
6022                &age_exit,
6023                &event_target,
6024                &event_competing,
6025                &sampleweight,
6026                &x_entry,
6027                &x_exit,
6028                &x_derivative,
6029            ),
6030            Some(offsets_above_guard),
6031            penalties,
6032            SurvivalMonotonicityPenalty { tolerance: 3.0 },
6033            SurvivalSpec::Net,
6034        )
6035        .expect("construct model with derivative offset above guard");
6036        let state = model_above_guard
6037            .update_state(&array![0.0, 0.0])
6038            .expect("derivative offset above guard should remain feasible");
6039        assert!(state.deviance.is_finite());
6040    }
6041
6042    #[test]
6043    fn structural_monotonicity_rejects_negative_derivative_offsets() {
6044        let age_entry = array![1.0_f64];
6045        let age_exit = array![2.0_f64];
6046        let event_target = array![1u8];
6047        let event_competing = array![0u8];
6048        let sampleweight = array![1.0];
6049        let x_entry = array![[0.0]];
6050        let x_exit = array![[0.2]];
6051        let x_derivative = array![[1.0]];
6052        let eta_entry = array![0.0];
6053        let eta_exit = array![0.0];
6054        let derivative_exit = array![-1e-3];
6055        let offsets = SurvivalBaselineOffsets {
6056            eta_entry: eta_entry.view(),
6057            eta_exit: eta_exit.view(),
6058            derivative_exit: derivative_exit.view(),
6059        };
6060
6061        let mut model = survival_model_with_offsets(
6062            survival_inputs(
6063                &age_entry,
6064                &age_exit,
6065                &event_target,
6066                &event_competing,
6067                &sampleweight,
6068                &x_entry,
6069                &x_exit,
6070                &x_derivative,
6071            ),
6072            Some(offsets),
6073            PenaltyBlocks::new(Vec::new()),
6074            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6075            SurvivalSpec::Net,
6076        )
6077        .expect("construct structural survival model");
6078        let err = model
6079            .set_structural_monotonicity(true, 1)
6080            .expect_err("negative derivative offsets must be rejected");
6081        assert!(
6082            err.to_string()
6083                .contains("structural monotonicity requires nonnegative derivative offsets"),
6084            "unexpected error: {err}"
6085        );
6086    }
6087
6088    #[test]
6089    fn structural_monotonicity_emits_coefficient_constraints() {
6090        let age_entry = array![1.0_f64, 1.5_f64];
6091        let age_exit = array![2.0_f64, 3.0_f64];
6092        let event_target = array![1u8, 0u8];
6093        let event_competing = array![0u8, 0u8];
6094        let sampleweight = array![1.0, 1.0];
6095        let x_entry = array![[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]];
6096        let x_exit = array![[0.2, 0.4, 1.0], [0.3, 0.5, 1.0]];
6097        let x_derivative = array![[0.3, 0.2, 0.0], [0.4, 0.1, 0.0]];
6098
6099        let mut model = survival_model(
6100            survival_inputs(
6101                &age_entry,
6102                &age_exit,
6103                &event_target,
6104                &event_competing,
6105                &sampleweight,
6106                &x_entry,
6107                &x_exit,
6108                &x_derivative,
6109            ),
6110            PenaltyBlocks::new(Vec::new()),
6111            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6112            SurvivalSpec::Net,
6113        )
6114        .expect("construct structural survival model");
6115        model
6116            .set_structural_monotonicity(true, 2)
6117            .expect("enable structural monotonicity");
6118
6119        let constraints = model
6120            .monotonicity_linear_constraints()
6121            .expect("structural derivative constraints");
6122
6123        assert_eq!(constraints.a.nrows(), 2);
6124        assert_eq!(constraints.a.ncols(), 3);
6125        assert_eq!(constraints.a.row(0).to_vec(), vec![1.0, 0.0, 0.0]);
6126        assert_eq!(constraints.a.row(1).to_vec(), vec![0.0, 1.0, 0.0]);
6127        assert!(constraints.b.iter().all(|&v| v.abs() <= 1e-12));
6128    }
6129
6130    #[test]
6131    fn structural_monotonicity_preserves_inactive_time_columns_in_constraints() {
6132        let age_entry = array![1.0_f64];
6133        let age_exit = array![2.0_f64];
6134        let event_target = array![1u8];
6135        let event_competing = array![0u8];
6136        let sampleweight = array![1.0];
6137        let x_entry = array![[1.0, 0.2]];
6138        let x_exit = array![[1.0, 0.6]];
6139        let x_derivative = array![[0.0, 1.0]];
6140
6141        let mut model = survival_model(
6142            survival_inputs(
6143                &age_entry,
6144                &age_exit,
6145                &event_target,
6146                &event_competing,
6147                &sampleweight,
6148                &x_entry,
6149                &x_exit,
6150                &x_derivative,
6151            ),
6152            PenaltyBlocks::new(Vec::new()),
6153            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6154            SurvivalSpec::Net,
6155        )
6156        .expect("construct structural survival model");
6157        model
6158            .set_structural_monotonicity(true, 2)
6159            .expect("enable structural monotonicity");
6160
6161        let constraints = model
6162            .monotonicity_linear_constraints()
6163            .expect("structural derivative constraints");
6164
6165        // Column 0's derivative basis is identically zero at the single
6166        // training row, so a row-activity filter would drop its constraint —
6167        // and that is precisely the tail blind spot `2cf771160` closed. The
6168        // structural certificate is domain-wide: it keeps a row for the
6169        // inactive time column too, because the coefficient is what has to stay
6170        // non-negative for monotonicity to hold at evaluation times outside the
6171        // training support, where that column's M-spline is NOT zero.
6172        assert_eq!(constraints.a.nrows(), 2);
6173        assert!(
6174            (constraints.a[[0, 0]] - 1.0).abs() <= 1e-12,
6175            "inactive time column must still be constrained (domain-wide certificate)"
6176        );
6177        assert!(
6178            (constraints.a[[1, 1]] - 1.0).abs() <= 1e-12,
6179            "active time column should remain constrained"
6180        );
6181        assert!(
6182            constraints.a[[0, 1]].abs() <= 1e-12 && constraints.a[[1, 0]].abs() <= 1e-12,
6183            "each row must constrain exactly its own time coefficient"
6184        );
6185    }
6186
6187    #[test]
6188    fn structural_monotonicity_preserves_sparse_row_patterns() {
6189        let age_entry = array![1.0_f64, 1.5_f64];
6190        let age_exit = array![2.0_f64, 2.5_f64];
6191        let event_target = array![1u8, 1u8];
6192        let event_competing = array![0u8, 0u8];
6193        let sampleweight = array![1.0, 1.0];
6194        let x_entry = array![[0.0, 0.0], [0.0, 0.0]];
6195        let x_exit = array![[0.4, 0.2], [0.6, 0.3]];
6196        let x_derivative = array![[1.0, 0.0], [1.0, 0.5]];
6197
6198        let mut model = survival_model(
6199            survival_inputs(
6200                &age_entry,
6201                &age_exit,
6202                &event_target,
6203                &event_competing,
6204                &sampleweight,
6205                &x_entry,
6206                &x_exit,
6207                &x_derivative,
6208            ),
6209            PenaltyBlocks::new(Vec::new()),
6210            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6211            SurvivalSpec::Net,
6212        )
6213        .expect("construct structural survival model");
6214        model
6215            .set_structural_monotonicity(true, 2)
6216            .expect("enable structural monotonicity");
6217
6218        let constraints = model
6219            .monotonicity_linear_constraints()
6220            .expect("structural derivative constraints");
6221
6222        assert_eq!(constraints.a.nrows(), 2);
6223        assert_eq!(constraints.a.row(0).to_vec(), vec![1.0, 0.0]);
6224        assert_eq!(constraints.a.row(1).to_vec(), vec![0.0, 1.0]);
6225    }
6226
6227    #[test]
6228    fn update_state_rejects_negative_exit_derivative_for_censoredrows() {
6229        let age_entry = array![1.0_f64];
6230        let age_exit = array![1.1_f64];
6231        let event_target = array![0u8];
6232        let event_competing = array![0u8];
6233        let sampleweight = array![1.0];
6234        let x_entry = array![[0.0]];
6235        let x_exit = array![[0.0]];
6236        let x_derivative = array![[-1.0]];
6237        let penalties = PenaltyBlocks::new(Vec::new());
6238        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
6239        let model = survival_model(
6240            survival_inputs(
6241                &age_entry,
6242                &age_exit,
6243                &event_target,
6244                &event_competing,
6245                &sampleweight,
6246                &x_entry,
6247                &x_exit,
6248                &x_derivative,
6249            ),
6250            penalties,
6251            mono,
6252            SurvivalSpec::Net,
6253        )
6254        .expect("construct censored survival model");
6255
6256        let err = model
6257            .update_state(&array![1.0])
6258            .expect_err("censored row should still enforce monotonic derivative");
6259        assert!(
6260            matches!(err, EstimationError::ParameterConstraintViolation(_)),
6261            "unexpected error: {err:?}"
6262        );
6263    }
6264
6265    fn crude_risk_quadrature_error(
6266        cumulative_entry: f64,
6267        cumulative_exit: f64,
6268        hazard_exit: f64,
6269    ) -> SurvivalError {
6270        calculate_crude_risk_quadrature(
6271            1.0,
6272            2.0,
6273            &[],
6274            0.4,
6275            0.2,
6276            array![1.0].view(),
6277            array![1.0].view(),
6278            |_, design_d, deriv_d, design_m| {
6279                design_d[0] = 1.0;
6280                deriv_d[0] = 0.0;
6281                design_m[0] = 1.0;
6282                Ok((cumulative_entry, cumulative_exit, hazard_exit))
6283            },
6284        )
6285        .expect_err("invalid hazards should fail")
6286    }
6287
6288    #[test]
6289    fn crude_risk_quadrature_rejects_decreasing_cumulative_hazard() {
6290        let err = crude_risk_quadrature_error(0.1, 0.3, 0.25);
6291        assert!(matches!(err, SurvivalError::NonMonotoneCumulativeHazard));
6292    }
6293
6294    #[test]
6295    fn crude_risk_quadrature_rejects_nonpositive_instantaneous_hazard() {
6296        let err = crude_risk_quadrature_error(0.0, 0.4, 0.25);
6297        assert!(matches!(err, SurvivalError::NonPositiveHazard));
6298    }
6299
6300    #[test]
6301    fn monotonicity_constraints_collapse_positive_collinearrows() {
6302        let a = array![[0.0, 0.5, 0.0], [0.0, 0.25, 0.0], [0.0, 0.125, 0.0]];
6303        let b = array![1e-8, 1e-8, 1e-8];
6304
6305        let compressed = compress_positive_collinear_constraints(&a, &b);
6306
6307        assert_eq!(compressed.a.nrows(), 1);
6308        assert_eq!(compressed.a.ncols(), 3);
6309        assert!(compressed.a[[0, 0]].abs() <= 1e-12);
6310        assert!((compressed.a[[0, 1]] - 1.0).abs() <= 1e-12);
6311        assert!(compressed.a[[0, 2]].abs() <= 1e-12);
6312        assert!((compressed.b[0] - 8e-8).abs() <= 1e-18);
6313    }
6314
6315    #[test]
6316    fn monotonicity_constraints_preserve_distinct_directions() {
6317        let a = array![[1.0, 0.0], [0.0, 1.0], [2.0, 0.0]];
6318        let b = array![0.2, 0.3, 0.1];
6319
6320        let compressed = compress_positive_collinear_constraints(&a, &b);
6321
6322        assert_eq!(compressed.a.nrows(), 2);
6323        let mut saw_x = false;
6324        let mut saw_y = false;
6325        for i in 0..compressed.a.nrows() {
6326            if (compressed.a[[i, 0]] - 1.0).abs() <= 1e-12 && compressed.a[[i, 1]].abs() <= 1e-12 {
6327                saw_x = true;
6328                assert!((compressed.b[i] - 0.2).abs() <= 1e-12);
6329            }
6330            if compressed.a[[i, 0]].abs() <= 1e-12 && (compressed.a[[i, 1]] - 1.0).abs() <= 1e-12 {
6331                saw_y = true;
6332                assert!((compressed.b[i] - 0.3).abs() <= 1e-12);
6333            }
6334        }
6335        assert!(saw_x);
6336        assert!(saw_y);
6337    }
6338
6339    #[test]
6340    fn monotonicity_constraints_cluster_near_collinearrows() {
6341        let a = array![
6342            [0.0, 0.5, 0.0],
6343            [0.0, 0.50000000003, 0.0],
6344            [0.0, 0.49999999997, 0.0]
6345        ];
6346        let b = array![1e-8, 1.00000000005e-8, 0.99999999995e-8];
6347
6348        let compressed = compress_positive_collinear_constraints(&a, &b);
6349
6350        assert_eq!(compressed.a.nrows(), 1);
6351        assert_eq!(compressed.a.ncols(), 3);
6352        assert!(compressed.a[[0, 0]].abs() <= 1e-12);
6353        assert!((compressed.a[[0, 1]] - 1.0).abs() <= 1e-12);
6354        assert!(compressed.a[[0, 2]].abs() <= 1e-12);
6355        assert!((compressed.b[0] - 2.0e-8).abs() <= 1e-18);
6356    }
6357
6358    #[test]
6359    fn monotonicity_constraints_cluster_spline_like_near_duplicates() {
6360        let a = array![
6361            [0.0, 0.401, 0.302, 0.197],
6362            [0.0, 0.40100000003, 0.30199999998, 0.19700000001],
6363            [0.0, 0.40099999997, 0.30200000002, 0.19699999999],
6364            [0.0, 0.125, 0.500, 0.375]
6365        ];
6366        let b = array![2.0e-8, 2.00000000004e-8, 1.99999999996e-8, 3.0e-8];
6367
6368        let compressed = compress_positive_collinear_constraints(&a, &b);
6369
6370        assert_eq!(compressed.a.nrows(), 2);
6371        let mut clustered_face = false;
6372        let mut distinct_face = false;
6373        for i in 0..compressed.a.nrows() {
6374            let row = compressed.a.row(i);
6375            if row[1] > 0.99 && row[2] > 0.7 && row[3] > 0.49 {
6376                clustered_face = true;
6377                assert!((compressed.b[i] - (2.0e-8 / 0.401)).abs() <= 1e-12);
6378            } else {
6379                distinct_face = true;
6380                assert!((row[1] - 0.25).abs() <= 1e-12);
6381                assert!((row[2] - 1.0).abs() <= 1e-12);
6382                assert!((row[3] - 0.75).abs() <= 1e-12);
6383                assert!((compressed.b[i] - 6.0e-8).abs() <= 1e-18);
6384            }
6385        }
6386        assert!(clustered_face);
6387        assert!(distinct_face);
6388    }
6389
6390    #[test]
6391    fn linear_time_monotonicity_constraints_reduce_to_single_halfspace() {
6392        let age_entry = array![1.0_f64, 1.0, 1.0];
6393        let age_exit = array![2.0_f64, 4.0, 8.0];
6394        let event_target = array![0u8, 1u8, 0u8];
6395        let event_competing = array![0u8, 0u8, 0u8];
6396        let sampleweight = array![1.0, 1.0, 1.0];
6397        let x_entry = array![
6398            [1.0, age_entry[0].ln()],
6399            [1.0, age_entry[1].ln()],
6400            [1.0, age_entry[2].ln()]
6401        ];
6402        let x_exit = array![
6403            [1.0, age_exit[0].ln()],
6404            [1.0, age_exit[1].ln()],
6405            [1.0, age_exit[2].ln()]
6406        ];
6407        let x_derivative = array![[0.0, 0.5], [0.0, 0.25], [0.0, 0.125]];
6408        let penalties = PenaltyBlocks::new(Vec::new());
6409        let mono = SurvivalMonotonicityPenalty { tolerance: 1e-8 };
6410
6411        let collocation_offsets = Array1::zeros(x_derivative.nrows());
6412        let mut inputs = survival_inputs(
6413            &age_entry,
6414            &age_exit,
6415            &event_target,
6416            &event_competing,
6417            &sampleweight,
6418            &x_entry,
6419            &x_exit,
6420            &x_derivative,
6421        );
6422        inputs.monotonicity_constraint_rows = Some(x_derivative.view());
6423        inputs.monotonicity_constraint_offsets = Some(collocation_offsets.view());
6424
6425        let model = survival_model(inputs, penalties, mono, SurvivalSpec::Net)
6426            .expect("construct linear survival model");
6427
6428        let constraints = model
6429            .monotonicity_linear_constraints()
6430            .expect("monotonicity constraints");
6431        assert_eq!(constraints.a.nrows(), 1);
6432        assert!((constraints.a[[0, 1]] - 1.0).abs() <= 1e-12);
6433        assert!((constraints.b[0] - 8e-8).abs() <= 1e-12);
6434    }
6435
6436    #[test]
6437    fn monotonicity_constraints_skip_numericallyzerorows() {
6438        let age_entry = array![1.0_f64, 1.0, 1.0];
6439        let age_exit = array![2.0_f64, 3.0, 4.0];
6440        let event_target = array![0u8, 0u8, 0u8];
6441        let event_competing = array![0u8, 0u8, 0u8];
6442        let sampleweight = array![1.0, 1.0, 1.0];
6443        let x_entry = array![[1.0, 0.0], [1.0, 0.0], [1.0, 0.0]];
6444        let x_exit = x_entry.clone();
6445        let x_derivative = array![[0.0, 0.0], [0.0, 1e-16], [0.0, 0.25]];
6446
6447        let collocation_offsets = Array1::zeros(x_derivative.nrows());
6448        let mut inputs = survival_inputs(
6449            &age_entry,
6450            &age_exit,
6451            &event_target,
6452            &event_competing,
6453            &sampleweight,
6454            &x_entry,
6455            &x_exit,
6456            &x_derivative,
6457        );
6458        inputs.monotonicity_constraint_rows = Some(x_derivative.view());
6459        inputs.monotonicity_constraint_offsets = Some(collocation_offsets.view());
6460
6461        let model = survival_model(
6462            inputs,
6463            PenaltyBlocks::new(Vec::new()),
6464            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6465            SurvivalSpec::Net,
6466        )
6467        .expect("construct survival model");
6468
6469        let constraints = model
6470            .monotonicity_linear_constraints()
6471            .expect("nonzero derivative row should remain");
6472        assert_eq!(constraints.a.nrows(), 1);
6473        assert!((constraints.a[[0, 1]] - 1.0).abs() <= 1e-12);
6474        assert!(constraints.b[0].abs() <= 1e-18);
6475    }
6476
6477    #[test]
6478    fn censoredrows_allowzero_boundary_derivative() {
6479        let age_entry = array![1.0_f64];
6480        let age_exit = array![2.0_f64];
6481        let event_target = array![0u8];
6482        let event_competing = array![0u8];
6483        let sampleweight = array![1.0];
6484        let x_entry = array![[0.0]];
6485        let x_exit = array![[0.0]];
6486        let x_derivative = array![[1.0]];
6487
6488        let model = survival_model(
6489            survival_inputs(
6490                &age_entry,
6491                &age_exit,
6492                &event_target,
6493                &event_competing,
6494                &sampleweight,
6495                &x_entry,
6496                &x_exit,
6497                &x_derivative,
6498            ),
6499            PenaltyBlocks::new(Vec::new()),
6500            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6501            SurvivalSpec::Net,
6502        )
6503        .expect("construct censored survival model");
6504
6505        let state = model
6506            .update_state(&array![0.0])
6507            .expect("censored boundary derivative should remain feasible with zero tolerance");
6508        assert_eq!(state.deviance, 0.0);
6509        assert_eq!(state.log_likelihood, 0.0);
6510        assert_eq!(state.gradient, array![0.0]);
6511    }
6512
6513    #[test]
6514    fn eventrows_keep_positive_derivative_constraint() {
6515        let age_entry = array![1.0_f64, 1.0];
6516        let age_exit = array![2.0_f64, 4.0];
6517        let event_target = array![0u8, 1u8];
6518        let event_competing = array![0u8, 0u8];
6519        let sampleweight = array![1.0, 1.0];
6520        let x_entry = array![[0.0], [0.0]];
6521        let x_exit = array![[0.0], [0.0]];
6522        let x_derivative = array![[0.5], [0.25]];
6523
6524        let collocation_offsets = Array1::zeros(x_derivative.nrows());
6525        let mut inputs = survival_inputs(
6526            &age_entry,
6527            &age_exit,
6528            &event_target,
6529            &event_competing,
6530            &sampleweight,
6531            &x_entry,
6532            &x_exit,
6533            &x_derivative,
6534        );
6535        inputs.monotonicity_constraint_rows = Some(x_derivative.view());
6536        inputs.monotonicity_constraint_offsets = Some(collocation_offsets.view());
6537
6538        let model = survival_model(
6539            inputs,
6540            PenaltyBlocks::new(Vec::new()),
6541            SurvivalMonotonicityPenalty { tolerance: 1e-8 },
6542            SurvivalSpec::Net,
6543        )
6544        .expect("construct mixed survival model");
6545
6546        let constraints = model
6547            .monotonicity_linear_constraints()
6548            .expect("event row should induce positive lower bound");
6549        assert_eq!(constraints.a.nrows(), 1);
6550        assert!((constraints.a[[0, 0]] - 1.0).abs() <= 1e-12);
6551        assert!((constraints.b[0] - 4e-8).abs() <= 1e-18);
6552    }
6553
6554    #[test]
6555    fn structural_monotonicity_clamps_tiny_negative_roundoff() {
6556        let age_entry = array![1.0_f64];
6557        let age_exit = array![2.0_f64];
6558        let event_target = array![1u8];
6559        let event_competing = array![0u8];
6560        let sampleweight = array![1.0];
6561        let x_entry = array![[0.0]];
6562        let x_exit = array![[0.0]];
6563        let x_derivative = array![[1.0]];
6564        let mut model = survival_model(
6565            survival_inputs(
6566                &age_entry,
6567                &age_exit,
6568                &event_target,
6569                &event_competing,
6570                &sampleweight,
6571                &x_entry,
6572                &x_exit,
6573                &x_derivative,
6574            ),
6575            PenaltyBlocks::new(Vec::new()),
6576            SurvivalMonotonicityPenalty { tolerance: 1e-8 },
6577            SurvivalSpec::Net,
6578        )
6579        .expect("construct survival model");
6580        model
6581            .set_structural_monotonicity(true, 1)
6582            .expect("enable structural monotonicity");
6583
6584        let state = model
6585            .update_state(&array![-1e-8])
6586            .expect("tiny structural roundoff should be clamped");
6587        let expected_deviance = -2.0 * (1.0e-12_f64).ln();
6588        assert!(
6589            (state.deviance - expected_deviance).abs() <= 1e-12,
6590            "floored structural event deviance: expected {expected_deviance}, got {}",
6591            state.deviance
6592        );
6593        assert_eq!(state.gradient, array![0.0]);
6594    }
6595
6596    #[test]
6597    fn compressed_monotonicity_constraints_preserve_uncompressed_feasible_region() {
6598        let uncompressed_constraints = LinearInequalityConstraints {
6599            a: array![
6600                [0.0, 0.5, 0.0],
6601                [0.0, 1.0 / 3.0, 0.0],
6602                [0.0, 0.2, 0.0],
6603                [0.0, 0.125, 0.0]
6604            ],
6605            b: Array1::from_elem(4, 1e-8),
6606        };
6607        let compressed_constraints = compress_positive_collinear_constraints(
6608            &uncompressed_constraints.a,
6609            &uncompressed_constraints.b,
6610        );
6611
6612        let candidates = [
6613            array![0.0, 1e-9, 0.0],
6614            array![0.0, 4e-8, 0.0],
6615            array![0.0, 8e-8, 0.0],
6616            array![0.0, 2e-7, 1.5],
6617        ];
6618        for beta in candidates {
6619            let uncompressed_ok = (0..uncompressed_constraints.a.nrows()).all(|i| {
6620                uncompressed_constraints.a.row(i).dot(&beta) >= uncompressed_constraints.b[i]
6621            });
6622            let compressed_ok = (0..compressed_constraints.a.nrows())
6623                .all(|i| compressed_constraints.a.row(i).dot(&beta) >= compressed_constraints.b[i]);
6624            assert_eq!(compressed_ok, uncompressed_ok);
6625        }
6626    }
6627
6628    #[test]
6629    fn exact_survival_derivatives_are_time_unit_invariant_up_to_constant_shift() {
6630        let age_entry = array![10.0_f64, 20.0, 25.0];
6631        let age_exit = array![15.0_f64, 30.0, 40.0];
6632        let event_target = array![1u8, 0u8, 1u8];
6633        let event_competing = array![0u8, 0u8, 0u8];
6634        let sampleweight = array![1.0, 2.0, 0.5];
6635        let x_entry = array![[0.1, 0.2, 1.0], [0.3, 0.4, 1.0], [0.2, 0.6, 1.0]];
6636        let x_exit = array![[0.2, 0.3, 1.0], [0.5, 0.7, 1.0], [0.4, 0.8, 1.0]];
6637        let x_derivative = array![[0.04, 0.02, 0.0], [0.03, 0.01, 0.0], [0.02, 0.03, 0.0]];
6638        let beta = array![0.8, 1.1, -0.2];
6639
6640        let base_model = survival_model(
6641            survival_inputs(
6642                &age_entry,
6643                &age_exit,
6644                &event_target,
6645                &event_competing,
6646                &sampleweight,
6647                &x_entry,
6648                &x_exit,
6649                &x_derivative,
6650            ),
6651            PenaltyBlocks::new(Vec::new()),
6652            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6653            SurvivalSpec::Net,
6654        )
6655        .expect("construct base survival model");
6656        let base_state = base_model
6657            .update_state(&beta)
6658            .expect("evaluate base survival state");
6659
6660        let time_scale = 365.25;
6661        let scaled_age_entry = age_entry.mapv(|v| v * time_scale);
6662        let scaled_age_exit = age_exit.mapv(|v| v * time_scale);
6663        let scaled_x_derivative = x_derivative.mapv(|v| v / time_scale);
6664        let scaled_model = survival_model(
6665            survival_inputs(
6666                &scaled_age_entry,
6667                &scaled_age_exit,
6668                &event_target,
6669                &event_competing,
6670                &sampleweight,
6671                &x_entry,
6672                &x_exit,
6673                &scaled_x_derivative,
6674            ),
6675            PenaltyBlocks::new(Vec::new()),
6676            SurvivalMonotonicityPenalty { tolerance: 0.0 },
6677            SurvivalSpec::Net,
6678        )
6679        .expect("construct scaled survival model");
6680        let scaled_state = scaled_model
6681            .update_state(&beta)
6682            .expect("evaluate scaled survival state");
6683
6684        let weighted_events = sampleweight
6685            .iter()
6686            .zip(event_target.iter())
6687            .map(|(w, d)| *w * f64::from(*d))
6688            .sum::<f64>();
6689        let expected_deviance_shift = 2.0 * weighted_events * time_scale.ln();
6690        assert!(
6691            (scaled_state.deviance - base_state.deviance - expected_deviance_shift).abs() <= 1e-10,
6692            "deviance shift mismatch: scaled={} base={} expected_shift={expected_deviance_shift}",
6693            scaled_state.deviance,
6694            base_state.deviance
6695        );
6696
6697        for j in 0..beta.len() {
6698            assert!(
6699                (scaled_state.gradient[j] - base_state.gradient[j]).abs() <= 1e-12,
6700                "gradient mismatch at j={j}: scaled={} base={}",
6701                scaled_state.gradient[j],
6702                base_state.gradient[j]
6703            );
6704        }
6705
6706        let base_hessian = base_state.hessian.to_dense();
6707        let scaled_hessian = scaled_state.hessian.to_dense();
6708        for r in 0..beta.len() {
6709            for c in 0..beta.len() {
6710                assert!(
6711                    (scaled_hessian[[r, c]] - base_hessian[[r, c]]).abs() <= 1e-12,
6712                    "hessian mismatch at ({r},{c}): scaled={} base={}",
6713                    scaled_hessian[[r, c]],
6714                    base_hessian[[r, c]]
6715                );
6716            }
6717        }
6718    }
6719
6720    #[test]
6721    fn survival_laml_rho_gradient_invariant_under_injected_orthogonal_frame_at_the_rail() {
6722        use gam_linalg::faer_ndarray::FaerEigh;
6723        use gam_problem::{EvalMode, PseudoLogdetMode};
6724        use gam_solve::estimate::reml::assembly::InnerAssembly;
6725        use gam_solve::estimate::reml::reml_outer_engine::{
6726            DenseSpectralOperator, DispersionHandling,
6727        };
6728        use gam_solve::estimate::reml::reparameterized_inner::{
6729            RawInnerReparamContext, assemble_reparameterized_inner,
6730        };
6731        use gam_terms::construction::{
6732            canonicalize_penalty_specs, precompute_reparam_invariant_from_canonical,
6733            stable_reparameterizationwith_invariant,
6734        };
6735        use gam_terms::penalty_spec::PenaltySpec;
6736
6737        const RAIL_RHO0: f64 = 7.394829814011909;
6738        const FREE_RHO1: f64 = -2.45;
6739        // #2337 §2 per-coordinate decision margin. The invariance is exact in
6740        // real arithmetic; only O(p·ε) conjugation round-off separates the two
6741        // frames, far below the rail's O(0.1) free-coordinate |Pg|.
6742        const DECISION_MARGIN: f64 = 1.0e-9;
6743
6744        let beta0 = array![-2.5_f64, 1.0];
6745        let rho = array![RAIL_RHO0, FREE_RHO1];
6746        let model = laml_rail_fd_test_model(RAIL_RHO0.exp(), FREE_RHO1.exp());
6747
6748        // Converge the inner mode at the railed ρ; extract the raw inner solution.
6749        let (rail_model, beta_hat) = model
6750            .reconverge_survival_inner_mode(rho.as_slice().expect("contiguous rho"), &beta0)
6751            .expect("reconverge inner mode at the rail");
6752        let state = rail_model
6753            .update_state(&beta_hat)
6754            .expect("inner state at the rail");
6755        let p = beta_hat.len();
6756        let h_dense = state.hessian.to_dense();
6757        let lambdas: Vec<f64> = rho.iter().map(|&r| r.exp()).collect();
6758
6759        let active_blocks: Vec<&PenaltyBlock> = rail_model
6760            .penalties
6761            .blocks
6762            .iter()
6763            .filter(|b| b.lambda > 0.0)
6764            .collect();
6765        let s_k_embedded: Vec<Array2<f64>> = active_blocks
6766            .iter()
6767            .map(|b| {
6768                let mut s = Array2::<f64>::zeros((p, p));
6769                let (rs, re) = (b.range.start, b.range.end);
6770                s.slice_mut(ndarray::s![rs..re, rs..re]).assign(&b.matrix);
6771                s
6772            })
6773            .collect();
6774        let penalty_specs: Vec<PenaltySpec> = active_blocks
6775            .iter()
6776            .map(|b| PenaltySpec::Block {
6777                local: b.matrix.clone(),
6778                col_range: b.range.clone(),
6779                prior_mean: gam_problem::CoefficientPriorMean::Zero,
6780                structure_hint: None,
6781                op: None,
6782            })
6783            .collect();
6784        let nullspace_dims: Vec<usize> = active_blocks.iter().map(|b| b.nullspace_dim).collect();
6785        let (canonical, _) = canonicalize_penalty_specs(
6786            &penalty_specs,
6787            &nullspace_dims,
6788            p,
6789            "rail-stability gate reparameterization",
6790        )
6791        .expect("canonicalize rail penalties");
6792        let invariant =
6793            precompute_reparam_invariant_from_canonical(&canonical, p).expect("reparam invariant");
6794        let reparam_prod =
6795            stable_reparameterizationwith_invariant(&canonical, &lambdas, p, &invariant)
6796                .expect("production reparameterization");
6797
6798        let hessian_logdet_mode = if rail_model
6799            .age_entry
6800            .iter()
6801            .any(|&t| t > ENTRY_AT_ORIGIN_THRESHOLD)
6802        {
6803            PseudoLogdetMode::HardPseudo
6804        } else {
6805            PseudoLogdetMode::Smooth
6806        };
6807
6808        // Deterministic non-identity orthogonal G (eigenvectors of a fixed
6809        // symmetric matrix — a genuine rotation, no RNG).
6810        let g = {
6811            let sym = Array2::<f64>::from_shape_fn((p, p), |(i, j)| {
6812                (((i * j) as f64) * 0.37).sin() + (((i + j) as f64) * 0.11 + 1.0).cos()
6813            });
6814            let (_, evecs) = sym.eigh(faer::Side::Lower).expect("orthogonal factor from eigh");
6815            evecs
6816        };
6817
6818        // Assemble the transformed-frame objective for a given reparameterization
6819        // and return the analytic ρ-gradient. Mirrors the post-helper body of
6820        // `unified_lamlobjective_and_rhogradient`.
6821        let grad_for_reparam = |reparam: &gam_terms::construction::ReparamResult| -> Array1<f64> {
6822            let provider = SurvivalDerivProvider::new(rail_model.clone(), beta_hat.clone());
6823            let ctx = RawInnerReparamContext {
6824                hessian: &h_dense,
6825                beta: &beta_hat,
6826                penalties_embedded: &s_k_embedded,
6827                lambdas: &lambdas,
6828            };
6829            let reparam_inner =
6830                assemble_reparameterized_inner(&ctx, Some(Box::new(provider)), reparam)
6831                    .expect("reparameterized inner assembly");
6832            let hop = DenseSpectralOperator::from_symmetric_with_mode(
6833                &reparam_inner.hessian_transformed,
6834                hessian_logdet_mode,
6835            )
6836            .expect("transformed Hessian operator");
6837            let penalty_coords = reparam
6838                .canonical_transformed
6839                .iter()
6840                .map(|cp| cp.to_penalty_coordinate())
6841                .collect::<Vec<_>>();
6842            let result = InnerAssembly {
6843                log_likelihood: state.log_likelihood,
6844                penalty_quadratic: state.penalty_term,
6845                beta: reparam_inner.beta_transformed,
6846                n_observations: rail_model.nrows(),
6847                hessian_op: std::sync::Arc::new(hop),
6848                // The survival lane prices its logdet on the operator its inner
6849                // solve converged against (#2612).
6850                mode_response_op: None,
6851                penalty_coords,
6852                penalty_logdet: reparam_inner.penalty_logdet,
6853                dispersion: DispersionHandling::Fixed {
6854                    phi: 1.0,
6855                    include_logdet_h: true,
6856                    include_logdet_s: true,
6857                },
6858                rho_curvature_scale: 1.0,
6859                rho_prior: gam_problem::RhoPrior::Flat,
6860                hessian_logdet_correction: 0.0,
6861                penalty_subspace_trace: None,
6862                deriv_provider: reparam_inner.deriv_provider,
6863                firth: None,
6864                nullspace_dim: None,
6865                barrier_config: None,
6866                ext_coords: Vec::new(),
6867                ext_coord_pair_fn: None,
6868                rho_ext_pair_fn: None,
6869                fixed_drift_deriv: None,
6870                contracted_psi_second_order: None,
6871                kkt_residual: None,
6872                active_constraints: None,
6873            }
6874            .evaluate(
6875                rho.as_slice().expect("contiguous rho"),
6876                EvalMode::ValueAndGradient,
6877                None,
6878            )
6879            .expect("transformed-frame LAML evaluate");
6880            result.gradient.expect("analytic ρ-gradient present")
6881        };
6882
6883        // Production frame Q_s.
6884        let g_prod = grad_for_reparam(&reparam_prod);
6885
6886        // Injected frame Q_s·G: post-compose G into Q_s and rotate the per-block
6887        // transformed roots by G so `penalty_coords` stay consistent with the
6888        // rotated frame (root·G lives in the Q_s·G basis).
6889        let mut reparam_conj = reparam_prod.clone();
6890        reparam_conj.qs = reparam_prod.qs.dot(&g);
6891        reparam_conj.canonical_transformed = reparam_prod
6892            .canonical_transformed
6893            .iter()
6894            .map(|cp| {
6895                let mut rotated = cp.clone();
6896                rotated.root = cp.root.dot(&g);
6897                rotated.local = rotated.root.t().dot(&rotated.root);
6898                rotated
6899            })
6900            .collect();
6901        let g_conj = grad_for_reparam(&reparam_conj);
6902
6903        for k in 0..rho.len() {
6904            let drift = (g_prod[k] - g_conj[k]).abs();
6905            assert!(
6906                drift <= DECISION_MARGIN * (1.0 + g_prod[k].abs()),
6907                "rail ρ-gradient not frame-invariant at coordinate {k}: \
6908                 Q_s frame {} vs Q_s·G frame {} (drift {:.3e} > margin {:.3e})",
6909                g_prod[k],
6910                g_conj[k],
6911                drift,
6912                DECISION_MARGIN * (1.0 + g_prod[k].abs())
6913            );
6914        }
6915    }
6916
6917}