Skip to main content

gam_models/survival/
construction.rs

1//! Survival model construction helpers.
2//!
3//! Types and functions for building survival model components:
4//! - Baseline hazard targets (Weibull, Gompertz, Gompertz-Makeham)
5//! - Time basis construction (I-spline on log-time)
6//! - Baseline offset computation
7//! - Time wiggle construction
8//!
9//! These are the building blocks a library consumer needs to construct
10//! a `FitRequest::SurvivalLocationScale` without going through the CLI.
11
12use crate::probability::{normal_pdf, standard_normal_quantile};
13use crate::survival::location_scale::{
14    DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD, ResidualDistribution,
15    SurvivalCovariateTermBlockTemplate,
16};
17use crate::survival::lognormal_kernel::HazardLoading;
18use crate::survival::marginal_slope::DEFAULT_SURVIVAL_MARGINAL_SLOPE_DERIVATIVE_GUARD;
19use crate::wiggle::{
20    WiggleBlockConfig, append_selected_wiggle_penalty_orders, buildwiggle_block_input_from_seed,
21    monotone_wiggle_basis_with_derivative_order, split_wiggle_penalty_orders,
22};
23use gam_linalg::matrix::{
24    DenseDesignMatrix, DesignMatrix, SparseDesignMatrix, symmetrize_in_place,
25};
26use gam_problem::outer_subsample::RowSet;
27use gam_problem::{InverseLink, StandardLink};
28use gam_terms::basis::{
29    BSplineBasisSpec, BSplineBoundaryConditions, BSplineIdentifiability, BSplineKnotSpec,
30    BasisMetadata, BasisOptions, Dense, KnotSource, OneDimensionalBoundary, build_bspline_basis_1d,
31    create_basis, evaluate_bspline_derivative_scalar,
32};
33use gam_terms::inference::formula_dsl::LinkWiggleFormulaSpec;
34use ndarray::{Array1, Array2, array, s};
35use rayon::prelude::*;
36
37// ---------------------------------------------------------------------------
38// Typed error
39// ---------------------------------------------------------------------------
40
41/// Structured failure surface for survival-model construction helpers
42/// (`parse_*`, baseline-config builders, time-basis construction). Every
43/// variant carries a free-form `reason: String` payload; `Display` emits
44/// that payload verbatim, so converting to `String` via the `From` impl
45/// produces text byte-equivalent to the pre-refactor `Err(format!(...))`
46/// call sites that were the only producers in this module.
47///
48/// The public CLI-input parsers (`parse_survival_distribution`,
49/// `parse_survival_likelihood_mode`, `parse_survival_baseline_config`)
50/// keep their `Result<_, String>` signatures — string is the natural
51/// failure type for free-form user input — and route through this enum
52/// internally via `From<SurvivalConstructionError> for String`.
53#[derive(Clone, Debug)]
54pub enum SurvivalConstructionError {
55    /// User-supplied configuration is malformed or out of range (knot
56    /// counts, anchor offsets, derivative guards, ranks).
57    InvalidConfig { reason: String },
58    /// A required column or block of metadata is absent (e.g. saved
59    /// survival ispline keep_cols, baseline target on a saved fit).
60    MissingColumn { reason: String },
61    /// Per-row / per-column shape disagreement (entry/exit lengths,
62    /// penalty rank vs basis width, basis vs coefficient counts).
63    IncompatibleDimensions { reason: String },
64    /// Numeric / domain rejection: non-finite ratios, non-positive
65    /// survival times, monotonicity violations, ispline-derivative
66    /// underflow.
67    DataValidationFailed { reason: String },
68    /// Underlying basis / penalty builder rejected the construction
69    /// request (invalid spline order, ispline keep_cols out of range,
70    /// internal empty ispline time basis).
71    BasisConstructionFailed { reason: String },
72    /// User-named distribution / likelihood-mode / baseline target /
73    /// time-basis kind is not one we recognise.
74    UnsupportedDistribution { reason: String },
75}
76
77impl_reason_error_boilerplate! {
78    SurvivalConstructionError {
79        InvalidConfig,
80        MissingColumn,
81        IncompatibleDimensions,
82        DataValidationFailed,
83        BasisConstructionFailed,
84        UnsupportedDistribution,
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Types
90// ---------------------------------------------------------------------------
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum SurvivalBaselineTarget {
94    /// No additional parametric target:
95    /// eta_target(t) = 0, so regularized model defaults to linear log-cumulative
96    /// hazard from the existing time basis.
97    Linear,
98    /// Parametric target: Weibull baseline.
99    ///
100    /// Transformation/cloglog survival uses `eta_target(t) = log(H0(t))`;
101    /// marginal-slope probit survival uses `q(t) = -Phi^-1(exp(-H0(t)))`.
102    Weibull,
103    /// Parametric target: Gompertz baseline.
104    ///
105    /// Transformation/cloglog survival uses `eta_target(t) = log(H0(t))`;
106    /// marginal-slope probit survival uses `q(t) = -Phi^-1(exp(-H0(t)))`.
107    Gompertz,
108    /// Parametric target: Gompertz-Makeham baseline.
109    ///
110    /// Transformation/cloglog survival uses `eta_target(t) = log(H0(t))`;
111    /// marginal-slope probit survival uses `q(t) = -Phi^-1(exp(-H0(t)))`.
112    GompertzMakeham,
113}
114
115#[derive(Clone, Debug)]
116pub struct SurvivalBaselineConfig {
117    pub target: SurvivalBaselineTarget,
118    pub scale: Option<f64>,
119    pub shape: Option<f64>,
120    pub rate: Option<f64>,
121    pub makeham: Option<f64>,
122}
123
124/// Recover the fitted Weibull baseline from anchor-centered linear
125/// `[1, log(t)]` time-basis coefficients.
126///
127/// Centering at `anchor` makes the constant coefficient unidentified. The
128/// fitted model is therefore `shape * (log(t) - log(anchor))`: the identified
129/// shape is `beta[1]` and the identified scale is the anchor itself. Consumers
130/// must not reconstruct the scale from the stale constant coefficient.
131pub fn fitted_weibull_baseline_from_linear_time_beta(
132    beta: &Array1<f64>,
133    anchor: f64,
134) -> Option<SurvivalBaselineConfig> {
135    if beta.len() < 2 {
136        return None;
137    }
138    let shape = beta[1];
139    if !shape.is_finite() || shape <= 0.0 || !anchor.is_finite() || anchor <= 0.0 {
140        return None;
141    }
142    Some(SurvivalBaselineConfig {
143        target: SurvivalBaselineTarget::Weibull,
144        scale: Some(anchor),
145        shape: Some(shape),
146        rate: None,
147        makeham: None,
148    })
149}
150
151#[derive(Clone, Debug)]
152pub enum SurvivalTimeBasisConfig {
153    None,
154    Linear,
155    BSpline {
156        degree: usize,
157        knots: Array1<f64>,
158        smooth_lambda: f64,
159    },
160    /// I-spline value rows on the `log(t)` axis with non-negative
161    /// coefficients (`γ ≥ 0`) enforcing structural monotonicity of
162    /// `q(t) = I_basis(log t) · γ`. This replaces the row-wise
163    /// `D β + o ≥ guard` derivative-guard constraints the marginal-slope
164    /// family previously relied on.
165    ///
166    /// The design builder lives below at `_build_time_block`'s
167    /// `SurvivalTimeBasisConfig::ISpline` arm and exposes:
168    ///
169    /// * `x_entry_time` / `x_exit_time` — I-spline value rows on the
170    ///   `log(t)` axis. Non-negative entries plus `γ ≥ 0` give a
171    ///   monotone-non-decreasing `q(t)`, the structural property the
172    ///   marginal-slope family needs.
173    /// * `x_derivative_time` — right-cumulative B-spline-derivative on
174    ///   `log(t)` scaled by `1/t`, again non-negative with `γ ≥ 0`, so
175    ///   `q'(t) ≥ 0` pointwise. The `derivative_guard` constant is added
176    ///   externally by [`add_survival_time_derivative_guard_offset`],
177    ///   leaving the derivative guarantee `q'(t) ≥ guard` exact.
178    /// * 2nd-difference penalty on the underlying degree-`(k+1)` B-spline
179    ///   coefficients, filtered through `keep_cols` for identifiability.
180    ///
181    /// `TimeBlockInput::time_monotonicity` declares to the consuming
182    /// family how monotonicity is enforced. The marginal-slope
183    /// construction site sets it to
184    /// [`crate::survival::location_scale::TimeBlockMonotonicity::StructuralISpline`]
185    /// so the family skips row-wise `D β + o ≥ guard` constraint
186    /// generation and treats `γ ≥ 0` as the sole derivative-guard
187    /// mechanism. The universal `validate_time_qd1_feasible` safety net
188    /// runs regardless.
189    ///
190    /// An earlier iteration proposed a separate C-spline antiderivative
191    /// parameterization that put `q'(t)` in the I-spline space and `q(t)`
192    /// in the integral-of-I-spline space. That was mathematically
193    /// equivalent but a strictly worse fit for the codebase (extra basis
194    /// degree, an extra antiderivative builder, an extra identifiability
195    /// path, an extra penalty); it was removed in favor of the canonical
196    /// I-spline-value path here.
197    ISpline {
198        degree: usize,
199        knots: Array1<f64>,
200        keep_cols: Vec<usize>,
201        smooth_lambda: f64,
202    },
203}
204
205/// Persistable snapshot of the time-basis state used by a survival fit.
206///
207/// Every survival family routes through [`SurvivalTimeBuildOutput`] during
208/// the fit, but the FFI save path needs only the metadata — not the full
209/// design matrices. This struct is the single source of truth that flows
210/// from the workflow-level basis construction, through the family-specific
211/// fit result, into the saved-model payload via
212/// [`crate::inference::model::FittedModelPayload::apply_survival_time_basis`].
213///
214/// Threading this snapshot end-to-end eliminates the prior bug pattern
215/// where each FFI builder had to reconstruct the metadata from
216/// `fit_config` + the formula (silent drift risk; one builder forgetting
217/// to do so caused the marginal-slope save→load break).
218#[derive(Clone, Debug, PartialEq)]
219pub struct SavedSurvivalTimeBasis {
220    pub basisname: String,
221    pub degree: Option<usize>,
222    pub knots: Option<Vec<f64>>,
223    pub keep_cols: Option<Vec<usize>>,
224    pub smooth_lambda: Option<f64>,
225    pub anchor: f64,
226}
227
228impl SavedSurvivalTimeBasis {
229    /// Build a snapshot from the realised time-basis state and the entry
230    /// anchor that was used during the fit.
231    pub fn from_build(build: &SurvivalTimeBuildOutput, anchor: f64) -> Self {
232        Self {
233            basisname: build.basisname.clone(),
234            degree: build.degree,
235            knots: build.knots.clone(),
236            keep_cols: build.keep_cols.clone(),
237            smooth_lambda: build.smooth_lambda,
238            anchor,
239        }
240    }
241}
242
243#[derive(Clone)]
244pub struct SurvivalTimeBuildOutput {
245    pub x_entry_time: DesignMatrix,
246    pub x_exit_time: DesignMatrix,
247    pub x_derivative_time: DesignMatrix,
248    pub penalties: Vec<Array2<f64>>,
249    /// Structural nullspace dimension of each penalty matrix.
250    pub nullspace_dims: Vec<usize>,
251    pub basisname: String,
252    pub degree: Option<usize>,
253    pub knots: Option<Vec<f64>>,
254    pub keep_cols: Option<Vec<usize>>,
255    pub smooth_lambda: Option<f64>,
256}
257
258pub const SURVIVAL_TIME_FLOOR: f64 = 1e-9;
259
260/// Entry ages above this value mark genuine left truncation (delayed entry): the
261/// row's cumulative-hazard interval starts at a positive left-tail time rather
262/// than the time origin. Kept in lockstep with the working-model's
263/// `ENTRY_AT_ORIGIN_THRESHOLD` so the "this row has an entry interval" and "the
264/// data is left-truncated" decisions agree.
265pub const SURVIVAL_DELAYED_ENTRY_THRESHOLD: f64 = 1e-8;
266
267/// Seed smoothing penalty `λ` used when a survival time basis is reconstructed
268/// from a build (or saved model) that did not carry an explicit `smooth_lambda`.
269/// This is only an initial value for the REML smoothing search, not a fixed
270/// policy: a small positive seed keeps the baseline spline lightly regularized
271/// at the start so the outer optimizer begins from a well-conditioned point and
272/// then adapts `λ` to the data. Kept in one place so the b-spline and i-spline
273/// reconstruction paths cannot drift apart.
274const SURVIVAL_TIME_SMOOTH_LAMBDA_SEED: f64 = 1e-2;
275
276/// Default initial Gompertz / Gompertz-Makeham shape parameter when the user
277/// does not supply `--baseline-shape`. The Gompertz hazard is
278/// `h(t) = rate · exp(shape · t)`; a near-zero shape seeds the baseline at an
279/// almost-flat (exponential-like) hazard, letting the fit grow the
280/// age-acceleration term from the data rather than committing to a strong
281/// curvature up front. Shared by the parse and fit-seed paths so both start
282/// from the same neutral shape.
283const GOMPERTZ_DEFAULT_SHAPE_SEED: f64 = 0.01;
284
285#[derive(Clone, Copy, Debug, PartialEq, Eq)]
286pub enum SurvivalLikelihoodMode {
287    Transformation,
288    Weibull,
289    LocationScale,
290    MarginalSlope,
291    Latent,
292    LatentBinary,
293}
294
295pub struct SurvivalTimeWiggleBuild {
296    pub penalties: Vec<Array2<f64>>,
297    pub nullspace_dims: Vec<usize>,
298    pub knots: Array1<f64>,
299    pub degree: usize,
300    pub ncols: usize,
301}
302
303// ---------------------------------------------------------------------------
304// Time normalization
305// ---------------------------------------------------------------------------
306
307pub fn normalize_survival_time_pair(
308    entry_raw: f64,
309    exit_raw: f64,
310    row_index: usize,
311) -> Result<(f64, f64), String> {
312    if !entry_raw.is_finite() || !exit_raw.is_finite() {
313        return Err(SurvivalConstructionError::DataValidationFailed {
314            reason: format!("non-finite survival times at row {}", row_index + 1),
315        }
316        .into());
317    }
318    if entry_raw < 0.0 || exit_raw < 0.0 {
319        return Err(SurvivalConstructionError::DataValidationFailed {
320            reason: format!("negative survival times at row {}", row_index + 1),
321        }
322        .into());
323    }
324
325    let entry = entry_raw.max(SURVIVAL_TIME_FLOOR);
326    let exit = exit_raw.max(entry + SURVIVAL_TIME_FLOOR);
327    Ok((entry, exit))
328}
329
330// ---------------------------------------------------------------------------
331// Basis monotonicity helpers
332// ---------------------------------------------------------------------------
333
334pub fn survival_basis_supports_structural_monotonicity(basisname: &str) -> bool {
335    basisname.eq_ignore_ascii_case("ispline")
336}
337
338pub fn require_structural_survival_time_basis(
339    basisname: &str,
340    context: &str,
341) -> Result<(), String> {
342    if survival_basis_supports_structural_monotonicity(basisname) {
343        return Ok(());
344    }
345    Err(SurvivalConstructionError::UnsupportedDistribution {
346        reason: format!(
347            "{context} requires a structural monotone survival time basis, but got '{basisname}'. \
348Only `ispline` is accepted here because its basis functions enforce a monotone cumulative time effect by construction. \
349`{basisname}` can fit non-monotone shapes, which can break survival semantics. \
350Re-run with `--time-basis ispline`."
351        ),
352    }
353    .into())
354}
355
356// ---------------------------------------------------------------------------
357// Baseline config parsing
358// ---------------------------------------------------------------------------
359
360pub fn parse_survival_baseline_config(
361    target_raw: &str,
362    scale: Option<f64>,
363    shape: Option<f64>,
364    rate: Option<f64>,
365    makeham: Option<f64>,
366) -> Result<SurvivalBaselineConfig, String> {
367    let target = match target_raw.to_ascii_lowercase().as_str() {
368        "linear" => SurvivalBaselineTarget::Linear,
369        "weibull" => SurvivalBaselineTarget::Weibull,
370        "gompertz" => SurvivalBaselineTarget::Gompertz,
371        "gompertz-makeham" => SurvivalBaselineTarget::GompertzMakeham,
372        other => {
373            return Err(SurvivalConstructionError::UnsupportedDistribution {
374                reason: format!(
375                    "unsupported --baseline-target '{other}'; use linear|weibull|gompertz|gompertz-makeham"
376                ),
377            }
378            .into());
379        }
380    };
381
382    match target {
383        SurvivalBaselineTarget::Linear => Ok(SurvivalBaselineConfig {
384            target,
385            scale: None,
386            shape: None,
387            rate: None,
388            makeham: None,
389        }),
390        SurvivalBaselineTarget::Weibull => {
391            let scale = scale.ok_or_else(|| {
392                "--baseline-target weibull requires --baseline-scale > 0".to_string()
393            })?;
394            let shape = shape.ok_or_else(|| {
395                "--baseline-target weibull requires --baseline-shape > 0".to_string()
396            })?;
397            if !scale.is_finite() || scale <= 0.0 || !shape.is_finite() || shape <= 0.0 {
398                return Err(
399                    "weibull baseline requires finite positive --baseline-scale and --baseline-shape"
400                        .to_string(),
401                );
402            }
403            Ok(SurvivalBaselineConfig {
404                target,
405                scale: Some(scale),
406                shape: Some(shape),
407                rate: None,
408                makeham: None,
409            })
410        }
411        SurvivalBaselineTarget::Gompertz => {
412            let rate = rate.unwrap_or(1.0);
413            let shape = shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED);
414            if !rate.is_finite() || rate <= 0.0 || !shape.is_finite() {
415                return Err(
416                    "gompertz baseline requires finite --baseline-shape and positive --baseline-rate"
417                        .to_string(),
418                );
419            }
420            Ok(SurvivalBaselineConfig {
421                target,
422                scale: None,
423                shape: Some(shape),
424                rate: Some(rate),
425                makeham: None,
426            })
427        }
428        SurvivalBaselineTarget::GompertzMakeham => {
429            let rate = rate.unwrap_or(0.5);
430            let shape = shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED);
431            let makeham = makeham.unwrap_or(0.5);
432            if !rate.is_finite()
433                || rate <= 0.0
434                || !shape.is_finite()
435                || !makeham.is_finite()
436                || makeham <= 0.0
437            {
438                return Err(
439                    "gompertz-makeham baseline requires finite --baseline-shape, positive --baseline-rate, and positive --baseline-makeham"
440                        .to_string(),
441                );
442            }
443            Ok(SurvivalBaselineConfig {
444                target,
445                scale: None,
446                shape: Some(shape),
447                rate: Some(rate),
448                makeham: Some(makeham),
449            })
450        }
451    }
452}
453
454// ---------------------------------------------------------------------------
455// Likelihood mode / distribution parsing
456// ---------------------------------------------------------------------------
457
458pub fn parse_survival_likelihood_mode(raw: &str) -> Result<SurvivalLikelihoodMode, String> {
459    match raw.to_ascii_lowercase().as_str() {
460        "transformation" => Ok(SurvivalLikelihoodMode::Transformation),
461        "weibull" => Ok(SurvivalLikelihoodMode::Weibull),
462        "location-scale" => Ok(SurvivalLikelihoodMode::LocationScale),
463        "marginal-slope" => Ok(SurvivalLikelihoodMode::MarginalSlope),
464        "latent" => Ok(SurvivalLikelihoodMode::Latent),
465        "latent-binary" => Ok(SurvivalLikelihoodMode::LatentBinary),
466        other => Err(SurvivalConstructionError::UnsupportedDistribution {
467            reason: format!(
468                "unsupported --survival-likelihood '{other}'; use transformation|weibull|location-scale|marginal-slope|latent|latent-binary"
469            ),
470        }
471        .into()),
472    }
473}
474
475pub const fn survival_likelihood_modename(mode: SurvivalLikelihoodMode) -> &'static str {
476    match mode {
477        SurvivalLikelihoodMode::Transformation => "transformation",
478        SurvivalLikelihoodMode::Weibull => "weibull",
479        SurvivalLikelihoodMode::LocationScale => "location-scale",
480        SurvivalLikelihoodMode::MarginalSlope => "marginal-slope",
481        SurvivalLikelihoodMode::Latent => "latent",
482        SurvivalLikelihoodMode::LatentBinary => "latent-binary",
483    }
484}
485
486pub fn parse_survival_distribution(raw: &str) -> Result<ResidualDistribution, String> {
487    match raw.to_ascii_lowercase().as_str() {
488        "gaussian" | "probit" => Ok(ResidualDistribution::Gaussian),
489        "gumbel" | "cloglog" => Ok(ResidualDistribution::Gumbel),
490        "logistic" | "logit" => Ok(ResidualDistribution::Logistic),
491        other => Err(SurvivalConstructionError::UnsupportedDistribution {
492            reason: format!(
493                "unsupported survmodel(distribution='{other}'); accepted: gaussian / probit, gumbel / cloglog, logistic / logit"
494            ),
495        }
496        .into()),
497    }
498}
499
500pub const fn survival_baseline_targetname(target: SurvivalBaselineTarget) -> &'static str {
501    match target {
502        SurvivalBaselineTarget::Linear => "linear",
503        SurvivalBaselineTarget::Weibull => "weibull",
504        SurvivalBaselineTarget::Gompertz => "gompertz",
505        SurvivalBaselineTarget::GompertzMakeham => "gompertz-makeham",
506    }
507}
508
509pub fn positive_survival_time_seed(age_exit: &Array1<f64>) -> f64 {
510    let sum = age_exit
511        .iter()
512        .copied()
513        .filter(|value| value.is_finite() && *value > 0.0)
514        .sum::<f64>();
515    let count = age_exit
516        .iter()
517        .filter(|value| value.is_finite() && **value > 0.0)
518        .count()
519        .max(1);
520    (sum / count as f64).max(SURVIVAL_TIME_FLOOR)
521}
522
523pub fn initial_survival_baseline_config_for_fit(
524    target_raw: &str,
525    scale: Option<f64>,
526    shape: Option<f64>,
527    rate: Option<f64>,
528    makeham: Option<f64>,
529    age_exit: &Array1<f64>,
530) -> Result<SurvivalBaselineConfig, String> {
531    let target = match target_raw.trim().to_ascii_lowercase().as_str() {
532        "linear" => SurvivalBaselineTarget::Linear,
533        "weibull" => SurvivalBaselineTarget::Weibull,
534        "gompertz" => SurvivalBaselineTarget::Gompertz,
535        "gompertz-makeham" => SurvivalBaselineTarget::GompertzMakeham,
536        other => {
537            return Err(SurvivalConstructionError::UnsupportedDistribution {
538                reason: format!(
539                    "unsupported --baseline-target '{other}'; use linear|weibull|gompertz|gompertz-makeham"
540                ),
541            }
542            .into());
543        }
544    };
545    let time_scale_seed = positive_survival_time_seed(age_exit);
546    let cfg = match target {
547        SurvivalBaselineTarget::Linear => SurvivalBaselineConfig {
548            target,
549            scale: None,
550            shape: None,
551            rate: None,
552            makeham: None,
553        },
554        SurvivalBaselineTarget::Weibull => SurvivalBaselineConfig {
555            target,
556            scale: Some(scale.unwrap_or(time_scale_seed)),
557            shape: Some(shape.unwrap_or(1.0)),
558            rate: None,
559            makeham: None,
560        },
561        SurvivalBaselineTarget::Gompertz => SurvivalBaselineConfig {
562            target,
563            scale: None,
564            shape: Some(shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED)),
565            rate: Some(rate.unwrap_or(1.0 / time_scale_seed)),
566            makeham: None,
567        },
568        SurvivalBaselineTarget::GompertzMakeham => SurvivalBaselineConfig {
569            target,
570            scale: None,
571            shape: Some(shape.unwrap_or(GOMPERTZ_DEFAULT_SHAPE_SEED)),
572            rate: Some(rate.unwrap_or(0.5 / time_scale_seed)),
573            makeham: Some(makeham.unwrap_or(0.5 / time_scale_seed)),
574        },
575    };
576    parse_survival_baseline_config(
577        survival_baseline_targetname(cfg.target),
578        cfg.scale,
579        cfg.shape,
580        cfg.rate,
581        cfg.makeham,
582    )
583}
584
585fn survival_baseline_theta_from_config(
586    cfg: &SurvivalBaselineConfig,
587) -> Result<Option<Array1<f64>>, String> {
588    Ok(match cfg.target {
589        SurvivalBaselineTarget::Linear => None,
590        SurvivalBaselineTarget::Weibull => Some(array![
591            cfg.scale
592                .ok_or_else(|| "missing weibull baseline scale".to_string())?
593                .ln(),
594            cfg.shape
595                .ok_or_else(|| "missing weibull baseline shape".to_string())?
596                .ln(),
597        ]),
598        SurvivalBaselineTarget::Gompertz => Some(array![
599            cfg.rate
600                .ok_or_else(|| "missing gompertz baseline rate".to_string())?
601                .ln(),
602            cfg.shape
603                .ok_or_else(|| "missing gompertz baseline shape".to_string())?,
604        ]),
605        SurvivalBaselineTarget::GompertzMakeham => Some(array![
606            cfg.rate
607                .ok_or_else(|| "missing gompertz-makeham baseline rate".to_string())?
608                .ln(),
609            cfg.shape
610                .ok_or_else(|| "missing gompertz-makeham baseline shape".to_string())?,
611            cfg.makeham
612                .ok_or_else(|| "missing gompertz-makeham baseline makeham".to_string())?
613                .ln(),
614        ]),
615    })
616}
617
618fn survival_baseline_config_from_theta(
619    target: SurvivalBaselineTarget,
620    theta: &Array1<f64>,
621) -> Result<SurvivalBaselineConfig, String> {
622    let cfg = match target {
623        SurvivalBaselineTarget::Linear => SurvivalBaselineConfig {
624            target,
625            scale: None,
626            shape: None,
627            rate: None,
628            makeham: None,
629        },
630        SurvivalBaselineTarget::Weibull => {
631            if theta.len() != 2 {
632                return Err(SurvivalConstructionError::IncompatibleDimensions {
633                    reason: format!(
634                        "weibull baseline parameter dimension mismatch: expected 2, got {}",
635                        theta.len()
636                    ),
637                }
638                .into());
639            }
640            SurvivalBaselineConfig {
641                target,
642                scale: Some(theta[0].exp()),
643                shape: Some(theta[1].exp()),
644                rate: None,
645                makeham: None,
646            }
647        }
648        SurvivalBaselineTarget::Gompertz => {
649            if theta.len() != 2 {
650                return Err(SurvivalConstructionError::IncompatibleDimensions {
651                    reason: format!(
652                        "gompertz baseline parameter dimension mismatch: expected 2, got {}",
653                        theta.len()
654                    ),
655                }
656                .into());
657            }
658            SurvivalBaselineConfig {
659                target,
660                scale: None,
661                shape: Some(theta[1]),
662                rate: Some(theta[0].exp()),
663                makeham: None,
664            }
665        }
666        SurvivalBaselineTarget::GompertzMakeham => {
667            if theta.len() != 3 {
668                return Err(SurvivalConstructionError::IncompatibleDimensions {
669                    reason: format!(
670                        "gompertz-makeham baseline parameter dimension mismatch: expected 3, got {}",
671                        theta.len()
672                    ),
673                }
674                .into());
675            }
676            SurvivalBaselineConfig {
677                target,
678                scale: None,
679                shape: Some(theta[1]),
680                rate: Some(theta[0].exp()),
681                makeham: Some(theta[2].exp()),
682            }
683        }
684    };
685    parse_survival_baseline_config(
686        survival_baseline_targetname(cfg.target),
687        cfg.scale,
688        cfg.shape,
689        cfg.rate,
690        cfg.makeham,
691    )
692}
693
694/// Derivative contract for the shared baseline-θ outer optimizer.
695///
696/// The two public baseline optimizers (`…_with_gradient_only`,
697/// `…_with_gradient`) differ in exactly one axis: how much derivative
698/// information the objective closure supplies, and therefore which curvature
699/// declaration the `OuterProblem` must advertise. Every baseline-θ path now
700/// supplies an exact analytic gradient (profile-NLL envelope gradient), so both
701/// contracts route to a gradient-based solver. Everything else — θ↔config
702/// conversion, the ±6 log-space box,
703/// the single-seed config, the `run`/convergence/error-formatting boilerplate
704/// — is identical, so it lives once in [`run_baseline_theta_optimizer`] and
705/// this enum selects the per-contract `OuterProblem` configuration.
706#[derive(Clone, Copy, Debug, PartialEq, Eq)]
707enum BaselineDerivativeContract {
708    /// Cost + analytic gradient, no analytic Hessian. Routes to BFGS, which
709    /// builds its own quasi-Newton curvature from successive gradients.
710    GradientOnly,
711    /// Cost + analytic gradient + analytic Hessian. Routes to the primary
712    /// second-order outer solver, which may use either the analytic Hessian or
713    /// a BFGS approximation depending on the planner.
714    GradientHessian,
715}
716
717impl BaselineDerivativeContract {
718    /// Apply this contract's derivative declaration, solver class, tolerance,
719    /// and iteration budget to a freshly-constructed `OuterProblem`. The
720    /// bounds, initial ρ, and seed config are contract-independent and applied
721    /// by [`run_baseline_theta_optimizer`].
722    fn configure(
723        self,
724        problem: gam_solve::rho_optimizer::OuterProblem,
725    ) -> gam_solve::rho_optimizer::OuterProblem {
726        use gam_problem::{DeclaredHessianForm, Derivative};
727        match self {
728            // BFGS on a 2–3 dim problem with an exact gradient typically
729            // converges in 5–10 outer evaluations.
730            BaselineDerivativeContract::GradientOnly => problem
731                .with_gradient(Derivative::Analytic)
732                .with_hessian(DeclaredHessianForm::Unavailable)
733                .with_tolerance(1e-4)
734                .with_max_iter(240),
735            BaselineDerivativeContract::GradientHessian => problem
736                .with_gradient(Derivative::Analytic)
737                .with_hessian(DeclaredHessianForm::Either)
738                .with_tolerance(1e-4)
739                .with_max_iter(240),
740        }
741    }
742}
743
744/// Shared engine behind the three public baseline-config optimizers.
745///
746/// Owns every step that is identical across the cost-only, gradient-only, and
747/// gradient+Hessian contracts: config→θ seeding (with the linear/no-parameter
748/// early return), the ±6 log-space box, the single-seed `OuterProblem`
749/// skeleton, derivative-contract configuration, `build_objective` wiring,
750/// `run`, the convergence check + error formatting, and θ→config. The only
751/// contract-specific inputs are the already-wired `cost_fn`/`eval_fn` closures
752/// (which embed the derivative shape and dimension validation) and the
753/// `contract` selecting the `OuterProblem` derivative declaration.
754fn run_baseline_theta_optimizer<Fc, Fe>(
755    initial: &SurvivalBaselineConfig,
756    context: &str,
757    contract: BaselineDerivativeContract,
758    cost_fn: Fc,
759    eval_fn: Fe,
760) -> Result<SurvivalBaselineConfig, String>
761where
762    Fc: FnMut(&mut (), &Array1<f64>) -> Result<f64, crate::model_types::EstimationError>,
763    Fe: FnMut(
764        &mut (),
765        &Array1<f64>,
766    ) -> Result<gam_problem::OuterEval, crate::model_types::EstimationError>,
767{
768    use gam_solve::rho_optimizer::OuterProblem;
769    let Some(seed) = survival_baseline_theta_from_config(initial)? else {
770        return Ok(initial.clone());
771    };
772    let dim = seed.len();
773    let target = initial.target;
774    let lower = seed.mapv(|v| v - 6.0);
775    let upper = seed.mapv(|v| v + 6.0);
776    let problem = contract
777        .configure(OuterProblem::new(dim))
778        .with_bounds(lower, upper)
779        .with_initial_rho(seed.clone())
780        .with_seed_config(crate::seeding::SeedConfig {
781            max_seeds: 1,
782            seed_budget: 1,
783            num_auxiliary_trailing: dim,
784            ..Default::default()
785        });
786    let mut obj = problem.build_objective(
787        (),
788        cost_fn,
789        eval_fn,
790        None::<fn(&mut ())>,
791        None::<
792            fn(
793                &mut (),
794                &Array1<f64>,
795            ) -> Result<gam_problem::EfsEval, crate::model_types::EstimationError>,
796        >,
797    );
798    let result = problem
799        .run(&mut obj, context)
800        .map_err(|e| format!("{context} failed: {e}"))?;
801    if !result.converged {
802        return Err(SurvivalConstructionError::InvalidConfig {
803            reason: format!(
804                "{context} did not converge after {} iterations (final_objective={:.6e}, final_grad_norm={})",
805                result.iterations,
806                result.final_value,
807                result.final_grad_norm_report(),
808            ),
809        }
810        .into());
811    }
812    survival_baseline_config_from_theta(target, &result.rho)
813}
814
815/// Shared engine for the two derivative-carrying baseline-config optimizers.
816///
817/// Both `…_with_gradient_only` and `…_with_gradient` route an objective that
818/// returns a fully-populated [`OuterEval`](gam_problem::OuterEval)
819/// (cost + analytic gradient, optionally + analytic Hessian) for a given
820/// config. Everything downstream of that — the `Rc<RefCell>` sharing that lets
821/// the same user closure back both the `cost_fn` and `eval_fn`, the θ→config
822/// conversion, and deriving the scalar `cost_fn` from the eval result — is
823/// identical, so it lives here once. The contract-specific axis is only which
824/// `HessianValue` the objective embeds, which the wrapper has already encoded
825/// in the returned `OuterEval`, so this helper is contract-agnostic beyond the
826/// `contract` it forwards to [`run_baseline_theta_optimizer`].
827fn run_baseline_theta_optimizer_with_eval<F>(
828    initial: &SurvivalBaselineConfig,
829    context: &str,
830    contract: BaselineDerivativeContract,
831    objective: F,
832) -> Result<SurvivalBaselineConfig, String>
833where
834    F: FnMut(&SurvivalBaselineConfig) -> Result<gam_problem::OuterEval, String>,
835{
836    let target = initial.target;
837    let engine_context = context.to_string();
838    let objective = std::rc::Rc::new(std::cell::RefCell::new(objective));
839    let eval_at = move |obj: &std::rc::Rc<std::cell::RefCell<F>>,
840                        theta: &Array1<f64>|
841          -> Result<gam_problem::OuterEval, crate::model_types::EstimationError> {
842        let cfg = survival_baseline_config_from_theta(target, theta)
843            .map_err(crate::model_types::EstimationError::InvalidInput)?;
844        let eval =
845            obj.borrow_mut()(&cfg).map_err(crate::model_types::EstimationError::InvalidInput)?;
846        if eval.gradient.len() != theta.len() {
847            return Err(crate::model_types::EstimationError::InvalidInput(format!(
848                "{engine_context}: baseline gradient dimension mismatch: got {}, expected {}",
849                eval.gradient.len(),
850                theta.len()
851            )));
852        }
853        if let gam_problem::HessianValue::Dense(ref h) = eval.hessian {
854            if h.nrows() != theta.len() || h.ncols() != theta.len() {
855                return Err(crate::model_types::EstimationError::InvalidInput(format!(
856                    "{engine_context}: baseline Hessian dimension mismatch: got {}x{}, expected {}x{}",
857                    h.nrows(),
858                    h.ncols(),
859                    theta.len(),
860                    theta.len()
861                )));
862            }
863        }
864        Ok(eval)
865    };
866    let cost_objective = std::rc::Rc::clone(&objective);
867    let cost_eval = eval_at.clone();
868    let cost_fn = move |_: &mut (), theta: &Array1<f64>| {
869        cost_eval(&cost_objective, theta).map(|eval| eval.cost)
870    };
871    let eval_fn = move |_: &mut (), theta: &Array1<f64>| eval_at(&objective, theta);
872    run_baseline_theta_optimizer(initial, context, contract, cost_fn, eval_fn)
873}
874
875/// Gradient-only outer baseline-config optimizer. Thin adapter over
876/// [`run_baseline_theta_optimizer`] under the
877/// [`BaselineDerivativeContract::GradientOnly`] contract, which advertises
878/// `DeclaredHessianForm::Unavailable`, so the planner routes to BFGS and
879/// builds its own quasi-Newton curvature from successive gradient
880/// evaluations. Used by the survival location-scale path which has a
881/// closed-form θ-gradient (`baseline_chain_rule_gradient` /
882/// `marginal_slope_baseline_chain_rule_gradient`) but no native analytic
883/// θ-Hessian; BFGS on a 2–3 dim problem with an exact gradient typically
884/// converges in 5–10 outer evaluations.
885pub fn optimize_survival_baseline_config_with_gradient_only<F>(
886    initial: &SurvivalBaselineConfig,
887    context: &str,
888    mut objective: F,
889) -> Result<SurvivalBaselineConfig, String>
890where
891    F: FnMut(&SurvivalBaselineConfig) -> Result<(f64, Array1<f64>), String>,
892{
893    use gam_problem::{HessianValue, OuterEval};
894    run_baseline_theta_optimizer_with_eval(
895        initial,
896        context,
897        BaselineDerivativeContract::GradientOnly,
898        move |cfg| {
899            let (cost, gradient) = objective(cfg)?;
900            Ok(OuterEval {
901                cost,
902                gradient,
903                hessian: HessianValue::Unavailable,
904                inner_beta_hint: None,
905            })
906        },
907    )
908}
909
910/// Gradient + Hessian outer baseline-config optimizer. Thin adapter over
911/// [`run_baseline_theta_optimizer`] under the
912/// [`BaselineDerivativeContract::GradientHessian`] contract, which advertises
913/// an analytic θ-Hessian so the primary second-order outer solver can use it.
914pub fn optimize_survival_baseline_config_with_gradient<F>(
915    initial: &SurvivalBaselineConfig,
916    context: &str,
917    mut objective: F,
918) -> Result<SurvivalBaselineConfig, String>
919where
920    F: FnMut(&SurvivalBaselineConfig) -> Result<(f64, Array1<f64>, Array2<f64>), String>,
921{
922    use gam_problem::{HessianValue, OuterEval};
923    run_baseline_theta_optimizer_with_eval(
924        initial,
925        context,
926        BaselineDerivativeContract::GradientHessian,
927        move |cfg| {
928            let (cost, gradient, hessian) = objective(cfg)?;
929            Ok(OuterEval {
930                cost,
931                gradient,
932                hessian: HessianValue::Dense(hessian),
933                inner_beta_hint: None,
934            })
935        },
936    )
937}
938
939// ---------------------------------------------------------------------------
940// Time basis config (library-friendly: takes primitives, not CLI args)
941// ---------------------------------------------------------------------------
942
943pub fn parse_survival_time_basis_config(
944    time_basis: &str,
945    time_degree: usize,
946    time_num_internal_knots: usize,
947    time_smooth_lambda: f64,
948) -> Result<SurvivalTimeBasisConfig, String> {
949    match time_basis.to_ascii_lowercase().as_str() {
950        "none" => Ok(SurvivalTimeBasisConfig::None),
951        "ispline" => {
952            if time_degree < 1 {
953                return Err(
954                    "time-basis degree must be >= 1 for ispline time basis (CLI: --time-degree; Python: time_degree=)"
955                        .to_string(),
956                );
957            }
958            if time_num_internal_knots == 0 {
959                return Err(
960                    "time-basis must have > 0 internal knots for ispline time basis (CLI: --time-num-internal-knots; Python: time_num_internal_knots=)"
961                        .to_string(),
962                );
963            }
964            if !time_smooth_lambda.is_finite() || time_smooth_lambda < 0.0 {
965                return Err(
966                    "time-basis smoothing lambda must be finite and >= 0 (CLI: --time-smooth-lambda; Python: time_smooth_lambda=)"
967                        .to_string(),
968                );
969            }
970            Ok(SurvivalTimeBasisConfig::ISpline {
971                degree: time_degree,
972                knots: Array1::zeros(0),
973                keep_cols: Vec::new(),
974                smooth_lambda: time_smooth_lambda,
975            })
976        }
977        "linear" | "bspline" => {
978            // Forward to the shared structural-basis check so error text
979            // stays consistent with every other call site. `linear` /
980            // `bspline` are not structural, so this always returns Err;
981            // we map a (currently impossible) `Ok` to an explicit error
982            // string instead of `unreachable!`, keeping the match total
983            // without relying on a never-executes claim.
984            match require_structural_survival_time_basis(time_basis, "survival model configuration")
985            {
986                Err(e) => Err(e),
987                Ok(()) => Err(format!(
988                    "internal: structural-basis check accepted non-structural \
989                     survival time basis '{time_basis}'"
990                )),
991            }
992        }
993        other => Err(format!(
994            "unsupported --time-basis '{other}'; accepted values: ispline, none"
995        )),
996    }
997}
998
999// ---------------------------------------------------------------------------
1000// Time basis construction
1001// ---------------------------------------------------------------------------
1002
1003pub fn build_survival_time_basis(
1004    age_entry: &Array1<f64>,
1005    age_exit: &Array1<f64>,
1006    cfg: SurvivalTimeBasisConfig,
1007    infer_knots_if_needed: Option<(usize, f64)>,
1008) -> Result<SurvivalTimeBuildOutput, String> {
1009    fn checked_log_survival_times(times: &Array1<f64>, label: &str) -> Result<Array1<f64>, String> {
1010        if let Some(row) = times.iter().position(|t| !t.is_finite()) {
1011            return Err(SurvivalConstructionError::DataValidationFailed {
1012                reason: format!(
1013                    "survival time basis requires finite {label} times (row {})",
1014                    row + 1
1015                ),
1016            }
1017            .into());
1018        }
1019        if let Some(row) = times.iter().position(|t| *t < 0.0) {
1020            return Err(SurvivalConstructionError::DataValidationFailed {
1021                reason: format!(
1022                    "survival time basis requires non-negative {label} times (row {})",
1023                    row + 1
1024                ),
1025            }
1026            .into());
1027        }
1028        Ok(times.mapv(|t| t.max(SURVIVAL_TIME_FLOOR).ln()))
1029    }
1030
1031    let n = age_entry.len();
1032    if n != age_exit.len() {
1033        return Err(SurvivalConstructionError::IncompatibleDimensions {
1034            reason: "survival time basis requires matching entry/exit lengths".to_string(),
1035        }
1036        .into());
1037    }
1038    for i in 0..n {
1039        if age_exit[i] < age_entry[i] {
1040            return Err(format!(
1041                "survival time basis requires exit times >= entry times (row {})",
1042                i + 1
1043            ));
1044        }
1045    }
1046    let log_entry = checked_log_survival_times(age_entry, "entry")?;
1047    let log_exit = checked_log_survival_times(age_exit, "exit")?;
1048
1049    fn survival_time_knot_input(log_entry: &Array1<f64>, log_exit: &Array1<f64>) -> Array1<f64> {
1050        let n = log_entry.len();
1051        let entry_range = log_entry
1052            .iter()
1053            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1054                (lo.min(v), hi.max(v))
1055            });
1056        let entry_degenerate = (entry_range.1 - entry_range.0).abs() < 1e-8;
1057        if entry_degenerate {
1058            log_exit.clone()
1059        } else {
1060            let mut combined = Array1::<f64>::zeros(2 * n);
1061            for i in 0..n {
1062                combined[i] = log_entry[i];
1063                combined[n + i] = log_exit[i];
1064            }
1065            combined
1066        }
1067    }
1068
1069    /// Cap the requested monotone-baseline internal-knot count to what the
1070    /// observed time resolution can actually support.
1071    ///
1072    /// The survival location-scale baseline is a degree-`d` I-spline with
1073    /// `num_internal_knots + d` shape-varying columns. Its smoothing parameter
1074    /// is informed *only* by the distinct interior log-time points: with fewer
1075    /// distinct interior times than requested knots the baseline is
1076    /// rank-deficient, and the REML/LAML profile in the time smoothing
1077    /// parameter becomes a flat ridge — the exact-joint outer search then
1078    /// probes that ridge indefinitely (each inner constrained Newton burns its
1079    /// whole cycle budget without certifying convergence) and the fit never
1080    /// terminates. This is the survival analogue of the standard
1081    /// "df must not exceed the data resolution" guard (`mgcv` caps `k` at the
1082    /// number of unique covariate values; `flexsurv`/`rstpm2` use a handful of
1083    /// baseline knots): we never place more interior knots than there are
1084    /// distinct interior points, and we keep the total baseline dimension a
1085    /// bounded fraction of the sample so the smoothing profile stays curved.
1086    ///
1087    /// This clamp lives in the shared knot-inference routine so the fit and any
1088    /// independent rebuild of the time basis (e.g. a predictor reconstructing
1089    /// `design · β` at fresh covariates) resolve to the *same* knot vector from
1090    /// the same data — there is no raw/active dimension drift.
1091    fn data_capped_internal_knots(
1092        combined: &Array1<f64>,
1093        degree: usize,
1094        requested_internal_knots: usize,
1095    ) -> usize {
1096        if requested_internal_knots == 0 {
1097            return 0;
1098        }
1099        let mut sorted: Vec<f64> = combined.iter().copied().collect();
1100        sorted.sort_by(f64::total_cmp);
1101        let minval = sorted.first().copied().unwrap_or(0.0);
1102        let maxval = sorted.last().copied().unwrap_or(minval);
1103        if minval == maxval {
1104            // Degenerate (single distinct time): no interior structure to fit.
1105            return 1.min(requested_internal_knots);
1106        }
1107        let scale = (maxval - minval).abs().max(1.0);
1108        let tol = 1e-12 * scale;
1109        // Count distinct strictly-interior points (knots can only live strictly
1110        // between the data extremes).
1111        let mut distinct_interior = 0usize;
1112        let mut last: Option<f64> = None;
1113        for &x in &sorted {
1114            if x <= minval + tol || x >= maxval - tol {
1115                continue;
1116            }
1117            if last.is_some_and(|prev| (x - prev).abs() <= tol) {
1118                continue;
1119            }
1120            distinct_interior += 1;
1121            last = Some(x);
1122        }
1123        // Distinct-point ceiling: cannot place more interior knots than there
1124        // are distinct interior values.
1125        let mut cap = requested_internal_knots.min(distinct_interior.max(1));
1126        // Dimension-vs-resolution ceiling: keep the total baseline column count
1127        // `cap + degree` below ~1/4 of the distinct sample points so the
1128        // smoothing-parameter profile retains curvature (the data must be able
1129        // to identify the baseline shape, not just interpolate it). `n_distinct`
1130        // counts all distinct points (interior + the two extremes).
1131        let n_distinct = {
1132            let mut count = 0usize;
1133            let mut last: Option<f64> = None;
1134            for &x in &sorted {
1135                if last.is_some_and(|prev| (x - prev).abs() <= tol) {
1136                    continue;
1137                }
1138                count += 1;
1139                last = Some(x);
1140            }
1141            count
1142        };
1143        let dim_budget = n_distinct / 4;
1144        let dim_cap = dim_budget.saturating_sub(degree);
1145        cap = cap.min(dim_cap.max(1));
1146        cap.max(1)
1147    }
1148
1149    fn infer_survival_time_knots(
1150        combined: &Array1<f64>,
1151        knot_degree: usize,
1152        validation_degree: usize,
1153        num_internal_knots: usize,
1154        basis_options: BasisOptions,
1155    ) -> Result<Array1<f64>, String> {
1156        // Identifiability/termination guard: never request more baseline
1157        // internal knots than the observed time resolution supports. See
1158        // `data_capped_internal_knots` for the full rationale (a flat smoothing
1159        // ridge on an over-parameterized baseline is what makes the survival
1160        // location-scale exact-joint outer search fail to terminate).
1161        let num_internal_knots =
1162            data_capped_internal_knots(combined, validation_degree, num_internal_knots);
1163
1164        fn quantile_knot_inference_needs_uniform_fallback(
1165            combined: &Array1<f64>,
1166            num_internal_knots: usize,
1167        ) -> bool {
1168            if num_internal_knots == 0 || combined.is_empty() {
1169                return false;
1170            }
1171
1172            let mut sorted: Vec<f64> = combined.iter().copied().collect();
1173            sorted.sort_by(f64::total_cmp);
1174            let minval = sorted[0];
1175            let maxval = *sorted.last().unwrap_or(&minval);
1176            if minval == maxval {
1177                return false;
1178            }
1179
1180            let scale = (maxval - minval).abs().max(1.0);
1181            let tol = 1e-12 * scale;
1182            let mut support = Vec::with_capacity(sorted.len());
1183            let mut last: Option<f64> = None;
1184            for &x in &sorted {
1185                if x <= minval + tol || x >= maxval - tol {
1186                    continue;
1187                }
1188                if last.map(|prev| (x - prev).abs() <= tol).unwrap_or(false) {
1189                    continue;
1190                }
1191                support.push(x);
1192                last = Some(x);
1193            }
1194            if support.is_empty() {
1195                return true;
1196            }
1197
1198            let n = support.len();
1199            let mut prev_q = minval;
1200            for j in 1..=num_internal_knots {
1201                let p = j as f64 / (num_internal_knots + 1) as f64;
1202                let pos = p * (n.saturating_sub(1) as f64);
1203                let lo = pos.floor() as usize;
1204                let hi = pos.ceil() as usize;
1205                let frac = pos - lo as f64;
1206                let q = if lo == hi {
1207                    support[lo]
1208                } else {
1209                    support[lo] * (1.0 - frac) + support[hi] * frac
1210                }
1211                .clamp(minval, maxval);
1212                if q <= prev_q + tol || q >= maxval - tol {
1213                    return true;
1214                }
1215                prev_q = q;
1216            }
1217
1218            false
1219        }
1220
1221        let inferwith =
1222            |placement: gam_terms::basis::BSplineKnotPlacement| -> Result<Array1<f64>, String> {
1223                let built = build_bspline_basis_1d(
1224                    combined.view(),
1225                    &BSplineBasisSpec {
1226                        degree: knot_degree,
1227                        penalty_order: 2,
1228                        knotspec: BSplineKnotSpec::Automatic {
1229                            num_internal_knots: Some(num_internal_knots),
1230                            placement,
1231                        },
1232                        double_penalty: false,
1233                        identifiability: BSplineIdentifiability::None,
1234                        boundary: OneDimensionalBoundary::Open,
1235                        boundary_conditions: BSplineBoundaryConditions::default(),
1236                    },
1237                )
1238                .map_err(|e| format!("failed to infer survival time knots: {e}"))?;
1239                let knots = match built.metadata {
1240                    BasisMetadata::BSpline1D { knots, .. } => knots,
1241                    _ => {
1242                        return Err(
1243                            "internal error: expected BSpline1D metadata for survival time basis"
1244                                .to_string(),
1245                        );
1246                    }
1247                };
1248                // `knot_degree` is the clamped B-spline degree used to size
1249                // the knot vector. `validation_degree` is the public basis
1250                // degree passed to the final evaluator. They differ for
1251                // I-splines because `create_basis(..., BasisOptions::i_spline())`
1252                // internally raises the public degree by one to its working
1253                // B-spline antiderivative degree. Validating with
1254                // `knot_degree` here would raise a second time and reject the
1255                // coherent knot vector we just inferred.
1256                create_basis::<Dense>(
1257                    combined.view(),
1258                    KnotSource::Provided(knots.view()),
1259                    validation_degree,
1260                    basis_options,
1261                )
1262                .map_err(|e| e.to_string())?;
1263                Ok(knots)
1264            };
1265
1266        if quantile_knot_inference_needs_uniform_fallback(combined, num_internal_knots) {
1267            inferwith(gam_terms::basis::BSplineKnotPlacement::Uniform)
1268        } else {
1269            inferwith(gam_terms::basis::BSplineKnotPlacement::Quantile)
1270        }
1271    }
1272
1273    match cfg {
1274        SurvivalTimeBasisConfig::None => Ok(SurvivalTimeBuildOutput {
1275            x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1276            x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1277            x_derivative_time: DesignMatrix::Dense(DenseDesignMatrix::from(Array2::zeros((n, 0)))),
1278            penalties: Vec::new(),
1279            nullspace_dims: Vec::new(),
1280            basisname: "none".to_string(),
1281            degree: None,
1282            knots: None,
1283            keep_cols: None,
1284            smooth_lambda: None,
1285        }),
1286        SurvivalTimeBasisConfig::Linear => {
1287            let mut x_entry_time = Array2::<f64>::zeros((n, 2));
1288            let mut x_exit_time = Array2::<f64>::zeros((n, 2));
1289            let mut x_derivative_time = Array2::<f64>::zeros((n, 2));
1290            for i in 0..n {
1291                x_entry_time[[i, 0]] = 1.0;
1292                x_exit_time[[i, 0]] = 1.0;
1293                x_entry_time[[i, 1]] = log_entry[i];
1294                x_exit_time[[i, 1]] = log_exit[i];
1295                x_derivative_time[[i, 1]] = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1296            }
1297            Ok(SurvivalTimeBuildOutput {
1298                x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_entry_time)),
1299                x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_exit_time)),
1300                x_derivative_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_derivative_time)),
1301                penalties: Vec::new(),
1302                nullspace_dims: Vec::new(),
1303                basisname: "linear".to_string(),
1304                degree: None,
1305                knots: None,
1306                keep_cols: None,
1307                smooth_lambda: None,
1308            })
1309        }
1310        SurvivalTimeBasisConfig::BSpline {
1311            degree,
1312            knots,
1313            smooth_lambda,
1314        } => {
1315            let knotvec = if knots.is_empty() {
1316                let (num_internal_knots, _) = infer_knots_if_needed.ok_or_else(|| {
1317                    "internal error: bspline time basis requested without knot source".to_string()
1318                })?;
1319                let combined = survival_time_knot_input(&log_entry, &log_exit);
1320                infer_survival_time_knots(
1321                    &combined,
1322                    degree,
1323                    degree,
1324                    num_internal_knots,
1325                    BasisOptions::value(),
1326                )?
1327            } else {
1328                knots
1329            };
1330
1331            let entry_basis = build_bspline_basis_1d(
1332                log_entry.view(),
1333                &BSplineBasisSpec {
1334                    degree,
1335                    penalty_order: 2,
1336                    knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1337                    double_penalty: false,
1338                    identifiability: BSplineIdentifiability::None,
1339                    boundary: OneDimensionalBoundary::Open,
1340                    boundary_conditions: BSplineBoundaryConditions::default(),
1341                },
1342            )
1343            .map_err(|e| format!("failed to build bspline entry basis: {e}"))?;
1344            let exit_basis = build_bspline_basis_1d(
1345                log_exit.view(),
1346                &BSplineBasisSpec {
1347                    degree,
1348                    penalty_order: 2,
1349                    knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1350                    double_penalty: false,
1351                    identifiability: BSplineIdentifiability::None,
1352                    boundary: OneDimensionalBoundary::Open,
1353                    boundary_conditions: BSplineBoundaryConditions::default(),
1354                },
1355            )
1356            .map_err(|e| format!("failed to build bspline exit basis: {e}"))?;
1357
1358            let p_time = exit_basis.design.ncols();
1359            // Build derivative basis as sparse triplets — B-spline derivatives
1360            // have the same local support as the basis itself (at most degree+1
1361            // nonzeros per row), so building dense first wastes memory.
1362            let mut deriv_triplets = Vec::with_capacity(n * (degree + 1));
1363            let mut deriv_buf = vec![0.0_f64; p_time];
1364            for i in 0..n {
1365                deriv_buf.fill(0.0);
1366                evaluate_bspline_derivative_scalar(
1367                    log_exit[i],
1368                    knotvec.view(),
1369                    degree,
1370                    &mut deriv_buf,
1371                )
1372                .map_err(|e| format!("failed to evaluate bspline derivative: {e}"))?;
1373                let chain = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1374                for j in 0..p_time {
1375                    let v = deriv_buf[j] * chain;
1376                    if v.abs() > 1e-15 {
1377                        deriv_triplets.push(faer::sparse::Triplet::new(i, j, v));
1378                    }
1379                }
1380            }
1381            let x_derivative_time =
1382                match faer::sparse::SparseColMat::try_new_from_triplets(n, p_time, &deriv_triplets)
1383                {
1384                    Ok(sparse) => DesignMatrix::Sparse(SparseDesignMatrix::new(sparse)),
1385                    Err(_) => {
1386                        // Fallback: build dense
1387                        let mut dense = Array2::<f64>::zeros((n, p_time));
1388                        for &faer::sparse::Triplet { row, col, val } in &deriv_triplets {
1389                            dense[[row, col]] = val;
1390                        }
1391                        DesignMatrix::Dense(DenseDesignMatrix::from(dense))
1392                    }
1393                };
1394
1395            Ok(SurvivalTimeBuildOutput {
1396                x_entry_time: entry_basis.design,
1397                x_exit_time: exit_basis.design,
1398                x_derivative_time,
1399                nullspace_dims: entry_basis.nullspace_dims,
1400                penalties: entry_basis.penalties,
1401                basisname: "bspline".to_string(),
1402                degree: Some(degree),
1403                knots: Some(knotvec.to_vec()),
1404                keep_cols: None,
1405                smooth_lambda: Some(smooth_lambda),
1406            })
1407        }
1408        SurvivalTimeBasisConfig::ISpline {
1409            degree,
1410            knots,
1411            keep_cols,
1412            smooth_lambda,
1413        } => {
1414            let bspline_degree = degree
1415                .checked_add(1)
1416                .ok_or_else(|| "ispline degree overflow while building knot basis".to_string())?;
1417            let knotvec = if knots.is_empty() {
1418                let (num_internal_knots, _) = infer_knots_if_needed.ok_or_else(|| {
1419                    "internal error: ispline time basis requested without knot source".to_string()
1420                })?;
1421                let combined = survival_time_knot_input(&log_entry, &log_exit);
1422                infer_survival_time_knots(
1423                    &combined,
1424                    bspline_degree,
1425                    degree,
1426                    num_internal_knots,
1427                    BasisOptions::i_spline(),
1428                )?
1429            } else {
1430                knots
1431            };
1432
1433            let (db_exit_arc, _) = create_basis::<Dense>(
1434                log_exit.view(),
1435                KnotSource::Provided(knotvec.view()),
1436                bspline_degree,
1437                BasisOptions::first_derivative(),
1438            )
1439            .map_err(|e| format!("failed to build ispline derivative basis: {e}"))?;
1440
1441            // Build full-width I-spline bases inside a block scope so the
1442            // large Arc allocations are freed when the block ends.
1443            let (x_entry_time, x_exit_time, keep_cols, p_time, p_time_full) = {
1444                let (entry_arc, _) = create_basis::<Dense>(
1445                    log_entry.view(),
1446                    KnotSource::Provided(knotvec.view()),
1447                    degree,
1448                    BasisOptions::i_spline(),
1449                )
1450                .map_err(|e| format!("failed to build ispline entry basis: {e}"))?;
1451                let (exit_arc, _) = create_basis::<Dense>(
1452                    log_exit.view(),
1453                    KnotSource::Provided(knotvec.view()),
1454                    degree,
1455                    BasisOptions::i_spline(),
1456                )
1457                .map_err(|e| format!("failed to build ispline exit basis: {e}"))?;
1458
1459                let x_entry_full = entry_arc.as_ref();
1460                let x_exit_full = exit_arc.as_ref();
1461                let p_time_full = x_exit_full.ncols();
1462                if p_time_full == 0 {
1463                    return Err(SurvivalConstructionError::BasisConstructionFailed {
1464                        reason: "internal error: empty ispline time basis".to_string(),
1465                    }
1466                    .into());
1467                }
1468                let db_exit = db_exit_arc.as_ref();
1469                if db_exit.ncols() != p_time_full + 1 {
1470                    return Err(
1471                        "internal error: ispline derivative basis width must exceed basis width by one"
1472                            .to_string(),
1473                    );
1474                }
1475
1476                let keep_cols = if keep_cols.is_empty() {
1477                    let constant_tol = 1e-12_f64;
1478                    let mut inferred_keep_cols: Vec<usize> = Vec::new();
1479                    for j in 0..p_time_full {
1480                        let mut minv = f64::INFINITY;
1481                        let mut maxv = f64::NEG_INFINITY;
1482                        for i in 0..n {
1483                            let ve = x_exit_full[[i, j]];
1484                            let vs = x_entry_full[[i, j]];
1485                            minv = minv.min(ve.min(vs));
1486                            maxv = maxv.max(ve.max(vs));
1487                        }
1488                        if (maxv - minv) > constant_tol {
1489                            inferred_keep_cols.push(j);
1490                        }
1491                    }
1492                    inferred_keep_cols
1493                } else {
1494                    keep_cols
1495                };
1496                if keep_cols.is_empty() {
1497                    return Err(
1498                        "internal error: ispline basis has no shape-varying time columns"
1499                            .to_string(),
1500                    );
1501                }
1502                if keep_cols.iter().any(|&j| j >= p_time_full) {
1503                    return Err(SurvivalConstructionError::MissingColumn {
1504                        reason: "saved survival ispline keep_cols exceed basis width".to_string(),
1505                    }
1506                    .into());
1507                }
1508
1509                let p_time = keep_cols.len();
1510                let x_entry_time = x_entry_full.select(ndarray::Axis(1), &keep_cols);
1511                let x_exit_time = x_exit_full.select(ndarray::Axis(1), &keep_cols);
1512                // entry_arc and exit_arc go out of scope here, freeing the
1513                // full-width bases before derivative computation below.
1514                (x_entry_time, x_exit_time, keep_cols, p_time, p_time_full)
1515            };
1516            let db_exit = db_exit_arc.as_ref();
1517
1518            // Build I-spline derivative as sparse triplets.  The derivative
1519            // is a cumulative sum of B-spline derivatives and typically has
1520            // more nonzeros per row than a plain B-spline, but still much
1521            // fewer than p_time for modest bases.
1522            let mut deriv_triplets = Vec::with_capacity(n * p_time.min(16));
1523            let mut found_nonfinite: Option<(usize, usize)> = None;
1524            for i in 0..n {
1525                let mut running = 0.0_f64;
1526                let mut d_i_log_full = vec![0.0_f64; p_time_full];
1527                for j in (1..db_exit.ncols()).rev() {
1528                    let term = db_exit[[i, j]];
1529                    if term.is_finite() {
1530                        running += term;
1531                    }
1532                    d_i_log_full[j - 1] = running;
1533                }
1534                let chain = 1.0 / age_exit[i].max(SURVIVAL_TIME_FLOOR);
1535                for (j_new, &j_old) in keep_cols.iter().enumerate() {
1536                    let raw_v = d_i_log_full[j_old] * chain;
1537                    let v = if (-1e-12..0.0).contains(&raw_v) {
1538                        0.0
1539                    } else {
1540                        raw_v
1541                    };
1542                    if !v.is_finite() {
1543                        found_nonfinite = Some((i, j_new));
1544                    }
1545                    if v < -1e-12 {
1546                        return Err(format!(
1547                            "survival ispline derivative basis must stay non-negative at row {}, column {}; found {:.3e}",
1548                            i + 1,
1549                            j_new + 1,
1550                            v
1551                        ));
1552                    }
1553                    if v.abs() > 1e-15 {
1554                        deriv_triplets.push(faer::sparse::Triplet::new(i, j_new, v));
1555                    }
1556                }
1557            }
1558            if let Some((row, col)) = found_nonfinite {
1559                return Err(format!(
1560                    "survival ispline derivative basis produced non-finite value at row {}, column {}",
1561                    row + 1,
1562                    col + 1
1563                ));
1564            }
1565            let x_derivative_time =
1566                match faer::sparse::SparseColMat::try_new_from_triplets(n, p_time, &deriv_triplets)
1567                {
1568                    Ok(sparse) => DesignMatrix::Sparse(SparseDesignMatrix::new(sparse)),
1569                    Err(_) => {
1570                        let mut dense = Array2::<f64>::zeros((n, p_time));
1571                        for &faer::sparse::Triplet { row, col, val } in &deriv_triplets {
1572                            dense[[row, col]] = val;
1573                        }
1574                        DesignMatrix::Dense(DenseDesignMatrix::from(dense))
1575                    }
1576                };
1577
1578            let penalty_basis = build_bspline_basis_1d(
1579                log_exit.view(),
1580                &BSplineBasisSpec {
1581                    degree: bspline_degree,
1582                    penalty_order: 2,
1583                    knotspec: BSplineKnotSpec::Provided(knotvec.clone()),
1584                    double_penalty: false,
1585                    identifiability: BSplineIdentifiability::None,
1586                    boundary: OneDimensionalBoundary::Open,
1587                    boundary_conditions: BSplineBoundaryConditions::default(),
1588                },
1589            )
1590            .map_err(|e| format!("failed to build ispline smoothing penalty: {e}"))?;
1591            if penalty_basis.design.ncols() != p_time_full + 1 {
1592                return Err("internal error: ispline penalty dimension mismatch".to_string());
1593            }
1594            // I-spline curvature penalty in the *value* space of the baseline
1595            // log-cumulative-hazard, restricted to the retained (non-dropped)
1596            // coefficient block.
1597            //
1598            // The I-spline coefficient γ is the consecutive increment of the B-spline
1599            // value coefficients `c`: `c_0 = 0`, `c_k = Σ_{j<k} γ_j = (L γ)_k`, where
1600            // `L` is the `p_time × p_time` lower-triangular cumsum matrix. The
1601            // second-difference penalty on the B-spline values is `S_B = D₂ᵀD₂`
1602            // (the `penalty_basis.penalties` block). The correct curvature penalty
1603            // on γ is the **value-space congruence transform**
1604            //
1605            //   `S_I = Lᵀ S_B[1:,1:] L`,
1606            //
1607            // which satisfies `γᵀ S_I γ = (Lγ)ᵀ S_B[1:,1:] (Lγ)`.
1608            //
1609            // A constant γ (γ_k = γ₀ ∀k) maps to the linear value sequence
1610            // `c_k = k·γ₀`, which is annihilated by D₂: `D₂c = 0`. Therefore
1611            // `γᵀ S_I γ = 0` for constant γ, i.e. the **affine trend lies in the
1612            // penalty null space**. REML does not penalize the baseline slope
1613            // `d(log Λ)/d(log t)` or the overall level, so it correctly lets the
1614            // data determine these quantities without bias. The previous increment-
1615            // space form `S_B[1:,1:]` (applied directly to γ instead of Lγ) did NOT
1616            // have constant γ in its null space and therefore over-penalized affine
1617            // baselines, causing the fitted log-cumulative-hazard to lose its tail
1618            // slope to the penalty and fail quality tests (#1076).
1619            //
1620            // The value-space form has a 1-dimensional null space (span{(1,…,1)}),
1621            // declared via `nullspace_dims` so the REML generalized-logdet picks it
1622            // up. The penalized inner PIRLS is well-conditioned because the
1623            // likelihood Hessian H_lik has O(n_events) curvature along the affine
1624            // direction (the overall baseline level is identified by the data), and
1625            // the global stabilization ridge (ridge_lambda) provides an absolute
1626            // positive-definite floor.
1627            let mut penalties = Vec::<Array2<f64>>::new();
1628            for s_mat in &penalty_basis.penalties {
1629                if s_mat.nrows() != p_time_full + 1 || s_mat.ncols() != p_time_full + 1 {
1630                    continue;
1631                }
1632                // I-spline value-space penalty, computed in the CORRECT order
1633                // (gam#979). The B-spline value coefficients are the cumulative
1634                // sum of the I-spline increment coefficients, `c = L γ_full`, where
1635                // `L` is the FULL `p_time_full × p_time_full` LOWER-triangular
1636                // all-ones cumsum matrix (`L[i,j] = 1 iff j ≤ i`, so
1637                // `c_i = Σ_{j≤i} γ_j`). The value-space curvature penalty on the
1638                // full increment vector is the symmetric congruence
1639                //
1640                //   `S_I_full = Lᵀ · S_B[1:,1:] · L`,
1641                //
1642                // which is PSD because `S_B[1:,1:]` is a principal submatrix of the
1643                // PSD `S_B = D₂ᵀD₂` and congruence by any matrix preserves PSD.
1644                //
1645                // CRITICAL ORDERING (the gam#979 indefiniteness bug): the retained
1646                // columns `keep_cols` must be selected as a PRINCIPAL SUBMATRIX of
1647                // the FULL congruence `S_I_full` — i.e. congruence FIRST, selection
1648                // SECOND. The previous code selected `keep_cols` from `S_B[1:,1:]`
1649                // first and then applied a `p_time × p_time` cumsum to that
1650                // already-reduced block. Because the cumsum `L` couples every
1651                // increment, restricting the increment index set BEFORE the cumsum
1652                // does NOT commute with it: the reduced operator is a different,
1653                // generally INDEFINITE matrix (measured `s0_min_eval = −9.8e7`),
1654                // which makes `½γᵀS_Iγ` unbounded below and the penalized survival
1655                // NLL diverge (β drifts up the negative-eigenvalue mode, the inner
1656                // joint-Newton follows the unbounded objective, the outer REML never
1657                // terminates — the #979 hang). Doing the congruence on the full γ
1658                // and then taking the `keep_cols` principal submatrix restores the
1659                // PSD guarantee (a principal submatrix of a PSD matrix is PSD).
1660                let s_increment = s_mat.slice(s![1.., 1..]);
1661                if s_increment.nrows() != p_time_full || s_increment.ncols() != p_time_full {
1662                    return Err(format!(
1663                        "internal error: ispline penalty increment block must be {p_time_full}x{p_time_full}, got {}x{}",
1664                        s_increment.nrows(),
1665                        s_increment.ncols(),
1666                    ));
1667                }
1668                // Symmetrize the (already-symmetric) source with the shared
1669                // matrix utility. The survival builder's value-space
1670                // congruence is domain-specific; only the low-level symmetric
1671                // cleanup is common with the generic and SAE construction code.
1672                let mut s_full = s_increment.to_owned();
1673                symmetrize_in_place(&mut s_full);
1674                // S_mid = S_B[1:,1:] · L  (right-multiply by lower-triangular
1675                // cumsum): (S·L)[i,j] = Σ_k S[i,k]·L[k,j] = Σ_{k≥j} S[i,k]
1676                // because L[k,j] = 1 iff j ≤ k.
1677                let mut s_mid_full = Array2::<f64>::zeros((p_time_full, p_time_full));
1678                for i in 0..p_time_full {
1679                    for j in 0..p_time_full {
1680                        let mut v = 0.0;
1681                        for k in j..p_time_full {
1682                            v += s_full[[i, k]];
1683                        }
1684                        s_mid_full[[i, j]] = v;
1685                    }
1686                }
1687                // S_I_full = Lᵀ · S_mid = Lᵀ · S · L:
1688                // (Lᵀ·S_mid)[i,j] = Σ_k Lᵀ[i,k]·S_mid[k,j] = Σ_{k≥i} S_mid[k,j]
1689                // because Lᵀ[i,k] = L[k,i] = 1 iff i ≤ k.
1690                let mut s_full_congruent = Array2::<f64>::zeros((p_time_full, p_time_full));
1691                for i in 0..p_time_full {
1692                    for j in 0..p_time_full {
1693                        let mut v = 0.0;
1694                        for k in i..p_time_full {
1695                            v += s_mid_full[[k, j]];
1696                        }
1697                        s_full_congruent[[i, j]] = v;
1698                    }
1699                }
1700                // Principal submatrix on the retained (shape-varying) columns.
1701                let mut local = Array2::<f64>::zeros((p_time, p_time));
1702                for (i_new, &i_old) in keep_cols.iter().enumerate() {
1703                    for (j_new, &j_old) in keep_cols.iter().enumerate() {
1704                        // Symmetrize on the way out to absorb residual
1705                        // floating-point asymmetry.
1706                        local[[i_new, j_new]] = 0.5
1707                            * (s_full_congruent[[i_old, j_old]] + s_full_congruent[[j_old, i_old]]);
1708                    }
1709                }
1710                penalties.push(local);
1711            }
1712
1713            // PSD contract (gam#979). The value-space congruence Lᵀ S_B[1:,1:] L,
1714            // restricted to a principal submatrix, is positive semidefinite by
1715            // construction. A negative eigenvalue here means the construction has
1716            // regressed to the increment-space / wrong-ordering form that made the
1717            // penalized survival NLL unbounded below (the #979 divergence). Verify
1718            // it here, at construction, so the defect can never silently reach the
1719            // inner solver again. The tolerance is the same relative scale the
1720            // nullspace detection below uses; a numerically tiny negative (round-off
1721            // on the genuine 1-D null direction) is allowed, a structural one is not.
1722            for (idx, s_mat) in penalties.iter().enumerate() {
1723                let p = s_mat.nrows();
1724                if p == 0 {
1725                    continue;
1726                }
1727                if let Ok((evals, _)) =
1728                    gam_linalg::faer_ndarray::FaerEigh::eigh(s_mat, faer::Side::Lower)
1729                {
1730                    let evals_slice: &[f64] = evals.as_slice().ok_or_else(|| {
1731                        "internal error: ispline penalty eigenvalues not contiguous".to_string()
1732                    })?;
1733                    let max_ev = evals_slice
1734                        .iter()
1735                        .copied()
1736                        .fold(0.0_f64, |a, b| a.max(b.abs()))
1737                        .max(1.0);
1738                    let min_ev = evals_slice.iter().copied().fold(f64::INFINITY, f64::min);
1739                    let neg_tol = -100.0 * (p as f64) * f64::EPSILON * max_ev;
1740                    if min_ev < neg_tol {
1741                        return Err(format!(
1742                            "internal error (gam#979): assembled ispline time-block penalty {idx} is \
1743                             indefinite (min eigenvalue {min_ev:.3e} < tol {neg_tol:.3e}, max |eig| \
1744                             {max_ev:.3e}); the value-space congruence Lᵀ S_B[1:,1:] L must be PSD"
1745                        ));
1746                    }
1747                }
1748            }
1749
1750            // The value-space penalty S_I = L^T S_B[1:,1:] L has a 1-dimensional
1751            // null space (constant γ ↦ affine c ↦ D₂c = 0). Detect it spectrally
1752            // so the REML uses the generalized logdet over the penalized subspace.
1753            let nullspace_dims: Vec<usize> = penalties
1754                .iter()
1755                .map(|s_mat| {
1756                    let p = s_mat.nrows();
1757                    if p == 0 {
1758                        return 0;
1759                    }
1760                    match gam_linalg::faer_ndarray::FaerEigh::eigh(s_mat, faer::Side::Lower) {
1761                        Ok((evals, _)) => {
1762                            let evals_slice: &[f64] = evals.as_slice().unwrap();
1763                            let max_ev = evals_slice
1764                                .iter()
1765                                .copied()
1766                                .fold(0.0_f64, |a, b| a.max(b.abs()))
1767                                .max(1.0);
1768                            let threshold = 100.0 * (p as f64) * f64::EPSILON * max_ev;
1769                            evals_slice.iter().filter(|&&e| e <= threshold).count()
1770                        }
1771                        Err(_) => 0,
1772                    }
1773                })
1774                .collect();
1775            Ok(SurvivalTimeBuildOutput {
1776                x_entry_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_entry_time)),
1777                x_exit_time: DesignMatrix::Dense(DenseDesignMatrix::from(x_exit_time)),
1778                x_derivative_time,
1779                penalties,
1780                nullspace_dims,
1781                basisname: "ispline".to_string(),
1782                degree: Some(degree),
1783                knots: Some(knotvec.to_vec()),
1784                keep_cols: Some(keep_cols),
1785                smooth_lambda: Some(smooth_lambda),
1786            })
1787        }
1788    }
1789}
1790
1791pub fn resolved_survival_time_basis_config_from_build(
1792    basisname: &str,
1793    degree: Option<usize>,
1794    knots: Option<&Vec<f64>>,
1795    keep_cols: Option<&Vec<usize>>,
1796    smooth_lambda: Option<f64>,
1797) -> Result<SurvivalTimeBasisConfig, String> {
1798    match basisname {
1799        "none" => Ok(SurvivalTimeBasisConfig::None),
1800        "linear" => Ok(SurvivalTimeBasisConfig::Linear),
1801        "bspline" => Ok(SurvivalTimeBasisConfig::BSpline {
1802            degree: degree.ok_or_else(|| "survival bspline basis is missing degree".to_string())?,
1803            knots: Array1::from_vec(
1804                knots
1805                    .cloned()
1806                    .ok_or_else(|| "survival bspline basis is missing knots".to_string())?,
1807            ),
1808            smooth_lambda: smooth_lambda.unwrap_or(SURVIVAL_TIME_SMOOTH_LAMBDA_SEED),
1809        }),
1810        "ispline" => Ok(SurvivalTimeBasisConfig::ISpline {
1811            degree: degree.ok_or_else(|| "survival ispline basis is missing degree".to_string())?,
1812            knots: Array1::from_vec(
1813                knots
1814                    .cloned()
1815                    .ok_or_else(|| "survival ispline basis is missing knots".to_string())?,
1816            ),
1817            keep_cols: keep_cols
1818                .cloned()
1819                .ok_or_else(|| "survival ispline basis is missing keep_cols".to_string())?,
1820            smooth_lambda: smooth_lambda.unwrap_or(SURVIVAL_TIME_SMOOTH_LAMBDA_SEED),
1821        }),
1822        other => Err(format!("unsupported survival time basis '{other}'")),
1823    }
1824}
1825
1826pub fn resolve_survival_time_anchor_value(
1827    age_entry: &Array1<f64>,
1828    time_anchor: Option<f64>,
1829) -> Result<f64, String> {
1830    if age_entry.is_empty() {
1831        return Err("survival time anchor requires non-empty entry times".to_string());
1832    }
1833    let anchor = match time_anchor {
1834        Some(t_anchor) => {
1835            if !t_anchor.is_finite() || t_anchor < 0.0 {
1836                return Err(format!(
1837                    "survival time anchor must be finite and non-negative, got {t_anchor}"
1838                ));
1839            }
1840            t_anchor
1841        }
1842        None => age_entry
1843            .iter()
1844            .copied()
1845            .min_by(f64::total_cmp)
1846            .ok_or_else(|| "failed to select survival time anchor".to_string())?,
1847    };
1848    Ok(anchor.max(SURVIVAL_TIME_FLOOR))
1849}
1850
1851/// Marginal-slope centering anchor: a robust *interior* time on the **exit**
1852/// scale rather than the earliest entry age.
1853///
1854/// `center_survival_time_designs_at_anchor` subtracts the time-basis row at the
1855/// anchor from every entry/exit design row, so the anchor sets the origin of
1856/// the baseline-hazard I-spline's affine reparameterization. The
1857/// location-scale path anchors at the minimum entry age
1858/// ([`resolve_survival_time_anchor_value`]); for right-censored-only data that
1859/// minimum is ≈ the time origin, so centering is nearly a no-op.
1860///
1861/// Under **left truncation** the minimum entry age is a genuine positive
1862/// *left-tail* point, and centering there leaves the centered linear-trend
1863/// column `X(exit) − X(anchor)` large and one-signed across all rows (exit
1864/// times sit far to the right of the earliest entry). That column is the
1865/// unpenalized polynomial null space of the 2nd-difference time penalty, so the
1866/// inflated, one-signed column multiplies the marginal-slope time-block score
1867/// at the `γ = 0` monotone-cone seed up by hundreds — the constrained joint
1868/// Newton cannot certify KKT on it and REML rejects every seed (issue #751).
1869///
1870/// Centering instead at a robust interior location on the *exit* scale — the
1871/// **median exit age**, where the at-risk mass concentrates — keeps the
1872/// centered column small and two-signed (some exits below the median, some
1873/// above), so the exit-event likelihood pins the linear trend and the seed
1874/// score stays bounded. Re-centering is an exact affine reparameterization of
1875/// the baseline offset: the fitted `q(t)` and the REML objective are unchanged,
1876/// only the seed conditioning improves. The median is chosen (over the mean)
1877/// for robustness to the heavy right tail of survival times.
1878///
1879/// An explicit `--survival-time-anchor` is honored verbatim (same validation as
1880/// the location-scale path) so the user retains full control; the saved
1881/// `survival_time_anchor` scalar round-trips to predict unchanged.
1882pub fn resolve_survival_marginal_slope_time_anchor_value(
1883    age_entry: &Array1<f64>,
1884    age_exit: &Array1<f64>,
1885    time_anchor: Option<f64>,
1886) -> Result<f64, String> {
1887    if age_entry.is_empty() || age_exit.is_empty() {
1888        return Err(
1889            "survival marginal-slope time anchor requires non-empty entry/exit times".to_string(),
1890        );
1891    }
1892    let anchor = match time_anchor {
1893        Some(t_anchor) => {
1894            if !t_anchor.is_finite() || t_anchor < 0.0 {
1895                return Err(format!(
1896                    "survival time anchor must be finite and non-negative, got {t_anchor}"
1897                ));
1898            }
1899            t_anchor
1900        }
1901        None => robust_interior_exit_anchor(age_exit),
1902    };
1903    Ok(anchor.max(SURVIVAL_TIME_FLOOR))
1904}
1905
1906/// Median exit age — a robust interior time on the exit scale, where the
1907/// at-risk mass concentrates. Used as the survival time-basis centering anchor
1908/// whenever the earliest entry is a positive left-tail point (delayed entry):
1909/// centering there keeps the reparameterized linear-trend column small and
1910/// two-signed instead of large and one-signed. The median is chosen over the
1911/// mean for robustness to the heavy right tail of survival times.
1912fn robust_interior_exit_anchor(age_exit: &Array1<f64>) -> f64 {
1913    let mut sorted: Vec<f64> = age_exit.iter().copied().collect();
1914    sorted.sort_by(f64::total_cmp);
1915    let m = sorted.len();
1916    if m == 0 {
1917        return SURVIVAL_TIME_FLOOR;
1918    }
1919    if m % 2 == 1 {
1920        sorted[m / 2]
1921    } else {
1922        0.5 * (sorted[m / 2 - 1] + sorted[m / 2])
1923    }
1924}
1925
1926/// Centering anchor for the default transformation (Royston-Parmar) survival
1927/// baseline.
1928///
1929/// For right-censored-only data the earliest entry age is ≈ the time origin, so
1930/// [`resolve_survival_time_anchor_value`] (min entry) is nearly a no-op and is
1931/// used unchanged. Under **left truncation** (every row enters at a positive
1932/// delayed-entry time) that minimum is a genuine left-tail point far below the
1933/// exit mass, and centering the I-spline time basis there leaves the
1934/// unpenalized linear-trend column `X(exit) − X(anchor)` large and one-signed
1935/// across all rows. That column is the null space of the 2nd-difference time
1936/// penalty, so the inflated one-signed column blows up the transformation
1937/// smoothing-parameter selection: it rails a penalty direction and collapses the
1938/// baseline to a covariate-independent, cumulative-hazard-inflated degenerate
1939/// fit (issue #1790 — the transformation-model analogue of the marginal-slope
1940/// #751 defect). Anchoring instead at the robust interior **median exit age**
1941/// keeps the centered column small and two-signed so the exit-event likelihood
1942/// pins the linear trend. Re-centering is an exact affine reparameterization of
1943/// the baseline offset — the fitted `q(t)` and REML objective are unchanged,
1944/// only the seed conditioning improves. An explicit `time_anchor` is honored
1945/// verbatim.
1946pub fn resolve_survival_transformation_time_anchor_value(
1947    age_entry: &Array1<f64>,
1948    age_exit: &Array1<f64>,
1949    time_anchor: Option<f64>,
1950) -> Result<f64, String> {
1951    if time_anchor.is_some() {
1952        return resolve_survival_time_anchor_value(age_entry, time_anchor);
1953    }
1954    if age_exit.is_empty() {
1955        return Err(
1956            "survival transformation time anchor requires non-empty exit times".to_string(),
1957        );
1958    }
1959    let min_entry = age_entry.iter().copied().fold(f64::INFINITY, f64::min);
1960    if min_entry > SURVIVAL_DELAYED_ENTRY_THRESHOLD {
1961        Ok(robust_interior_exit_anchor(age_exit).max(SURVIVAL_TIME_FLOOR))
1962    } else {
1963        resolve_survival_time_anchor_value(age_entry, None)
1964    }
1965}
1966
1967pub fn evaluate_survival_time_basis_row(
1968    age: f64,
1969    cfg: &SurvivalTimeBasisConfig,
1970) -> Result<Array1<f64>, String> {
1971    if !age.is_finite() || age < 0.0 {
1972        return Err(format!(
1973            "survival time basis row requires finite non-negative age, got {age}"
1974        ));
1975    }
1976    let age = age.max(SURVIVAL_TIME_FLOOR);
1977    let log_age = array![age.ln()];
1978    match cfg {
1979        SurvivalTimeBasisConfig::None => Ok(Array1::zeros(0)),
1980        SurvivalTimeBasisConfig::Linear => Ok(array![1.0, age.ln()]),
1981        SurvivalTimeBasisConfig::BSpline { degree, knots, .. } => {
1982            if knots.is_empty() {
1983                return Err(
1984                    "survival BSpline anchor evaluation requires resolved knot metadata"
1985                        .to_string(),
1986                );
1987            }
1988            let built = build_bspline_basis_1d(
1989                log_age.view(),
1990                &BSplineBasisSpec {
1991                    degree: *degree,
1992                    penalty_order: 2,
1993                    knotspec: BSplineKnotSpec::Provided(knots.clone()),
1994                    double_penalty: false,
1995                    identifiability: BSplineIdentifiability::None,
1996                    boundary: OneDimensionalBoundary::Open,
1997                    boundary_conditions: BSplineBoundaryConditions::default(),
1998                },
1999            )
2000            .map_err(|e| format!("failed to evaluate survival bspline anchor row: {e}"))?;
2001            Ok(built.design.to_dense().row(0).to_owned())
2002        }
2003        SurvivalTimeBasisConfig::ISpline {
2004            degree,
2005            knots,
2006            keep_cols,
2007            ..
2008        } => {
2009            if knots.is_empty() {
2010                return Err(
2011                    "survival ISpline anchor evaluation requires resolved knot metadata"
2012                        .to_string(),
2013                );
2014            }
2015            let (basis_arc, _) = create_basis::<Dense>(
2016                log_age.view(),
2017                KnotSource::Provided(knots.view()),
2018                *degree,
2019                BasisOptions::i_spline(),
2020            )
2021            .map_err(|e| format!("failed to evaluate survival ispline anchor row: {e}"))?;
2022            let basis = basis_arc.as_ref();
2023            let row = basis.row(0);
2024            if keep_cols.is_empty() {
2025                return Ok(row.to_owned());
2026            }
2027            if keep_cols.iter().any(|&j| j >= row.len()) {
2028                return Err(SurvivalConstructionError::MissingColumn {
2029                    reason: "survival ISpline anchor keep_cols exceed basis width".to_string(),
2030                }
2031                .into());
2032            }
2033            Ok(Array1::from_iter(keep_cols.iter().map(|&j| row[j])))
2034        }
2035    }
2036}
2037
2038pub fn center_survival_time_designs_at_anchor(
2039    design_entry: &mut DesignMatrix,
2040    design_exit: &mut DesignMatrix,
2041    anchor_row: &Array1<f64>,
2042) -> Result<(), String> {
2043    if design_entry.ncols() != anchor_row.len() || design_exit.ncols() != anchor_row.len() {
2044        return Err(format!(
2045            "survival time anchoring column mismatch: entry={}, exit={}, anchor={}",
2046            design_entry.ncols(),
2047            design_exit.ncols(),
2048            anchor_row.len()
2049        ));
2050    }
2051    // Centering destroys sparsity (every row gets a dense offset), so
2052    // materialize to dense.  This only runs once at construction time.
2053    fn center_dense(dm: &mut DesignMatrix, anchor: &Array1<f64>) {
2054        let mut dense = dm.to_dense();
2055        for mut row in dense.rows_mut() {
2056            row -= &anchor.view();
2057        }
2058        *dm = DesignMatrix::Dense(DenseDesignMatrix::from(dense));
2059    }
2060    center_dense(design_entry, anchor_row);
2061    center_dense(design_exit, anchor_row);
2062    Ok(())
2063}
2064
2065// ---------------------------------------------------------------------------
2066// Baseline evaluation (Gompertz, Weibull, Gompertz-Makeham)
2067// ---------------------------------------------------------------------------
2068
2069/// Partial derivatives of the baseline offsets `(eta_target, d_eta_target/dt)`
2070/// with respect to the θ-parameters in the same parameterization that
2071/// [`survival_baseline_theta_from_config`] / [`survival_baseline_config_from_theta`]
2072/// use:
2073///
2074/// - **Weibull**: θ = (log_scale, log_shape).  `eta = shape·(log t − log scale)`,
2075///   `o_D = shape/t`.
2076/// - **Gompertz**: θ = (log_rate, shape).  `eta = log H_G(t)` with
2077///   `H_G(t) = (rate/shape)·(exp(shape·t) − 1)`, `o_D = h_G(t)/H_G(t) =
2078///   shape·E/(E−1)` where `E = exp(shape·t)`.
2079/// - **Gompertz–Makeham**: θ = (log_rate, shape, log_makeham).
2080///   `eta = log H(t)` with `H(t) = makeham·t + H_G(t)`,
2081///   `o_D = (makeham + h_G(t)) / H(t)`.
2082///
2083/// Returns a flat `(d_eta/dθ_k, d_oD/dθ_k)` pair for each component of θ,
2084/// in the same order as `survival_baseline_theta_from_config`.  Linear has
2085/// no θ-parameters so returns `Ok(None)`.
2086///
2087/// The `eta`-channel derivatives are closed-form for every branch.  The
2088/// `o_D`-channel derivatives use the log-derivative identity
2089/// `∂o_D/∂θ = o_D · ∂log(o_D)/∂θ` which is more numerically stable near
2090/// the small-shape limit (shape·t → 0).  Near shape = 0 we fall back to
2091/// a third-order Taylor expansion with the same 1e-10 pivot that
2092/// `evaluate_survival_baseline` uses, keeping the value/derivative pair
2093/// continuous and agreement with the linear-hazard limit exact at shape=0.
2094pub fn baseline_offset_theta_partials(
2095    age: f64,
2096    cfg: &SurvivalBaselineConfig,
2097) -> Result<Option<Vec<(f64, f64)>>, String> {
2098    let Some(params) = validated_baseline_params(age, cfg, "baseline derivative evaluation")?
2099    else {
2100        return Ok(None);
2101    };
2102
2103    match params {
2104        ValidatedBaselineTarget::Weibull { scale, shape } => {
2105            // eta = shape·(log t − log scale)
2106            //     = shape·log t − shape·log scale
2107            // o_D = shape / t
2108            //
2109            // θ = (log_scale, log_shape):
2110            //   ∂eta/∂log_scale  = −shape          ∂o_D/∂log_scale = 0
2111            //   ∂eta/∂log_shape  = shape·(log t − log scale) = eta
2112            //   ∂o_D/∂log_shape  = shape / t = o_D
2113            let eta = shape * (age.ln() - scale.ln());
2114            let o_d = shape / age;
2115            let d_eta_d_log_scale = -shape;
2116            let d_od_d_log_scale = 0.0;
2117            let d_eta_d_log_shape = eta;
2118            let d_od_d_log_shape = o_d;
2119            Ok(Some(vec![
2120                (d_eta_d_log_scale, d_od_d_log_scale),
2121                (d_eta_d_log_shape, d_od_d_log_shape),
2122            ]))
2123        }
2124        ValidatedBaselineTarget::Gompertz { shape, .. } => {
2125            // θ = (log_rate, shape):
2126            //   Rate cancels in o_D = h/H for Gompertz, so ∂o_D/∂log_rate = 0
2127            //   and ∂eta/∂log_rate = 1. The shape channel uses
2128            //     ∂eta/∂shape   = −1/shape + t·E/(E−1)
2129            //     ∂log(o_D)/∂shape = 1/shape − t/(E−1)
2130            //     ∂o_D/∂shape  = o_D · ∂log(o_D)/∂shape
2131            //   Near shape=0 both numerators are 1/shape cancellations. Use
2132            //   Taylor expansions with the same 1e-10 pivot that
2133            //   gompertz_components uses in evaluate_survival_baseline.
2134            let (d_eta_d_shape, d_od_d_shape) = gompertz_shape_derivatives(age, shape);
2135            Ok(Some(vec![(1.0, 0.0), (d_eta_d_shape, d_od_d_shape)]))
2136        }
2137        ValidatedBaselineTarget::GompertzMakeham {
2138            rate,
2139            shape,
2140            makeham,
2141        } => {
2142            // H(t) = M·t + H_G(t),   H_G(t) = (rate/shape)·(E−1),  E = exp(shape·t)
2143            // h(t) = M + h_G(t),     h_G(t) = rate·E
2144            // o_D  = h/H
2145            //
2146            // θ = (log_rate, shape, log_makeham):
2147            //   ∂H/∂log_rate    = rate · ∂H/∂rate = H_G               (scales with rate)
2148            //   ∂H/∂shape       = H_G_shape                            (closed form below)
2149            //   ∂H/∂log_makeham = makeham · t                          (linear in makeham)
2150            //   ∂h/∂log_rate    = rate · ∂h/∂rate = h_G
2151            //   ∂h/∂shape       = h_G_shape = rate·t·E + 0              (= rate·t·E)
2152            //   ∂h/∂log_makeham = makeham
2153            //   ∂eta/∂θ = (∂H/∂θ) / H
2154            //   ∂o_D/∂θ = (∂h/∂θ − o_D · ∂H/∂θ) / H
2155            //           = (∂h/∂θ)/H − o_D · (∂H/∂θ)/H
2156            let (cum_g, inst_g) = gompertz_hazard_components(age, rate, shape);
2157            let cum_total = makeham * age + cum_g;
2158            if cum_total <= 0.0 || !cum_total.is_finite() {
2159                return Err(SurvivalConstructionError::DataValidationFailed {
2160                    reason: "gm baseline produced non-positive cumulative hazard".to_string(),
2161                }
2162                .into());
2163            }
2164            let inst_total = makeham + inst_g;
2165            let o_d = inst_total / cum_total;
2166            let inv_cum = 1.0 / cum_total;
2167            // Each channel: ∂cum/∂θ and ∂inst/∂θ → ∂eta/∂θ = ∂cum/∂θ / cum
2168            //                                       ∂o_D/∂θ = (∂inst/∂θ − o_D·∂cum/∂θ) / cum
2169            // log_rate channel: cum is linear in rate through H_G; ∂cum/∂rate = H_G/rate,
2170            //   so ∂cum/∂log_rate = H_G (= cum_g here). Similarly ∂inst/∂log_rate = h_G (= inst_g).
2171            let d_cum_dlr = cum_g;
2172            let d_inst_dlr = inst_g;
2173            let d_eta_dlr = d_cum_dlr * inv_cum;
2174            let d_od_dlr = (d_inst_dlr - o_d * d_cum_dlr) * inv_cum;
2175            // shape channel: only H_G and h_G have shape dependence.
2176            let (d_cum_dshape, d_inst_dshape) =
2177                gompertz_cumulative_shape_derivative(age, rate, shape);
2178            let d_eta_dshape = d_cum_dshape * inv_cum;
2179            let d_od_dshape = (d_inst_dshape - o_d * d_cum_dshape) * inv_cum;
2180            // log_makeham channel: cum contributes M·t, inst contributes M.
2181            //   ∂cum/∂log_makeham = makeham·t,  ∂inst/∂log_makeham = makeham.
2182            let d_cum_dlm = makeham * age;
2183            let d_inst_dlm = makeham;
2184            let d_eta_dlm = d_cum_dlm * inv_cum;
2185            let d_od_dlm = (d_inst_dlm - o_d * d_cum_dlm) * inv_cum;
2186            Ok(Some(vec![
2187                (d_eta_dlr, d_od_dlr),
2188                (d_eta_dshape, d_od_dshape),
2189                (d_eta_dlm, d_od_dlm),
2190            ]))
2191        }
2192    }
2193}
2194
2195/// Shared chain-rule θ-gradient contraction for baseline offsets.
2196///
2197/// Both [`baseline_chain_rule_gradient`] (RP eta offsets) and
2198/// [`marginal_slope_baseline_chain_rule_gradient`] (probit q-offsets) reduce to
2199/// the same contraction of [`OffsetChannelResiduals`] against per-age baseline
2200/// θ-partials; only the `partials` provider differs. This engine owns the length
2201/// checks, the θ-dim probe, the parallel per-row reduction, the entry gating, and
2202/// the error handling. Each provider returns, per age, a length-`theta_dim` vector
2203/// of `(∂eta/∂θ_k, ∂(d eta/dt)/∂θ_k)` pairs (or `(∂q/∂θ_k, ∂(dq/dt)/∂θ_k)` for the
2204/// probit channel), and `None` when `cfg` has no θ-parameters (`Linear` target).
2205///
2206/// Contract (envelope theorem at converged β; the penalty has no θ dependence):
2207///
2208///   d[0.5·deviance + 0.5·βᵀS_λβ] / dθ_k
2209///     = Σᵢ r_X[i]·(∂o_X_i/∂θ_k) + r_D[i]·(∂o_D_i/∂θ_k) + r_E[i]·(∂o_E_i/∂θ_k)
2210///       + r_R[i]·(∂o_R_i/∂θ_k)
2211///
2212/// where `r_X = residuals.exit`, `r_D = residuals.derivative`, `r_E =
2213/// residuals.entry`, `r_R = residuals.right` (all sampleweight-scaled already).
2214/// Exit and derivative partials both come from the `age_exit[i]` evaluation;
2215/// the entry partial from `age_entry[i]`; the interval upper-bound (`R`)
2216/// η-partial from `age_right[i]`. Origin-entry rows have `r_E[i] == 0` exactly
2217/// and non-interval rows have `r_R[i] == 0` exactly, so those partials are
2218/// skipped for those rows (avoiding the `age > 0` precondition failure when an
2219/// inactive boundary age is 0 / a placeholder).
2220///
2221/// Returns `Ok(None)` when the provider reports no θ-parameters.
2222fn baseline_chain_rule_gradient_with_partials<F>(
2223    label: &'static str,
2224    age_entry: ndarray::ArrayView1<'_, f64>,
2225    age_exit: ndarray::ArrayView1<'_, f64>,
2226    age_right: ndarray::ArrayView1<'_, f64>,
2227    cfg: &SurvivalBaselineConfig,
2228    residuals: &crate::survival::OffsetChannelResiduals,
2229    partials: F,
2230) -> Result<Option<Array1<f64>>, String>
2231where
2232    F: Fn(f64, &SurvivalBaselineConfig) -> Result<Option<Vec<(f64, f64)>>, String> + Sync,
2233{
2234    let n = age_exit.len();
2235    if age_entry.len() != n
2236        || age_right.len() != n
2237        || residuals.exit.len() != n
2238        || residuals.entry.len() != n
2239        || residuals.derivative.len() != n
2240        || residuals.right.len() != n
2241    {
2242        return Err(format!(
2243            "{label}: length mismatch (age_entry={}, age_exit={}, age_right={}, r_exit={}, r_entry={}, r_deriv={}, r_right={})",
2244            age_entry.len(),
2245            n,
2246            age_right.len(),
2247            residuals.exit.len(),
2248            residuals.entry.len(),
2249            residuals.derivative.len(),
2250            residuals.right.len(),
2251        ));
2252    }
2253    // Probe θ-dim via any valid positive age. If the provider returns None the
2254    // config carries no θ-parameters (Linear target) and there is no θ-gradient.
2255    let probe_age = age_exit.iter().copied().find(|v| v.is_finite() && *v > 0.0);
2256    let theta_dim = match probe_age {
2257        Some(t) => match partials(t, cfg)? {
2258            None => return Ok(None),
2259            Some(v) => v.len(),
2260        },
2261        None => {
2262            return Err(format!("{label}: no valid positive age for dim probe"));
2263        }
2264    };
2265    // Per-row partial contractions are independent, but each row's
2266    // contribution is a `theta_dim`-vector of `O(theta_dim · partial_cost)`
2267    // flops — small enough that the rayon parallel reduction's split
2268    // overhead dominates for any plausible `theta_dim`, *and* the
2269    // non-associative IEEE-754 sum order across thread chunks made the
2270    // engine drift in the low-order bits from row to row. The serial
2271    // accumulator below mirrors the inline reference exactly (and remains
2272    // ~memory-bandwidth-bound at large-scale `n`), so the engine is now a
2273    // bit-for-bit replacement for the legacy path, not just a
2274    // floating-point-noise-equivalent one.
2275    let mut grad = Array1::<f64>::zeros(theta_dim);
2276    for i in 0..n {
2277        // Exit + derivative partials both come from the age_exit evaluation.
2278        let partials_exit = partials(age_exit[i], cfg)?
2279            .ok_or_else(|| format!("{label}: unexpected None from partials at exit"))?;
2280        if partials_exit.len() != theta_dim {
2281            return Err(format!(
2282                "{label}: theta_dim drifted ({} != {})",
2283                partials_exit.len(),
2284                theta_dim
2285            ));
2286        }
2287        let r_x = residuals.exit[i];
2288        let r_d = residuals.derivative[i];
2289        for k in 0..theta_dim {
2290            let (d_eta_dk, d_od_dk) = partials_exit[k];
2291            grad[k] += r_x * d_eta_dk + r_d * d_od_dk;
2292        }
2293        // Entry channel is nonzero only for rows with a positive entry
2294        // interval; for origin-entry rows age_entry may be 0 and calling
2295        // the provider would error. Gate on residual==0.
2296        let r_e = residuals.entry[i];
2297        if r_e != 0.0 {
2298            let partials_entry = partials(age_entry[i], cfg)?
2299                .ok_or_else(|| format!("{label}: unexpected None from partials at entry"))?;
2300            for k in 0..theta_dim {
2301                grad[k] += r_e * partials_entry[k].0;
2302            }
2303        }
2304        // Interval upper-bound (`R`) channel: `q_right = X_time(R)·β + o_R(θ)`
2305        // carries its own baseline-θ η-offset evaluated at `age_right[i]`. It is
2306        // an η-level offset with NO time-derivative channel (the interval
2307        // likelihood `log[S(L) − S(R)]` has no hazard-derivative term), so it
2308        // contracts against the η-partial `.0` only. Nonzero only for
2309        // interval-censored latent rows; for every other channel/model
2310        // `r_right[i] == 0` exactly, so the (possibly placeholder) `age_right[i]`
2311        // partial is never consulted.
2312        let r_r = residuals.right[i];
2313        if r_r != 0.0 {
2314            let partials_right = partials(age_right[i], cfg)?.ok_or_else(|| {
2315                format!("{label}: unexpected None from partials at right boundary")
2316            })?;
2317            if partials_right.len() != theta_dim {
2318                return Err(format!(
2319                    "{label}: theta_dim drifted at right boundary ({} != {})",
2320                    partials_right.len(),
2321                    theta_dim
2322                ));
2323            }
2324            for k in 0..theta_dim {
2325                grad[k] += r_r * partials_right[k].0;
2326            }
2327        }
2328    }
2329    Ok(Some(grad))
2330}
2331
2332/// Contract `OffsetChannelResiduals` against `baseline_offset_theta_partials`
2333/// to produce the closed-form θ-gradient of the unpenalized NLL at converged β.
2334///
2335/// Derivation (envelope theorem on the penalized objective, β* minimizes the
2336/// same cost wrt β and the penalty has no θ dependence):
2337///
2338///   d[0.5·deviance + 0.5·βᵀS_λβ] / dθ_k
2339///     = d[NLL(β*; o(θ))] / dθ_k
2340///     = Σᵢ (∂NLL_i/∂o_X[i])·(∂o_X_i/∂θ_k)
2341///       + (∂NLL_i/∂o_E[i])·(∂o_E_i/∂θ_k)
2342///       + (∂NLL_i/∂o_D[i])·(∂o_D_i/∂θ_k)
2343///       + (∂NLL_i/∂o_R[i])·(∂o_R_i/∂θ_k)
2344///
2345/// The four `∂NLL_i/∂o_channel` terms are the `exit`, `entry`, `derivative`,
2346/// `right` fields of [`OffsetChannelResiduals`] (sampleweight-scaled already).
2347/// The `∂o/∂θ_k` terms come from [`baseline_offset_theta_partials`] per obs at
2348/// the appropriate age.
2349///
2350/// Per the RP offset convention:
2351///   o_E[i] = eta_target(age_entry[i])
2352///   o_X[i] = eta_target(age_exit[i])
2353///   o_D[i] = d/dt eta_target(t) |_{t=age_exit[i]}
2354///   o_R[i] = eta_target(age_right[i])   (interval upper bound `R`; η-level only)
2355///
2356/// so the exit and derivative partials are both evaluated at `age_exit[i]`,
2357/// the entry partial at `age_entry[i]`, and the interval-right η-partial at
2358/// `age_right[i]`. The origin-entry case (`entry_at_origin[i]`) has
2359/// `r_entry[i] = 0` exactly and every non-interval row has `r_right[i] = 0`
2360/// exactly, so we skip the `baseline_offset_theta_partials(age, ..)` call for
2361/// those rows (avoiding the `age > 0` precondition failure when an inactive
2362/// boundary age is 0 / a placeholder).
2363///
2364/// Returns `Ok(None)` when `cfg.target == Linear` (no θ-parameters).
2365pub fn baseline_chain_rule_gradient(
2366    age_entry: ndarray::ArrayView1<'_, f64>,
2367    age_exit: ndarray::ArrayView1<'_, f64>,
2368    age_right: ndarray::ArrayView1<'_, f64>,
2369    cfg: &SurvivalBaselineConfig,
2370    residuals: &crate::survival::OffsetChannelResiduals,
2371) -> Result<Option<Array1<f64>>, String> {
2372    baseline_chain_rule_gradient_with_partials(
2373        "baseline_chain_rule_gradient",
2374        age_entry,
2375        age_exit,
2376        age_right,
2377        cfg,
2378        residuals,
2379        baseline_offset_theta_partials,
2380    )
2381}
2382
2383/// Chain-rule θ-gradient for marginal-slope probit baseline offsets.
2384///
2385/// This is the probit-survival counterpart of [`baseline_chain_rule_gradient`].
2386/// It contracts residuals against
2387/// [`marginal_slope_baseline_offset_theta_partials`], so the offset channels
2388/// are `(q_entry, q_exit, dq_exit/dt)` with `Phi(-q(t)) = exp(-H0(t))`.
2389pub fn marginal_slope_baseline_chain_rule_gradient(
2390    age_entry: ndarray::ArrayView1<'_, f64>,
2391    age_exit: ndarray::ArrayView1<'_, f64>,
2392    cfg: &SurvivalBaselineConfig,
2393    residuals: &crate::survival::OffsetChannelResiduals,
2394) -> Result<Option<Array1<f64>>, String> {
2395    // Marginal-slope has no interval upper-bound channel; `residuals.right` is
2396    // all-zero, so the right channel never contracts and `age_exit` serves as an
2397    // unconsulted placeholder for the (unused) `age_right` argument.
2398    baseline_chain_rule_gradient_with_partials(
2399        "marginal_slope_baseline_chain_rule_gradient",
2400        age_entry,
2401        age_exit,
2402        age_exit,
2403        cfg,
2404        residuals,
2405        marginal_slope_baseline_offset_theta_partials,
2406    )
2407}
2408
2409/// Shared Gompertz hazard components `(H_G(t), h_G(t))`.
2410/// Mirrors the private helper in `evaluate_survival_baseline` with the
2411/// same 1e-10 small-shape pivot.
2412#[inline]
2413fn gompertz_hazard_components(age: f64, rate: f64, shape: f64) -> (f64, f64) {
2414    if shape.abs() < 1e-10 {
2415        // Taylor at shape=0: H_G(t) = rate·t·(1 + shape·t/2 + (shape·t)²/6),
2416        // h_G(t) = rate·(1 + shape·t + (shape·t)²/2).
2417        let x = shape * age;
2418        (
2419            rate * age * (1.0 + 0.5 * x + x * x / 6.0),
2420            rate * (1.0 + x + 0.5 * x * x),
2421        )
2422    } else {
2423        let shape_age = shape * age;
2424        let cumulative_hazard = (rate / shape) * shape_age.exp_m1();
2425        let instant_hazard = rate * shape_age.exp();
2426        (cumulative_hazard, instant_hazard)
2427    }
2428}
2429
2430/// Partials of `(H_G(t), h_G(t))` with respect to the shape parameter.
2431///
2432/// H_G(t) = (rate/shape)·(E−1),  h_G(t) = rate·E,  E = exp(shape·t)
2433///
2434/// ∂H_G/∂shape  = −(rate/shape²)·(E−1) + (rate/shape)·t·E
2435///              = rate·[t·E/shape − (E−1)/shape²]
2436///              = rate·[t·E·shape − (E−1)] / shape²
2437/// ∂h_G/∂shape  = rate·t·E
2438///
2439/// Near shape=0 the first expression has a 1/shape² singularity that
2440/// cancels analytically. Using the series E−1 = Σₖ≥₁ (shape·t)ᵏ/k!:
2441///   t·E·shape − (E−1) = Σₖ≥₁ (shape·t)ᵏ·(k−1)/k!·shape⁰  [after simplification]
2442///                     = (shape·t)²/2 + 2(shape·t)³/6 + 3(shape·t)⁴/24 + ...
2443/// so ∂H_G/∂shape at shape→0 = rate·[t²/2 + shape·t³/3 + shape²·t⁴/8 + ...].
2444/// We use that Taylor expansion in the small-shape branch.
2445#[inline]
2446fn gompertz_cumulative_shape_derivative(age: f64, rate: f64, shape: f64) -> (f64, f64) {
2447    let x = shape * age;
2448    let dinstg_dshape = rate * age * x.exp();
2449    // The exact form rate·[t·E·shape − (E−1)]/shape² is a difference of two
2450    // O(1/shape) quantities whose leading terms cancel, so its accuracy is
2451    // governed by the dimensionless product x = shape·age, NOT by `shape`
2452    // alone. Pivoting on `shape < 1e-10` ignored `age`: for large ages a small
2453    // shape still yields a small x where the catastrophic cancellation has
2454    // already corrupted the difference. Pivot on x instead; the 3-term Taylor
2455    // (through O(x²)) is accurate to <1e-9 for |x| < 1e-4, and the exact branch
2456    // is clean above it.
2457    let dhg_dshape = if x.abs() < 1e-4 {
2458        let t = age;
2459        // Truncated to O(x³): t²/2 + x·t²/3 + x²·t²/8
2460        rate * t * t * (0.5 + x / 3.0 + x * x / 8.0)
2461    } else {
2462        // t·E·shape − (E−1) = t·e^x·shape − expm1(x)
2463        let e = x.exp();
2464        let em1 = x.exp_m1();
2465        let numerator = age * e * shape - em1;
2466        rate * numerator / (shape * shape)
2467    };
2468    (dhg_dshape, dinstg_dshape)
2469}
2470
2471/// Partials `(∂eta/∂shape, ∂o_D/∂shape)` for the pure Gompertz baseline.
2472/// Pure Gompertz has rate cancelling in o_D, so there is no log_rate
2473/// contribution in o_D. The rate channel for eta is trivially 1; this
2474/// helper only covers the shape channel.
2475#[inline]
2476fn gompertz_shape_derivatives(age: f64, shape: f64) -> (f64, f64) {
2477    if shape.abs() < 1e-10 {
2478        // Closed-form limits from the series t·E/(E−1) = 1/x + 1/2 + x/12 + ...
2479        // with E = e^x, x = shape·t:
2480        //   ∂eta/∂shape  = −1/shape + t·E/(E−1)
2481        //                = t/2 + shape·t²/12 + O(shape²)
2482        //   o_D         = shape·E/(E−1)
2483        //                = 1/t + shape/2 + shape²·t/12 + O(shape³)
2484        //   ∂log(o_D)/∂shape = 1/shape − t/(E−1)
2485        //                = t/2 − shape·t²/12 + O(shape²)
2486        //   ∂o_D/∂shape = o_D · ∂log(o_D)/∂shape
2487        let t = age;
2488        let d_eta = 0.5 * t + shape * t * t / 12.0;
2489        let dlog_od = 0.5 * t - shape * t * t / 12.0;
2490        let o_d = 1.0 / t + 0.5 * shape + shape * shape * t / 12.0;
2491        (d_eta, o_d * dlog_od)
2492    } else {
2493        let x = shape * age;
2494        let e = x.exp();
2495        let em1 = x.exp_m1(); // E − 1 via expm1 for accuracy at small x
2496        let d_eta = -1.0 / shape + age * e / em1;
2497        // o_D = shape · E/(E−1); ∂log(o_D)/∂shape = 1/shape − t/(E−1)
2498        let o_d = shape * e / em1;
2499        let dlog_od = 1.0 / shape - age / em1;
2500        (d_eta, o_d * dlog_od)
2501    }
2502}
2503
2504/// Per-target baseline parameters after the shared age guard and the per-target
2505/// required-field extraction + finiteness/positivity validation have passed.
2506///
2507/// This is the single source of truth for *which* config fields each baseline
2508/// target requires and *what* domain each must satisfy. Both the hazard-value
2509/// evaluator (`survival_cumulative_and_instant_hazard`) and the θ-partials
2510/// evaluator (`survival_hazard_theta_partials`) consume it and only differ in how
2511/// they assemble their (value vs derivative) outputs from these checked scalars.
2512#[derive(Clone, Copy, Debug)]
2513enum ValidatedBaselineTarget {
2514    Weibull { scale: f64, shape: f64 },
2515    Gompertz { rate: f64, shape: f64 },
2516    GompertzMakeham { rate: f64, shape: f64, makeham: f64 },
2517}
2518
2519/// Shared prologue for the survival baseline hazard evaluators: validate the age,
2520/// then extract and domain-check the per-target parameters from `cfg`.
2521///
2522/// `Ok(None)` is the `Linear` target (no parametric baseline). `context` is woven
2523/// into the age-guard error so each caller keeps its specific phrasing.
2524fn validated_baseline_params(
2525    age: f64,
2526    cfg: &SurvivalBaselineConfig,
2527    context: &str,
2528) -> Result<Option<ValidatedBaselineTarget>, String> {
2529    if !age.is_finite() || age <= 0.0 {
2530        return Err(format!(
2531            "survival ages must be finite and positive for {context}"
2532        ));
2533    }
2534
2535    match cfg.target {
2536        SurvivalBaselineTarget::Linear => Ok(None),
2537        SurvivalBaselineTarget::Weibull => {
2538            let scale = cfg
2539                .scale
2540                .ok_or_else(|| "weibull missing scale".to_string())?;
2541            let shape = cfg
2542                .shape
2543                .ok_or_else(|| "weibull missing shape".to_string())?;
2544            if !(scale.is_finite() && shape.is_finite() && scale > 0.0 && shape > 0.0) {
2545                return Err(SurvivalConstructionError::InvalidConfig {
2546                    reason: "weibull baseline requires finite positive scale and shape".to_string(),
2547                }
2548                .into());
2549            }
2550            Ok(Some(ValidatedBaselineTarget::Weibull { scale, shape }))
2551        }
2552        SurvivalBaselineTarget::Gompertz => {
2553            let rate = cfg
2554                .rate
2555                .ok_or_else(|| "gompertz missing rate".to_string())?;
2556            let shape = cfg
2557                .shape
2558                .ok_or_else(|| "gompertz missing shape".to_string())?;
2559            if !(rate.is_finite() && shape.is_finite() && rate > 0.0) {
2560                return Err(
2561                    "gompertz baseline requires finite positive rate and finite shape".to_string(),
2562                );
2563            }
2564            Ok(Some(ValidatedBaselineTarget::Gompertz { rate, shape }))
2565        }
2566        SurvivalBaselineTarget::GompertzMakeham => {
2567            let rate = cfg
2568                .rate
2569                .ok_or_else(|| "gompertz-makeham missing rate".to_string())?;
2570            let shape = cfg
2571                .shape
2572                .ok_or_else(|| "gompertz-makeham missing shape".to_string())?;
2573            let makeham = cfg
2574                .makeham
2575                .ok_or_else(|| "gompertz-makeham missing makeham".to_string())?;
2576            if !(rate.is_finite()
2577                && shape.is_finite()
2578                && makeham.is_finite()
2579                && rate > 0.0
2580                && makeham > 0.0)
2581            {
2582                return Err(
2583                    "gompertz-makeham baseline requires finite positive rate, makeham, and finite shape"
2584                        .to_string(),
2585                );
2586            }
2587            Ok(Some(ValidatedBaselineTarget::GompertzMakeham {
2588                rate,
2589                shape,
2590                makeham,
2591            }))
2592        }
2593    }
2594}
2595
2596fn survival_hazard_theta_partials(
2597    age: f64,
2598    cfg: &SurvivalBaselineConfig,
2599) -> Result<Option<Vec<(f64, f64)>>, String> {
2600    let Some(params) = validated_baseline_params(age, cfg, "baseline hazard partials")? else {
2601        return Ok(None);
2602    };
2603
2604    match params {
2605        ValidatedBaselineTarget::Weibull { scale, shape } => {
2606            let log_time_ratio = age.ln() - scale.ln();
2607            let cumulative_hazard = (age / scale).powf(shape);
2608            let instant_hazard = shape * cumulative_hazard / age;
2609            let eta = shape * log_time_ratio;
2610            Ok(Some(vec![
2611                (-shape * cumulative_hazard, -shape * instant_hazard),
2612                (eta * cumulative_hazard, (1.0 + eta) * instant_hazard),
2613            ]))
2614        }
2615        ValidatedBaselineTarget::Gompertz { rate, shape } => {
2616            let (cumulative_hazard, instant_hazard) = gompertz_hazard_components(age, rate, shape);
2617            let (d_cum_dshape, d_inst_dshape) =
2618                gompertz_cumulative_shape_derivative(age, rate, shape);
2619            Ok(Some(vec![
2620                (cumulative_hazard, instant_hazard),
2621                (d_cum_dshape, d_inst_dshape),
2622            ]))
2623        }
2624        ValidatedBaselineTarget::GompertzMakeham {
2625            rate,
2626            shape,
2627            makeham,
2628        } => {
2629            let (cum_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2630            let (d_cum_dshape, d_inst_dshape) =
2631                gompertz_cumulative_shape_derivative(age, rate, shape);
2632            Ok(Some(vec![
2633                (cum_gompertz, inst_gompertz),
2634                (d_cum_dshape, d_inst_dshape),
2635                (makeham * age, makeham),
2636            ]))
2637        }
2638    }
2639}
2640
2641fn survival_cumulative_and_instant_hazard(
2642    age: f64,
2643    cfg: &SurvivalBaselineConfig,
2644) -> Result<Option<(f64, f64)>, String> {
2645    let Some(params) = validated_baseline_params(age, cfg, "baseline hazard evaluation")? else {
2646        return Ok(None);
2647    };
2648
2649    match params {
2650        ValidatedBaselineTarget::Weibull { scale, shape } => {
2651            let cumulative_hazard = (age / scale).powf(shape);
2652            let instant_hazard = shape * cumulative_hazard / age;
2653            Ok(Some((cumulative_hazard, instant_hazard)))
2654        }
2655        ValidatedBaselineTarget::Gompertz { rate, shape } => {
2656            let (cumulative_hazard, instant_hazard) = gompertz_hazard_components(age, rate, shape);
2657            Ok(Some((cumulative_hazard, instant_hazard)))
2658        }
2659        ValidatedBaselineTarget::GompertzMakeham {
2660            rate,
2661            shape,
2662            makeham,
2663        } => {
2664            let (h_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2665            Ok(Some((makeham * age + h_gompertz, makeham + inst_gompertz)))
2666        }
2667    }
2668}
2669
2670#[derive(Clone, Copy, Debug)]
2671struct MarginalSlopeBaselinePoint {
2672    instant_hazard: f64,
2673    q: f64,
2674    q_t: f64,
2675}
2676
2677fn evaluate_marginal_slope_baseline_point(
2678    age: f64,
2679    cfg: &SurvivalBaselineConfig,
2680) -> Result<Option<MarginalSlopeBaselinePoint>, String> {
2681    let Some((cumulative_hazard, instant_hazard)) =
2682        survival_cumulative_and_instant_hazard(age, cfg)?
2683    else {
2684        return Ok(None);
2685    };
2686    if !(cumulative_hazard.is_finite() && cumulative_hazard > 0.0) {
2687        return Err(format!(
2688            "{} marginal-slope baseline produced non-positive cumulative hazard",
2689            survival_baseline_targetname(cfg.target)
2690        ));
2691    }
2692    if !(instant_hazard.is_finite() && instant_hazard > 0.0) {
2693        return Err(format!(
2694            "{} marginal-slope baseline produced non-positive instant hazard",
2695            survival_baseline_targetname(cfg.target)
2696        ));
2697    }
2698    let survival = (-cumulative_hazard).exp();
2699    if !(survival.is_finite() && survival > 0.0 && survival < 1.0) {
2700        return Err(format!(
2701            "{} marginal-slope baseline survival must be strictly inside (0,1), got {survival}",
2702            survival_baseline_targetname(cfg.target)
2703        ));
2704    }
2705    let q = -standard_normal_quantile(survival).map_err(|e| {
2706        format!(
2707            "{} marginal-slope baseline failed to invert survival probability {survival}: {e}",
2708            survival_baseline_targetname(cfg.target)
2709        )
2710    })?;
2711    let phi_q = normal_pdf(q);
2712    if !(phi_q.is_finite() && phi_q > 0.0) {
2713        return Err(format!(
2714            "{} marginal-slope baseline produced non-positive probit density phi(q)={phi_q} at q={q}",
2715            survival_baseline_targetname(cfg.target)
2716        ));
2717    }
2718    Ok(Some(MarginalSlopeBaselinePoint {
2719        instant_hazard,
2720        q,
2721        q_t: instant_hazard * survival / phi_q,
2722    }))
2723}
2724
2725/// Evaluate the parametric baseline target at a given age.
2726/// Returns `(eta_target(age), d eta_target / d age)` on the log-cumulative-hazard scale.
2727pub fn evaluate_survival_baseline(
2728    age: f64,
2729    cfg: &SurvivalBaselineConfig,
2730) -> Result<(f64, f64), String> {
2731    if !age.is_finite() || age < 0.0 {
2732        return Err(
2733            "survival ages must be finite and non-negative for baseline target evaluation"
2734                .to_string(),
2735        );
2736    }
2737
2738    // At t = 0 every parametric cumulative-hazard target satisfies H(0) = 0
2739    // exactly (this is the defining property of a cumulative hazard:
2740    // S(0) = 1 ⇒ H(0) = -log S(0) = 0). The log-cumulative-hazard offset is
2741    // therefore eta(0) = log H(0) = -inf, and we report a zero log-derivative
2742    // since `exp(eta(0)) = H(0) = 0` is the only physically valid value.
2743    // Returning `Ok((-inf, 0.0))` keeps the baseline cumulative hazard exactly
2744    // zero at the origin; downstream callers that need to multiply this offset
2745    // into a linear predictor are responsible for handling the origin row via
2746    // the `entry_at_origin` / `exit_at_origin` gating already wired through the
2747    // engine.
2748    if age == 0.0 {
2749        return match cfg.target {
2750            SurvivalBaselineTarget::Linear => Ok((0.0, 0.0)),
2751            SurvivalBaselineTarget::Weibull
2752            | SurvivalBaselineTarget::Gompertz
2753            | SurvivalBaselineTarget::GompertzMakeham => Ok((f64::NEG_INFINITY, 0.0)),
2754        };
2755    }
2756
2757    let Some(params) = validated_baseline_params(age, cfg, "baseline target evaluation")? else {
2758        return Ok((0.0, 0.0));
2759    };
2760
2761    match params {
2762        ValidatedBaselineTarget::Weibull { scale, shape } => {
2763            let eta = shape * (age.ln() - scale.ln());
2764            let derivative = shape / age;
2765            Ok((eta, derivative))
2766        }
2767        ValidatedBaselineTarget::Gompertz { rate, shape } => {
2768            let (h, inst) = gompertz_hazard_components(age, rate, shape);
2769            if h <= 0.0 || !h.is_finite() {
2770                return Err(if shape.abs() < 1e-10 {
2771                    "invalid gompertz baseline at near-zero shape".to_string()
2772                } else {
2773                    "gompertz baseline produced non-positive cumulative hazard".to_string()
2774                });
2775            }
2776            let derivative = inst / h;
2777            Ok((h.ln(), derivative))
2778        }
2779        ValidatedBaselineTarget::GompertzMakeham {
2780            rate,
2781            shape,
2782            makeham,
2783        } => {
2784            let (h_gompertz, inst_gompertz) = gompertz_hazard_components(age, rate, shape);
2785            let h = makeham * age + h_gompertz;
2786            if h <= 0.0 || !h.is_finite() {
2787                return Err(
2788                    "gompertz-makeham baseline produced non-positive cumulative hazard".to_string(),
2789                );
2790            }
2791            let inst = makeham + inst_gompertz;
2792            let derivative = inst / h;
2793            Ok((h.ln(), derivative))
2794        }
2795    }
2796}
2797
2798/// Evaluate the parametric baseline as the probit index whose marginal
2799/// survival is the true hazard survival `exp(-H0(t))`.
2800///
2801/// Returns `(q(age), dq / d age)` such that `Phi(-q(age)) = exp(-H0(age))`.
2802/// The derivative is `h0(t) * exp(-H0(t)) / phi(q(t))`.
2803pub fn evaluate_survival_marginal_slope_baseline(
2804    age: f64,
2805    cfg: &SurvivalBaselineConfig,
2806) -> Result<(f64, f64), String> {
2807    // Survival-curve origin. Every cumulative-hazard baseline satisfies
2808    // `H0(0) = 0` (`S0(0) = exp(-H0(0)) = 1`), so the probit index
2809    // `q(0) = -Phi^{-1}(S0(0)) = -Phi^{-1}(1) = -inf`: there is no *finite*
2810    // probit-survival offset at the origin. The survival surface anchors
2811    // `S(0) = 1` directly (see the `t <= 0` origin handling in the survival
2812    // predict paths), so the baseline contributes nothing here — report the
2813    // zero offset rather than aborting in the `age <= 0` hazard guard. This
2814    // mirrors `evaluate_survival_baseline`'s explicit `age == 0` branch on the
2815    // log-cumulative-hazard channel; without it the probit/marginal-slope
2816    // baseline path (location-scale + marginal-slope likelihoods) could not be
2817    // evaluated on a prediction grid whose first node is the origin (#1024).
2818    if age == 0.0 {
2819        return Ok((0.0, 0.0));
2820    }
2821    let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2822        return Ok((0.0, 0.0));
2823    };
2824    Ok((point.q, point.q_t))
2825}
2826
2827/// Partial derivatives of the true survival marginal-slope probit offsets
2828/// `(q(t), dq(t)/dt)` with respect to the baseline θ-parameters.
2829///
2830/// The returned channels match `survival_baseline_theta_from_config`.  For
2831/// Gompertz-Makeham, θ is `(log_rate, shape, log_makeham)`.  If
2832/// `S(t)=exp(-H(t))`, `q(t)=-Phi^-1(S(t))`, `A(t)=S(t)/phi(q(t))`, and
2833/// `h(t)=dH/dt`, then
2834///
2835///   dq/dθ      = A * dH/dθ
2836///   d(q')/dθ   = A * (dh/dθ + h * (q*A - 1) * dH/dθ)
2837///
2838/// which keeps the probit transform and the hazard baseline analytically tied.
2839pub fn marginal_slope_baseline_offset_theta_partials(
2840    age: f64,
2841    cfg: &SurvivalBaselineConfig,
2842) -> Result<Option<Vec<(f64, f64)>>, String> {
2843    let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2844        return Ok(None);
2845    };
2846    let hazard_partials = survival_hazard_theta_partials(age, cfg)?
2847        .ok_or_else(|| "unexpected missing hazard partials for nonlinear baseline".to_string())?;
2848    let a = point.q_t / point.instant_hazard;
2849    let a_log_derivative_factor = point.q * a - 1.0;
2850    Ok(Some(
2851        hazard_partials
2852            .into_iter()
2853            .map(|(d_h_cum, d_h_inst)| {
2854                (
2855                    a * d_h_cum,
2856                    a * (d_h_inst + point.instant_hazard * a_log_derivative_factor * d_h_cum),
2857                )
2858            })
2859            .collect(),
2860    ))
2861}
2862
2863/// Contract marginal-slope offset residuals and channel curvatures into the
2864/// exact Hessian with respect to baseline θ-parameters.
2865pub fn marginal_slope_baseline_chain_rule_hessian(
2866    age_entry: ndarray::ArrayView1<'_, f64>,
2867    age_exit: ndarray::ArrayView1<'_, f64>,
2868    cfg: &SurvivalBaselineConfig,
2869    residuals: &crate::survival::OffsetChannelResiduals,
2870    curvatures: &crate::survival::OffsetChannelCurvatures,
2871) -> Result<Option<Array2<f64>>, String> {
2872    let n = age_exit.len();
2873    if age_entry.len() != n
2874        || residuals.exit.len() != n
2875        || residuals.entry.len() != n
2876        || residuals.derivative.len() != n
2877        || curvatures.rows.len() != n
2878    {
2879        return Err(format!(
2880            "marginal_slope_baseline_chain_rule_hessian: length mismatch (age_entry={}, age_exit={}, r_exit={}, r_entry={}, r_deriv={}, h_rows={})",
2881            age_entry.len(),
2882            n,
2883            residuals.exit.len(),
2884            residuals.entry.len(),
2885            residuals.derivative.len(),
2886            curvatures.rows.len(),
2887        ));
2888    }
2889    let probe_age = age_exit.iter().copied().find(|v| v.is_finite() && *v > 0.0);
2890    let dim = match probe_age {
2891        Some(t) => match marginal_slope_baseline_offset_theta_second_partials(t, cfg)? {
2892            None => return Ok(None),
2893            Some(parts) => parts.first.len(),
2894        },
2895        None => {
2896            return Err(
2897                "marginal_slope_baseline_chain_rule_hessian: no valid positive age for dim probe"
2898                    .to_string(),
2899            );
2900        }
2901    };
2902    // Per-row Hessian contractions are independent. Each row contributes a
2903    // dim×dim increment combining second partials (exit/entry channels) with
2904    // the curvature-weighted outer product of the (entry, exit, derivative)
2905    // first-partial Jacobians. Fixed row chunks are combined in chunk-index
2906    // order so floating-point addition stays deterministic across Rayon
2907    // scheduling decisions.
2908    let hessian = RowSet::All.par_try_reduce_fold(
2909        n,
2910        || Array2::<f64>::zeros((dim, dim)),
2911        |mut acc, i, _row_weight| -> Result<Array2<f64>, String> {
2912            let exit_parts =
2913                marginal_slope_baseline_offset_theta_second_partials(age_exit[i], cfg)?
2914                    .ok_or_else(|| {
2915                        "unexpected None from marginal-slope second partials at exit".to_string()
2916                    })?;
2917            if exit_parts.first.len() != dim {
2918                return Err(
2919                    "marginal_slope_baseline_chain_rule_hessian: theta_dim drifted".to_string(),
2920                );
2921            }
2922            let mut entry_parts = None;
2923            if residuals.entry[i] != 0.0 {
2924                entry_parts = Some(
2925                    marginal_slope_baseline_offset_theta_second_partials(age_entry[i], cfg)?
2926                        .ok_or_else(|| {
2927                            "unexpected None from marginal-slope second partials at entry"
2928                                .to_string()
2929                        })?,
2930                );
2931            }
2932            for a in 0..dim {
2933                for b in 0..dim {
2934                    let j_exit_a = exit_parts.first[a].0;
2935                    let j_exit_b = exit_parts.first[b].0;
2936                    let j_deriv_a = exit_parts.first[a].1;
2937                    let j_deriv_b = exit_parts.first[b].1;
2938                    let mut value = residuals.exit[i] * exit_parts.second[a][b].0
2939                        + residuals.derivative[i] * exit_parts.second[a][b].1;
2940                    if let Some(parts) = entry_parts.as_ref() {
2941                        value += residuals.entry[i] * parts.second[a][b].0;
2942                    }
2943                    let curv = curvatures.rows[i];
2944                    let j_entry_a = entry_parts.as_ref().map_or(0.0, |parts| parts.first[a].0);
2945                    let j_entry_b = entry_parts.as_ref().map_or(0.0, |parts| parts.first[b].0);
2946                    let ja = [j_entry_a, j_exit_a, j_deriv_a];
2947                    let jb = [j_entry_b, j_exit_b, j_deriv_b];
2948                    for u in 0..3 {
2949                        for v in 0..3 {
2950                            value += ja[u] * curv[u][v] * jb[v];
2951                        }
2952                    }
2953                    acc[[a, b]] += value;
2954                }
2955            }
2956            Ok(acc)
2957        },
2958        |a, b| Ok(a + b),
2959    )?;
2960    Ok(Some(hessian))
2961}
2962
2963struct MarginalSlopeThetaSecondPartials {
2964    first: Vec<(f64, f64)>,
2965    second: Vec<Vec<(f64, f64)>>,
2966}
2967
2968fn marginal_slope_baseline_offset_theta_second_partials(
2969    age: f64,
2970    cfg: &SurvivalBaselineConfig,
2971) -> Result<Option<MarginalSlopeThetaSecondPartials>, String> {
2972    let Some(point) = evaluate_marginal_slope_baseline_point(age, cfg)? else {
2973        return Ok(None);
2974    };
2975    let Some((hazard, first, second)) = survival_hazard_theta_first_second(age, cfg)? else {
2976        return Ok(None);
2977    };
2978    let (cum_hazard, instant_hazard) = hazard;
2979    let survival = (-cum_hazard).exp();
2980    let a = survival / normal_pdf(point.q);
2981    let b = point.q * a - 1.0;
2982    let b_factor = a + point.q * b;
2983    let dim = first.len();
2984    let mut first_out = Vec::with_capacity(dim);
2985    let mut second_out = vec![vec![(0.0, 0.0); dim]; dim];
2986    for i in 0..dim {
2987        let (h_i, inst_i) = first[i];
2988        first_out.push((a * h_i, a * (inst_i + instant_hazard * b * h_i)));
2989    }
2990    for i in 0..dim {
2991        for j in 0..dim {
2992            let (h_i, inst_i) = first[i];
2993            let (h_j, inst_j) = first[j];
2994            let (h_ij, inst_ij) = second[i][j];
2995            let a_j = a * b * h_j;
2996            let b_j = a * h_j * b_factor;
2997            let q_ij = a * h_ij + a * b * h_i * h_j;
2998            let qt_inner_i = inst_i + instant_hazard * b * h_i;
2999            let qt_ij = a_j * qt_inner_i
3000                + a * (inst_ij + inst_j * b * h_i + instant_hazard * (b_j * h_i + b * h_ij));
3001            second_out[i][j] = (q_ij, qt_ij);
3002        }
3003    }
3004    Ok(Some(MarginalSlopeThetaSecondPartials {
3005        first: first_out,
3006        second: second_out,
3007    }))
3008}
3009
3010type HazardFirstSecond = ((f64, f64), Vec<(f64, f64)>, Vec<Vec<(f64, f64)>>);
3011
3012fn survival_hazard_theta_first_second(
3013    age: f64,
3014    cfg: &SurvivalBaselineConfig,
3015) -> Result<Option<HazardFirstSecond>, String> {
3016    let Some(hazard) = survival_cumulative_and_instant_hazard(age, cfg)? else {
3017        return Ok(None);
3018    };
3019    let first = survival_hazard_theta_partials(age, cfg)?
3020        .ok_or_else(|| "unexpected missing hazard partials".to_string())?;
3021    let dim = first.len();
3022    let mut second = vec![vec![(0.0, 0.0); dim]; dim];
3023    match cfg.target {
3024        SurvivalBaselineTarget::Linear => return Ok(None),
3025        SurvivalBaselineTarget::Weibull => {
3026            let scale = cfg
3027                .scale
3028                .ok_or_else(|| "weibull missing scale".to_string())?;
3029            let shape = cfg
3030                .shape
3031                .ok_or_else(|| "weibull missing shape".to_string())?;
3032            let log_time_ratio = age.ln() - scale.ln();
3033            let cumulative_hazard = hazard.0;
3034            let instant_hazard = hazard.1;
3035            let eta = shape * log_time_ratio;
3036            second[0][0] = (
3037                shape * shape * cumulative_hazard,
3038                shape * shape * instant_hazard,
3039            );
3040            second[0][1] = (
3041                -shape * cumulative_hazard * (1.0 + eta),
3042                -shape * instant_hazard * (2.0 + eta),
3043            );
3044            second[1][0] = second[0][1];
3045            second[1][1] = (
3046                eta * cumulative_hazard * (1.0 + eta),
3047                (eta + (1.0 + eta) * (1.0 + eta)) * instant_hazard,
3048            );
3049        }
3050        SurvivalBaselineTarget::Gompertz => {
3051            let rate = cfg
3052                .rate
3053                .ok_or_else(|| "gompertz missing rate".to_string())?;
3054            let shape = cfg
3055                .shape
3056                .ok_or_else(|| "gompertz missing shape".to_string())?;
3057            second[0][0] = first[0];
3058            second[0][1] = first[1];
3059            second[1][0] = first[1];
3060            second[1][1] = gompertz_cumulative_shape_second_derivative(age, rate, shape);
3061        }
3062        SurvivalBaselineTarget::GompertzMakeham => {
3063            let rate = cfg.rate.ok_or_else(|| "gm missing rate".to_string())?;
3064            let shape = cfg.shape.ok_or_else(|| "gm missing shape".to_string())?;
3065            second[0][0] = first[0];
3066            second[0][1] = first[1];
3067            second[1][0] = first[1];
3068            second[1][1] = gompertz_cumulative_shape_second_derivative(age, rate, shape);
3069            second[2][2] = first[2];
3070        }
3071    }
3072    Ok(Some((hazard, first, second)))
3073}
3074
3075#[inline]
3076fn gompertz_cumulative_shape_second_derivative(age: f64, rate: f64, shape: f64) -> (f64, f64) {
3077    let x = shape * age;
3078    // ∂²H_G/∂shape² = rate·[t²·E/shape − 2·(shape·t·E − (E−1))/shape³]. This is
3079    // a difference of O(1/shape³) terms whose leading parts cancel, so its
3080    // floating-point accuracy is governed by x = shape·age — and the
3081    // cancellation is FAR worse than the first derivative's 1/shape² form.
3082    // Empirically the exact branch is already garbage for |x| < ~1e-4 (e.g.
3083    // x=1e-9 gives a ~98% relative error; x=1e-10 a ~9700% error). The old
3084    // `shape < 1e-10` pivot ignored `age` and so routed those small-x cases
3085    // through the cancelling exact form, corrupting the marginal-slope baseline
3086    // Hessian near small shape. Pivot on x with a wider threshold than the
3087    // first derivative: the 3-term Taylor (through O(x²)) holds to <1e-8 for
3088    // |x| < 1e-3, and the exact branch is clean above it.
3089    if x.abs() < 1e-3 {
3090        let t = age;
3091        (
3092            rate * t * t * t * (1.0 / 3.0 + x / 4.0 + x * x / 10.0),
3093            rate * t * t * (1.0 + x + 0.5 * x * x),
3094        )
3095    } else {
3096        let e = x.exp();
3097        let em1 = x.exp_m1();
3098        let n = shape * age * e - em1;
3099        (
3100            rate * (age * age * e / shape - 2.0 * n / (shape * shape * shape)),
3101            rate * age * age * e,
3102        )
3103    }
3104}
3105
3106// ---------------------------------------------------------------------------
3107// Baseline offsets
3108// ---------------------------------------------------------------------------
3109
3110#[derive(Clone, Copy)]
3111enum BaselineOffsetEvaluator {
3112    LogCumulativeHazard,
3113    ProbitSurvival,
3114}
3115
3116impl BaselineOffsetEvaluator {
3117    fn length_error(self) -> String {
3118        match self {
3119            Self::LogCumulativeHazard => SurvivalConstructionError::IncompatibleDimensions {
3120                reason: "survival baseline offsets require matching entry/exit lengths".to_string(),
3121            }
3122            .into(),
3123            Self::ProbitSurvival => {
3124                "survival probit baseline offsets require matching entry/exit lengths".to_string()
3125            }
3126        }
3127    }
3128
3129    fn finite_error(self) -> &'static str {
3130        match self {
3131            Self::LogCumulativeHazard => "non-finite survival baseline offsets computed",
3132            Self::ProbitSurvival => "non-finite survival probit baseline offsets computed",
3133        }
3134    }
3135
3136    fn evaluate(self, age: f64, cfg: &SurvivalBaselineConfig) -> Result<(f64, f64), String> {
3137        match self {
3138            Self::LogCumulativeHazard => evaluate_survival_baseline(age, cfg),
3139            Self::ProbitSurvival => evaluate_survival_marginal_slope_baseline(age, cfg),
3140        }
3141    }
3142
3143    fn exit_is_finite(self, value: f64, age: f64) -> bool {
3144        match self {
3145            Self::LogCumulativeHazard => {
3146                value.is_finite() || (age == 0.0 && value == f64::NEG_INFINITY)
3147            }
3148            Self::ProbitSurvival => value.is_finite(),
3149        }
3150    }
3151}
3152
3153fn build_survival_offsets_with_evaluator(
3154    age_entry: &Array1<f64>,
3155    age_exit: &Array1<f64>,
3156    cfg: &SurvivalBaselineConfig,
3157    evaluator: BaselineOffsetEvaluator,
3158) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3159    if age_entry.len() != age_exit.len() {
3160        return Err(evaluator.length_error());
3161    }
3162    let n = age_entry.len();
3163    // Each row's three offsets are independent across i. Compute the triplets
3164    // in parallel, then unpack into three Array1 outputs preserving order.
3165    let triples: Vec<(f64, f64, f64)> = (0..n)
3166        .into_par_iter()
3167        .map(|i| -> Result<(f64, f64, f64), String> {
3168            // Origin-entry rows are multiplied out by the survival engines, so
3169            // keep their entry channel finite even when the evaluator's natural
3170            // value at t=0 is undefined or -inf.
3171            let entry_age = age_entry[i];
3172            let e0 = if !entry_age.is_finite() {
3173                return Err(SurvivalConstructionError::DataValidationFailed {
3174                    reason: format!("non-finite entry age at row {i}"),
3175                }
3176                .into());
3177            } else if entry_age <= 0.0 {
3178                0.0
3179            } else {
3180                evaluator.evaluate(entry_age, cfg)?.0
3181            };
3182            let exit_age = age_exit[i];
3183            let (e1, d1) = evaluator.evaluate(exit_age, cfg)?;
3184            if !e0.is_finite() || !evaluator.exit_is_finite(e1, exit_age) || !d1.is_finite() {
3185                return Err(SurvivalConstructionError::DataValidationFailed {
3186                    reason: evaluator.finite_error().to_string(),
3187                }
3188                .into());
3189            }
3190            Ok((e0, e1, d1))
3191        })
3192        .collect::<Result<Vec<_>, String>>()?;
3193    let mut eta_entry = Array1::<f64>::zeros(n);
3194    let mut eta_exit = Array1::<f64>::zeros(n);
3195    let mut derivative_exit = Array1::<f64>::zeros(n);
3196    for (i, (e0, e1, d1)) in triples.into_iter().enumerate() {
3197        eta_entry[i] = e0;
3198        eta_exit[i] = e1;
3199        derivative_exit[i] = d1;
3200    }
3201    Ok((eta_entry, eta_exit, derivative_exit))
3202}
3203
3204/// Compute baseline target offsets for all observations.
3205/// Returns `(eta_entry, eta_exit, derivative_exit)`.
3206pub fn build_survival_baseline_offsets(
3207    age_entry: &Array1<f64>,
3208    age_exit: &Array1<f64>,
3209    cfg: &SurvivalBaselineConfig,
3210) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3211    build_survival_offsets_with_evaluator(
3212        age_entry,
3213        age_exit,
3214        cfg,
3215        BaselineOffsetEvaluator::LogCumulativeHazard,
3216    )
3217}
3218
3219/// Compute probit-survival baseline target offsets for all observations.
3220/// Returns `(q_entry, q_exit, q_derivative_exit)` where `Phi(-q(t)) = exp(-H0(t))`.
3221pub fn build_survival_marginal_slope_baseline_offsets(
3222    age_entry: &Array1<f64>,
3223    age_exit: &Array1<f64>,
3224    cfg: &SurvivalBaselineConfig,
3225) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3226    build_survival_offsets_with_evaluator(
3227        age_entry,
3228        age_exit,
3229        cfg,
3230        BaselineOffsetEvaluator::ProbitSurvival,
3231    )
3232}
3233
3234pub fn location_scale_uses_probit_survival_baseline(inverse_link: Option<&InverseLink>) -> bool {
3235    matches!(
3236        inverse_link,
3237        Some(
3238            InverseLink::Standard(StandardLink::Probit)
3239                | InverseLink::LatentCLogLog(_)
3240                | InverseLink::Sas(_)
3241                | InverseLink::BetaLogistic(_)
3242                | InverseLink::Mixture(_)
3243        )
3244    )
3245}
3246
3247pub fn survival_derivative_guard_for_likelihood(likelihood_mode: SurvivalLikelihoodMode) -> f64 {
3248    match likelihood_mode {
3249        SurvivalLikelihoodMode::LocationScale
3250        | SurvivalLikelihoodMode::Latent
3251        | SurvivalLikelihoodMode::LatentBinary => DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD,
3252        SurvivalLikelihoodMode::MarginalSlope => DEFAULT_SURVIVAL_MARGINAL_SLOPE_DERIVATIVE_GUARD,
3253        SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull => 0.0,
3254    }
3255}
3256
3257pub fn build_survival_time_offsets_for_likelihood(
3258    age_entry: &Array1<f64>,
3259    age_exit: &Array1<f64>,
3260    baseline_cfg: &SurvivalBaselineConfig,
3261    likelihood_mode: SurvivalLikelihoodMode,
3262    inverse_link: Option<&InverseLink>,
3263) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
3264    if likelihood_mode == SurvivalLikelihoodMode::MarginalSlope
3265        || (likelihood_mode == SurvivalLikelihoodMode::LocationScale
3266            && location_scale_uses_probit_survival_baseline(inverse_link))
3267    {
3268        build_survival_marginal_slope_baseline_offsets(age_entry, age_exit, baseline_cfg)
3269    } else {
3270        build_survival_baseline_offsets(age_entry, age_exit, baseline_cfg)
3271    }
3272}
3273
3274pub fn add_survival_time_derivative_guard_offset(
3275    age_entry: &Array1<f64>,
3276    age_exit: &Array1<f64>,
3277    anchor_time: f64,
3278    derivative_guard: f64,
3279    eta_offset_entry: &mut Array1<f64>,
3280    eta_offset_exit: &mut Array1<f64>,
3281    derivative_offset_exit: &mut Array1<f64>,
3282) -> Result<(), String> {
3283    if derivative_guard <= 0.0 {
3284        return Ok(());
3285    }
3286    let n = age_entry.len();
3287    if age_exit.len() != n
3288        || eta_offset_entry.len() != n
3289        || eta_offset_exit.len() != n
3290        || derivative_offset_exit.len() != n
3291    {
3292        return Err(SurvivalConstructionError::IncompatibleDimensions {
3293            reason: "survival derivative-guard offset lengths must match".to_string(),
3294        }
3295        .into());
3296    }
3297    for i in 0..n {
3298        eta_offset_entry[i] += derivative_guard * (age_entry[i] - anchor_time);
3299        eta_offset_exit[i] += derivative_guard * (age_exit[i] - anchor_time);
3300        derivative_offset_exit[i] += derivative_guard;
3301    }
3302    Ok(())
3303}
3304
3305#[derive(Clone, Debug)]
3306pub struct LatentSurvivalBaselineOffsets {
3307    pub loaded_eta_entry: Array1<f64>,
3308    pub loaded_eta_exit: Array1<f64>,
3309    pub loaded_derivative_exit: Array1<f64>,
3310    pub unloaded_mass_entry: Array1<f64>,
3311    pub unloaded_mass_exit: Array1<f64>,
3312    pub unloaded_hazard_exit: Array1<f64>,
3313}
3314
3315pub fn build_latent_survival_baseline_offsets(
3316    age_entry: &Array1<f64>,
3317    age_exit: &Array1<f64>,
3318    cfg: &SurvivalBaselineConfig,
3319    loading: HazardLoading,
3320) -> Result<LatentSurvivalBaselineOffsets, String> {
3321    if age_entry.len() != age_exit.len() {
3322        return Err(
3323            "latent survival baseline offsets require matching entry/exit lengths".to_string(),
3324        );
3325    }
3326
3327    fn gompertz_components(age: f64, rate: f64, shape: f64) -> (f64, f64) {
3328        if shape.abs() < 1e-10 {
3329            // Taylor at shape=0 matching `gompertz_hazard_components`:
3330            //   H_G(t) = rate·t·(1 + (shape·t)/2 + (shape·t)²/6)
3331            //   h_G(t) = rate·(1 + shape·t + (shape·t)²/2)
3332            // Dropping the higher-order `shape*t` corrections silently
3333            // diverges this helper from its sibling for non-zero shape near
3334            // the cutoff and gives inconsistent loaded-vs-unloaded offsets.
3335            let x = shape * age;
3336            return (
3337                rate * age * (1.0 + 0.5 * x + x * x / 6.0),
3338                rate * (1.0 + x + 0.5 * x * x),
3339            );
3340        }
3341        let shape_age = shape * age;
3342        let cumulative_hazard = (rate / shape) * shape_age.exp_m1();
3343        let instant_hazard = rate * shape_age.exp();
3344        (cumulative_hazard, instant_hazard)
3345    }
3346
3347    let n = age_entry.len();
3348
3349    // Per-row 6-tuple is independent. Evaluate in parallel into a Vec and then
3350    // unpack into the six Array1 outputs in original order.
3351    let rows: Vec<[f64; 6]> = (0..n)
3352        .into_par_iter()
3353        .map(|i| -> Result<[f64; 6], String> {
3354            let entry = age_entry[i];
3355            let exit = age_exit[i];
3356            if !entry.is_finite()
3357                || !exit.is_finite()
3358                || entry <= 0.0
3359                || exit <= 0.0
3360                || exit < entry
3361            {
3362                return Err(format!(
3363                    "latent survival baseline offsets require finite positive entry/exit ages with exit >= entry (row {})",
3364                    i + 1
3365                ));
3366            }
3367            match loading {
3368                HazardLoading::Full => {
3369                    let (eta_entry, _) = evaluate_survival_baseline(entry, cfg)?;
3370                    let (eta_exit, derivative_exit) = evaluate_survival_baseline(exit, cfg)?;
3371                    Ok([eta_entry, eta_exit, derivative_exit, 0.0, 0.0, 0.0])
3372                }
3373                HazardLoading::LoadedVsUnloaded => {
3374                    if cfg.target != SurvivalBaselineTarget::GompertzMakeham {
3375                        return Err(format!(
3376                            "HazardLoading::LoadedVsUnloaded requires --baseline-target gompertz-makeham, got {}",
3377                            survival_baseline_targetname(cfg.target)
3378                        ));
3379                    }
3380                    let rate = cfg.rate.ok_or_else(|| {
3381                        "gompertz-makeham latent survival is missing baseline rate".to_string()
3382                    })?;
3383                    let shape = cfg.shape.ok_or_else(|| {
3384                        "gompertz-makeham latent survival is missing baseline shape".to_string()
3385                    })?;
3386                    let makeham = cfg.makeham.ok_or_else(|| {
3387                        "gompertz-makeham latent survival is missing baseline makeham".to_string()
3388                    })?;
3389                    let (loaded_entry, _) = gompertz_components(entry, rate, shape);
3390                    let (loaded_exit, loaded_hazard) = gompertz_components(exit, rate, shape);
3391                    if !(loaded_entry.is_finite()
3392                        && loaded_entry > 0.0
3393                        && loaded_exit.is_finite()
3394                        && loaded_exit > 0.0
3395                        && loaded_hazard.is_finite()
3396                        && loaded_hazard > 0.0)
3397                    {
3398                        return Err(format!(
3399                            "gompertz-makeham latent loaded component produced a non-positive or non-finite hazard decomposition at row {}",
3400                            i + 1
3401                        ));
3402                    }
3403                    Ok([
3404                        loaded_entry.ln(),
3405                        loaded_exit.ln(),
3406                        loaded_hazard / loaded_exit,
3407                        makeham * entry,
3408                        makeham * exit,
3409                        makeham,
3410                    ])
3411                }
3412            }
3413        })
3414        .collect::<Result<Vec<_>, String>>()?;
3415
3416    let mut loaded_eta_entry = Array1::<f64>::zeros(n);
3417    let mut loaded_eta_exit = Array1::<f64>::zeros(n);
3418    let mut loaded_derivative_exit = Array1::<f64>::zeros(n);
3419    let mut unloaded_mass_entry = Array1::<f64>::zeros(n);
3420    let mut unloaded_mass_exit = Array1::<f64>::zeros(n);
3421    let mut unloaded_hazard_exit = Array1::<f64>::zeros(n);
3422    for (i, row) in rows.into_iter().enumerate() {
3423        loaded_eta_entry[i] = row[0];
3424        loaded_eta_exit[i] = row[1];
3425        loaded_derivative_exit[i] = row[2];
3426        unloaded_mass_entry[i] = row[3];
3427        unloaded_mass_exit[i] = row[4];
3428        unloaded_hazard_exit[i] = row[5];
3429    }
3430
3431    Ok(LatentSurvivalBaselineOffsets {
3432        loaded_eta_entry,
3433        loaded_eta_exit,
3434        loaded_derivative_exit,
3435        unloaded_mass_entry,
3436        unloaded_mass_exit,
3437        unloaded_hazard_exit,
3438    })
3439}
3440
3441// ---------------------------------------------------------------------------
3442// Time wiggle construction
3443// ---------------------------------------------------------------------------
3444
3445pub fn build_survival_timewiggle_derivative_design(
3446    eta_exit: &Array1<f64>,
3447    derivative_exit: &Array1<f64>,
3448    knots: &Array1<f64>,
3449    degree: usize,
3450) -> Result<Array2<f64>, String> {
3451    let mut design_derivative_exit =
3452        monotone_wiggle_basis_with_derivative_order(eta_exit.view(), knots, degree, 1)?;
3453    for i in 0..design_derivative_exit.nrows() {
3454        let chain = derivative_exit[i];
3455        for j in 0..design_derivative_exit.ncols() {
3456            design_derivative_exit[[i, j]] *= chain;
3457        }
3458    }
3459    Ok(design_derivative_exit)
3460}
3461
3462/// Build the dynamic "baseline as prior" timewiggle runtime.
3463///
3464/// The baseline offsets are used only to initialize the wiggle knot placement
3465/// on a stable scalar scale.  The exact survival family evaluates the resulting
3466/// monotone wiggle dynamically on the current time predictor h0(t):
3467///
3468///   h(t) = g(h0(t)),   g(z) = z + w(z).
3469///
3470/// No fixed `B(eta_baseline)` design is constructed here.
3471pub fn build_survival_timewiggle_from_baseline(
3472    eta_entry: &Array1<f64>,
3473    eta_exit: &Array1<f64>,
3474    derivative_exit: &Array1<f64>,
3475    cfg: &LinkWiggleFormulaSpec,
3476) -> Result<SurvivalTimeWiggleBuild, String> {
3477    if eta_entry.len() != eta_exit.len() || eta_exit.len() != derivative_exit.len() {
3478        return Err(
3479            "baseline-timewiggle requires matching entry/exit/derivative lengths".to_string(),
3480        );
3481    }
3482    // Guard: if baseline offsets are all zero (linear baseline), the timewiggle
3483    // construction is degenerate — it adds only a constant, not time-varying structure.
3484    let all_zero = eta_entry.iter().all(|&v| v.abs() < 1e-15)
3485        && eta_exit.iter().all(|&v| v.abs() < 1e-15)
3486        && derivative_exit.iter().all(|&v| v.abs() < 1e-15);
3487    if all_zero {
3488        return Err(
3489            "timewiggle requires a non-linear scalar survival baseline target; \
3490             the provided baseline offsets are all zero (linear baseline)"
3491                .to_string(),
3492        );
3493    }
3494    let n = eta_exit.len();
3495    let mut seed = Array1::<f64>::zeros(2 * n);
3496    for i in 0..n {
3497        seed[i] = eta_entry[i];
3498        seed[n + i] = eta_exit[i];
3499    }
3500    // Use the smallest requested positive penalty order as the primary
3501    // coefficient-space penalty so the fitted wiggle penalty system matches
3502    // the public formula exactly, including the slope (`order = 1`) case.
3503    let (primary_order, extra_orders) = split_wiggle_penalty_orders(2, &cfg.penalty_orders);
3504    let wiggle_cfg = WiggleBlockConfig {
3505        degree: cfg.degree,
3506        num_internal_knots: cfg.num_internal_knots,
3507        penalty_order: primary_order,
3508        double_penalty: cfg.double_penalty,
3509    };
3510    let (mut combined_block, knots) = buildwiggle_block_input_from_seed(seed.view(), &wiggle_cfg)?;
3511    append_selected_wiggle_penalty_orders(&mut combined_block, &extra_orders)?;
3512    let ncols = combined_block.design.ncols();
3513    Ok(SurvivalTimeWiggleBuild {
3514        nullspace_dims: combined_block.nullspace_dims.clone(),
3515        penalties: {
3516            combined_block
3517                .penalties
3518                .into_iter()
3519                .map(|ps| ps.to_global(ncols))
3520                .collect()
3521        },
3522        knots,
3523        degree: cfg.degree,
3524        ncols,
3525    })
3526}
3527
3528pub fn append_zero_tail_columns(
3529    x_entry: &mut DesignMatrix,
3530    x_exit: &mut DesignMatrix,
3531    x_derivative: &mut DesignMatrix,
3532    tail_cols: usize,
3533) {
3534    if tail_cols == 0 {
3535        return;
3536    }
3537    // Wiggle tail columns are dense, so materialize everything to dense.
3538    // This only runs once at construction time when time-wiggles are active.
3539    fn append_dense(dm: &mut DesignMatrix, tail: usize) {
3540        let old = dm.to_dense();
3541        let n = old.nrows();
3542        let p_base = old.ncols();
3543        let mut out = Array2::<f64>::zeros((n, p_base + tail));
3544        out.slice_mut(s![.., 0..p_base]).assign(&old);
3545        *dm = DesignMatrix::Dense(DenseDesignMatrix::from(out));
3546    }
3547    append_dense(x_entry, tail_cols);
3548    append_dense(x_exit, tail_cols);
3549    append_dense(x_derivative, tail_cols);
3550}
3551
3552// ---------------------------------------------------------------------------
3553// Resolved config (from build output back to config for serialization)
3554// ---------------------------------------------------------------------------
3555
3556// ---------------------------------------------------------------------------
3557// Time-varying covariate template
3558// ---------------------------------------------------------------------------
3559
3560/// Build a time-varying covariate block by tensoring the covariate design
3561/// with a 1D B-spline basis on log(time).
3562pub fn build_time_varying_survival_covariate_template(
3563    age_entry: &Array1<f64>,
3564    age_exit: &Array1<f64>,
3565    time_k: usize,
3566    time_degree: usize,
3567    block_name: &str,
3568) -> Result<SurvivalCovariateTermBlockTemplate, String> {
3569    if time_k < time_degree + 1 {
3570        return Err(format!(
3571            "--{block_name}-time-k must be >= degree + 1 = {}, got {time_k}",
3572            time_degree + 1
3573        ));
3574    }
3575    let num_internal_knots = time_k - (time_degree + 1);
3576
3577    let log_entry = age_entry.mapv(|t| t.max(1e-12).ln());
3578    let log_exit = age_exit.mapv(|t| t.max(1e-12).ln());
3579
3580    let time_spec = BSplineBasisSpec {
3581        degree: time_degree,
3582        penalty_order: 2,
3583        knotspec: BSplineKnotSpec::Automatic {
3584            num_internal_knots: Some(num_internal_knots),
3585            placement: gam_terms::basis::BSplineKnotPlacement::Quantile,
3586        },
3587        double_penalty: false,
3588        identifiability: BSplineIdentifiability::None,
3589        boundary: OneDimensionalBoundary::Open,
3590        boundary_conditions: BSplineBoundaryConditions::default(),
3591    };
3592
3593    let time_build = build_bspline_basis_1d(log_exit.view(), &time_spec)
3594        .map_err(|e| format!("failed to build {block_name} time-margin B-spline basis: {e}"))?;
3595    let time_design_exit = time_build.design.to_dense();
3596
3597    let knots = match &time_build.metadata {
3598        BasisMetadata::BSpline1D { knots, .. } => knots.clone(),
3599        _ => {
3600            return Err(format!(
3601                "{block_name} time-margin basis returned unexpected metadata type"
3602            ));
3603        }
3604    };
3605
3606    let time_build_entry = build_bspline_basis_1d(
3607        log_entry.view(),
3608        &BSplineBasisSpec {
3609            degree: time_degree,
3610            penalty_order: 2,
3611            knotspec: BSplineKnotSpec::Provided(knots.clone()),
3612            double_penalty: false,
3613            identifiability: BSplineIdentifiability::None,
3614            boundary: OneDimensionalBoundary::Open,
3615            boundary_conditions: BSplineBoundaryConditions::default(),
3616        },
3617    )
3618    .map_err(|e| format!("failed to evaluate {block_name} time-margin basis at entry: {e}"))?;
3619    let time_design_entry = time_build_entry.design.to_dense();
3620    let p_time = time_design_exit.ncols();
3621    let mut time_design_derivative_exit = Array2::<f64>::zeros((age_exit.len(), p_time));
3622    // Per-row derivative-basis evaluation is independent; each row owns its
3623    // own small `deriv_buf`. par_chunks_mut over the (n × p_time) output rows
3624    // hands disjoint mutable row-slices to rayon workers.
3625    time_design_derivative_exit
3626        .as_slice_mut()
3627        .expect("zeros are contiguous")
3628        .par_chunks_mut(p_time)
3629        .enumerate()
3630        .try_for_each(|(i, row_out)| -> Result<(), String> {
3631            let mut deriv_buf = vec![0.0_f64; p_time];
3632            evaluate_bspline_derivative_scalar(
3633                log_exit[i],
3634                knots.view(),
3635                time_degree,
3636                &mut deriv_buf,
3637            )
3638            .map_err(|e| {
3639                format!("failed to evaluate {block_name} time-margin derivative basis: {e}")
3640            })?;
3641            let chain = 1.0 / age_exit[i].max(1e-12);
3642            for j in 0..p_time {
3643                row_out[j] = deriv_buf[j] * chain;
3644            }
3645            Ok(())
3646        })?;
3647
3648    Ok(SurvivalCovariateTermBlockTemplate::TimeVarying {
3649        time_basis_entry: time_design_entry,
3650        time_basis_exit: time_design_exit,
3651        time_basis_derivative_exit: time_design_derivative_exit,
3652        time_penalties: time_build.penalties,
3653    })
3654}
3655
3656#[cfg(test)]
3657mod tests {
3658    use super::{
3659        SurvivalBaselineConfig, SurvivalBaselineTarget, SurvivalTimeBasisConfig,
3660        baseline_chain_rule_gradient, baseline_offset_theta_partials,
3661        build_survival_marginal_slope_baseline_offsets, build_survival_time_basis,
3662        build_survival_timewiggle_from_baseline, evaluate_survival_baseline,
3663        evaluate_survival_marginal_slope_baseline, fitted_weibull_baseline_from_linear_time_beta,
3664        gompertz_cumulative_shape_derivative, gompertz_cumulative_shape_second_derivative,
3665        gompertz_hazard_components, marginal_slope_baseline_chain_rule_gradient,
3666        marginal_slope_baseline_chain_rule_hessian, marginal_slope_baseline_offset_theta_partials,
3667        optimize_survival_baseline_config_with_gradient,
3668        optimize_survival_baseline_config_with_gradient_only,
3669        resolve_survival_marginal_slope_time_anchor_value, survival_baseline_config_from_theta,
3670        survival_baseline_theta_from_config,
3671    };
3672    use crate::probability::normal_cdf;
3673    use crate::survival::{OffsetChannelCurvatures, OffsetChannelResiduals};
3674    use gam_terms::inference::formula_dsl::LinkWiggleFormulaSpec;
3675    use ndarray::{Array1, Array2, array};
3676
3677    #[test]
3678    fn fitted_weibull_baseline_uses_identified_anchor_and_slope() {
3679        let fitted = fitted_weibull_baseline_from_linear_time_beta(&array![123.0, 1.75], 4.5)
3680            .expect("valid Weibull baseline");
3681        assert_eq!(fitted.target, SurvivalBaselineTarget::Weibull);
3682        assert_eq!(fitted.scale, Some(4.5));
3683        assert_eq!(fitted.shape, Some(1.75));
3684        assert_eq!(fitted.rate, None);
3685        assert_eq!(fitted.makeham, None);
3686
3687        assert!(fitted_weibull_baseline_from_linear_time_beta(&array![1.0], 4.5).is_none());
3688        assert!(fitted_weibull_baseline_from_linear_time_beta(&array![0.0, 0.0], 4.5).is_none());
3689        assert!(fitted_weibull_baseline_from_linear_time_beta(&array![0.0, 1.0], 0.0).is_none());
3690    }
3691
3692    #[test]
3693    fn survival_timewiggle_keeps_requested_order_one_penalty() {
3694        let eta_entry = array![0.1, 0.3, 0.5, 0.8];
3695        let eta_exit = array![0.4, 0.7, 1.0, 1.4];
3696        let derivative_exit = array![0.9, 1.1, 1.2, 1.3];
3697        let cfg = LinkWiggleFormulaSpec {
3698            degree: 3,
3699            num_internal_knots: 4,
3700            penalty_orders: vec![1, 2, 3],
3701            double_penalty: false,
3702        };
3703
3704        let build =
3705            build_survival_timewiggle_from_baseline(&eta_entry, &eta_exit, &derivative_exit, &cfg)
3706                .expect("build survival timewiggle");
3707
3708        assert_eq!(build.penalties.len(), 3);
3709        assert_eq!(build.nullspace_dims, vec![1, 2, 3]);
3710        assert!(build.ncols > 0);
3711    }
3712
3713    #[test]
3714    fn marginal_slope_time_anchor_defaults_to_median_exit() {
3715        let age_entry = array![9.0, 1.0, 4.0, 6.0];
3716        let age_exit = array![20.0, 12.0, 18.0, 30.0];
3717        let anchor = resolve_survival_marginal_slope_time_anchor_value(&age_entry, &age_exit, None)
3718            .expect("resolve marginal-slope default time anchor");
3719
3720        assert!(
3721            (anchor - 19.0).abs() <= 1e-12,
3722            "marginal-slope default anchor should be median exit, got {anchor}"
3723        );
3724    }
3725
3726    #[test]
3727    fn marginal_slope_time_anchor_honors_explicit_value() {
3728        let age_entry = array![9.0, 1.0, 4.0, 6.0];
3729        let age_exit = array![20.0, 12.0, 18.0, 30.0];
3730        let anchor =
3731            resolve_survival_marginal_slope_time_anchor_value(&age_entry, &age_exit, Some(7.5))
3732                .expect("resolve explicit marginal-slope time anchor");
3733
3734        assert!(
3735            (anchor - 7.5).abs() <= 1e-12,
3736            "explicit marginal-slope anchor should round-trip, got {anchor}"
3737        );
3738    }
3739
3740    /// Derivative-contract parity for the two public baseline optimizers.
3741    ///
3742    /// After the unification onto `run_baseline_theta_optimizer`, the
3743    /// gradient-only and gradient+Hessian entry points differ *only* in how
3744    /// much derivative information they hand the outer solver — not in the
3745    /// surface they minimize. We exercise that invariant on a known
3746    /// strictly-convex quadratic in θ-space (Weibull baseline: θ = (ln scale,
3747    /// ln shape)) whose unique minimizer is `theta_star`, supplying the same
3748    /// objective as `(f, ∇f)` and as `(f, ∇f, ∇²f)`. Both contracts must
3749    /// recover the same minimizer config, not weakened to pass.
3750    #[test]
3751    fn baseline_optimizer_contracts_agree_on_shared_surface() {
3752        // SPD curvature and interior minimizer in θ-space. A is well away from
3753        // singular so both the analytic-Hessian and BFGS paths see the same
3754        // unambiguous bowl; θ* sits comfortably inside the ±6 box around the
3755        // θ=(0,0) seed below.
3756        let curvature: Array2<f64> = array![[3.0, 0.5], [0.5, 2.0]];
3757        let theta_star: Array1<f64> = array![2.5_f64.ln(), 1.3_f64.ln()];
3758
3759        // Seed config at θ=(0,0) (scale=shape=1). The Linear early-return path
3760        // is not exercised here; Weibull has a genuine 2-dim θ to optimize.
3761        let initial = SurvivalBaselineConfig {
3762            target: SurvivalBaselineTarget::Weibull,
3763            scale: Some(1.0),
3764            shape: Some(1.0),
3765            rate: None,
3766            makeham: None,
3767        };
3768
3769        // θ recovered from a returned Weibull config, via the exact inverse of
3770        // the config→θ map the optimizers use internally.
3771        let recovered_theta = |cfg: &SurvivalBaselineConfig| -> Array1<f64> {
3772            survival_baseline_theta_from_config(cfg)
3773                .expect("config→θ")
3774                .expect("Weibull config has a θ")
3775        };
3776
3777        // Shared quadratic surface, evaluated by mapping config→θ so every
3778        // contract sees the identical objective.
3779        let curvature_cost = curvature.clone();
3780        let star_cost = theta_star.clone();
3781        let cost_at = move |cfg: &SurvivalBaselineConfig| -> Result<f64, String> {
3782            let theta = survival_baseline_theta_from_config(cfg)?
3783                .ok_or_else(|| "expected a θ for the cost surface".to_string())?;
3784            let d = &theta - &star_cost;
3785            let ad = curvature_cost.dot(&d);
3786            Ok(0.5 * d.dot(&ad))
3787        };
3788
3789        let curvature_grad = curvature.clone();
3790        let star_grad = theta_star.clone();
3791        let cost_for_grad = cost_at.clone();
3792        let result_grad_only = optimize_survival_baseline_config_with_gradient_only(
3793            &initial,
3794            "baseline parity (gradient-only)",
3795            move |cfg| {
3796                let cost = cost_for_grad(cfg)?;
3797                let theta = survival_baseline_theta_from_config(cfg)?
3798                    .ok_or_else(|| "expected a θ for the gradient".to_string())?;
3799                let gradient = curvature_grad.dot(&(&theta - &star_grad));
3800                Ok((cost, gradient))
3801            },
3802        )
3803        .expect("gradient-only baseline optimization converges");
3804
3805        let curvature_hess = curvature.clone();
3806        let star_hess = theta_star.clone();
3807        let cost_for_hess = cost_at.clone();
3808        let result_grad_hess = optimize_survival_baseline_config_with_gradient(
3809            &initial,
3810            "baseline parity (gradient+Hessian)",
3811            move |cfg| {
3812                let cost = cost_for_hess(cfg)?;
3813                let theta = survival_baseline_theta_from_config(cfg)?
3814                    .ok_or_else(|| "expected a θ for the gradient".to_string())?;
3815                let gradient = curvature_hess.dot(&(&theta - &star_hess));
3816                Ok((cost, gradient, curvature_hess.clone()))
3817            },
3818        )
3819        .expect("gradient+Hessian baseline optimization converges");
3820
3821        let theta_grad_only = recovered_theta(&result_grad_only);
3822        let theta_grad_hess = recovered_theta(&result_grad_hess);
3823
3824        // Each contract recovers the true minimizer. 2e-3 is a safe,
3825        // un-weakened bound; both gradient paths land far tighter.
3826        for (label, theta) in [
3827            ("gradient-only", &theta_grad_only),
3828            ("gradient+Hessian", &theta_grad_hess),
3829        ] {
3830            let err = (theta - &theta_star)
3831                .mapv(f64::abs)
3832                .fold(0.0_f64, |a, &v| a.max(v));
3833            assert!(
3834                err <= 2e-3,
3835                "{label} contract recovered θ {theta:?} off true minimizer {theta_star:?} by {err:e}"
3836            );
3837        }
3838
3839        // Cross-contract agreement: the three results must coincide, since the
3840        // only difference between the entry points is the derivative contract,
3841        // never the surface they minimize.
3842        let pairwise_max = |a: &Array1<f64>, b: &Array1<f64>| -> f64 {
3843            (a - b).mapv(f64::abs).fold(0.0_f64, |acc, &v| acc.max(v))
3844        };
3845        assert!(
3846            pairwise_max(&theta_grad_only, &theta_grad_hess) <= 2e-3,
3847            "gradient-only vs gradient+Hessian disagree: {theta_grad_only:?} vs {theta_grad_hess:?}"
3848        );
3849    }
3850
3851    #[test]
3852    fn automatic_ispline_time_knots_are_sized_for_antiderivative_degree() {
3853        let age_entry = array![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0];
3854        let age_exit = array![2.0_f64, 3.0, 5.0, 8.0, 13.0, 21.0];
3855        let requested_degree = 3;
3856        let num_internal_knots = 1;
3857
3858        let built = build_survival_time_basis(
3859            &age_entry,
3860            &age_exit,
3861            SurvivalTimeBasisConfig::ISpline {
3862                degree: requested_degree,
3863                knots: Array1::zeros(0),
3864                keep_cols: Vec::new(),
3865                smooth_lambda: 1e-2,
3866            },
3867            Some((num_internal_knots, 1e-2)),
3868        )
3869        .expect("automatic cubic ispline with one interior knot builds");
3870
3871        let working_degree = requested_degree + 1;
3872        let knots = built.knots.expect("resolved ispline knots");
3873        assert_eq!(
3874            knots.len(),
3875            num_internal_knots + 2 * (working_degree + 1),
3876            "I-spline automatic knots must be clamped for the working B-spline degree"
3877        );
3878        assert_eq!(built.degree, Some(requested_degree));
3879        assert!(built.x_exit_time.ncols() > 0);
3880        assert_eq!(built.x_entry_time.ncols(), built.x_exit_time.ncols());
3881        assert_eq!(built.x_derivative_time.ncols(), built.x_exit_time.ncols());
3882    }
3883
3884    #[test]
3885    fn ispline_time_derivative_is_nonzero_at_right_boundary() {
3886        let age_entry = array![1.0_f64, 1.0, 1.0];
3887        let age_exit = array![4.0_f64, 4.0, 4.0];
3888        let left = 1.0_f64.ln();
3889        let right = 4.0_f64.ln();
3890        let mid = left + 0.5 * (right - left);
3891        let knots = array![left, left, left, left, mid, right, right, right, right];
3892
3893        let built = build_survival_time_basis(
3894            &age_entry,
3895            &age_exit,
3896            SurvivalTimeBasisConfig::ISpline {
3897                degree: 2,
3898                knots,
3899                keep_cols: Vec::new(),
3900                smooth_lambda: 1e-2,
3901            },
3902            None,
3903        )
3904        .expect("build right-boundary ispline time basis");
3905
3906        let derivative = built.x_derivative_time.as_dense_cow();
3907        let max_abs = derivative.iter().fold(0.0_f64, |acc, v| acc.max(v.abs()));
3908        assert!(
3909            max_abs > 1e-8,
3910            "right-boundary I-spline derivative must use the left-hand endpoint slope"
3911        );
3912        for row in derivative.rows() {
3913            assert!(
3914                row.iter().any(|v| *v > 1e-8),
3915                "each row at the right boundary needs a positive hazard derivative"
3916            );
3917        }
3918    }
3919
3920    #[test]
3921    fn ispline_time_penalty_is_psd_under_nontrivial_keep_cols() {
3922        // PSD-invariant forward guard for the gam#979 survival hang. The I-spline
3923        // value-space curvature penalty on the increment coefficients is the
3924        // congruence `S_I = Lᵀ S_B[1:,1:] L`. When identifiability drops columns,
3925        // the retained block MUST be taken as a PRINCIPAL SUBMATRIX of the FULL
3926        // congruence (congruence first, column selection second). The historical
3927        // regression assembled the reduced penalty in the wrong order, producing
3928        // a strongly INDEFINITE matrix (measured `s0_min_eval = −9.8e7`); an
3929        // indefinite time penalty makes `½γᵀ S_I γ` unbounded below, the inner
3930        // joint-Newton follows the divergence, and the outer REML never
3931        // terminates — the survival marginal-slope hang.
3932        //
3933        // This test exercises the reduction with a NON-TRIVIAL `keep_cols`
3934        // (a proper subset, an interior column dropped) and asserts the assembled
3935        // penalty satisfies the PSD contract the fix guarantees. It locks the
3936        // invariant on the shipped code path so a future reassembly that
3937        // reintroduces an indefinite reduction is caught at construction rather
3938        // than silently as an outer-loop hang. (It is a forward invariant lock,
3939        // not a bit-exact replay of the removed buggy assembly.)
3940        let age_entry = array![1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0];
3941        let age_exit = array![2.0_f64, 3.0, 5.0, 8.0, 13.0, 21.0];
3942        let left = 1.0_f64.ln();
3943        let right = 21.0_f64.ln();
3944        let q1 = left + 0.25 * (right - left);
3945        let mid = left + 0.5 * (right - left);
3946        let q3 = left + 0.75 * (right - left);
3947        // Degree-2 I-spline with three interior knots -> a value-space basis wide
3948        // enough to drop an interior column and still leave the reduction
3949        // non-trivial (p_time < p_time_full).
3950        let knots = array![
3951            left, left, left, left, q1, mid, q3, right, right, right, right
3952        ];
3953
3954        // Discover the full basis width by building with all columns retained.
3955        let full = build_survival_time_basis(
3956            &age_entry,
3957            &age_exit,
3958            SurvivalTimeBasisConfig::ISpline {
3959                degree: 2,
3960                knots: knots.clone(),
3961                keep_cols: Vec::new(),
3962                smooth_lambda: 1e-2,
3963            },
3964            None,
3965        )
3966        .expect("build full-width ispline time basis");
3967        let p_time_full = full
3968            .keep_cols
3969            .as_ref()
3970            .map(|k| k.len())
3971            .unwrap_or_else(|| full.x_exit_time.ncols());
3972        assert!(
3973            p_time_full >= 3,
3974            "test needs at least 3 shape-varying columns to drop an interior one; got {p_time_full}"
3975        );
3976
3977        // Retain everything except one interior column, forcing the
3978        // principal-submatrix-of-the-full-congruence path.
3979        let keep_cols: Vec<usize> = (0..p_time_full).filter(|&j| j != 1).collect();
3980
3981        let built = build_survival_time_basis(
3982            &age_entry,
3983            &age_exit,
3984            SurvivalTimeBasisConfig::ISpline {
3985                degree: 2,
3986                knots,
3987                keep_cols: keep_cols.clone(),
3988                smooth_lambda: 1e-2,
3989            },
3990            None,
3991        )
3992        .expect(
3993            "reduced ispline penalty must build (PSD contract must accept the \
3994             congruence-first / select-second ordering)",
3995        );
3996
3997        assert_eq!(
3998            built.penalties.len(),
3999            1,
4000            "the ispline time basis should carry exactly one curvature penalty"
4001        );
4002        let s = &built.penalties[0];
4003        assert_eq!(s.nrows(), keep_cols.len());
4004        assert_eq!(s.ncols(), keep_cols.len());
4005
4006        let (evals, _) = gam_linalg::faer_ndarray::FaerEigh::eigh(s, faer::Side::Lower)
4007            .expect("eigh of penalty");
4008        let evals_slice = evals.as_slice().expect("contiguous eigenvalues");
4009        let max_abs = evals_slice
4010            .iter()
4011            .copied()
4012            .fold(0.0_f64, |a, b| a.max(b.abs()))
4013            .max(1.0);
4014        let min_ev = evals_slice.iter().copied().fold(f64::INFINITY, f64::min);
4015        let tol = -100.0 * (s.nrows() as f64) * f64::EPSILON * max_abs;
4016        assert!(
4017            min_ev >= tol,
4018            "reduced I-spline time penalty must be PSD (gam#979): min eigenvalue \
4019             {min_ev:.3e} < tol {tol:.3e}, max|eig| {max_abs:.3e}"
4020        );
4021    }
4022
4023    #[test]
4024    fn marginal_slope_baseline_maps_gompertz_makeham_survival_to_probit_index() {
4025        let cfg = SurvivalBaselineConfig {
4026            target: SurvivalBaselineTarget::GompertzMakeham,
4027            scale: None,
4028            shape: Some(0.07),
4029            rate: Some(0.012),
4030            makeham: Some(0.003),
4031        };
4032        let age = 11.5;
4033        let (q, q_derivative) = evaluate_survival_marginal_slope_baseline(age, &cfg)
4034            .expect("evaluate marginal-slope gompertz-makeham baseline");
4035        let shape = cfg.shape.expect("shape");
4036        let rate = cfg.rate.expect("rate");
4037        let makeham = cfg.makeham.expect("makeham");
4038        let cumulative_hazard = makeham * age + (rate / shape) * ((shape * age).exp() - 1.0);
4039        let instant_hazard = makeham + rate * (shape * age).exp();
4040        let expected_survival = (-cumulative_hazard).exp();
4041        let actual_survival = normal_cdf(-q);
4042        assert!((actual_survival - expected_survival).abs() <= 1e-12);
4043
4044        let h = 1e-5;
4045        let q_plus = evaluate_survival_marginal_slope_baseline(age + h, &cfg)
4046            .expect("q plus")
4047            .0;
4048        let q_minus = evaluate_survival_marginal_slope_baseline(age - h, &cfg)
4049            .expect("q minus")
4050            .0;
4051        let fd = (q_plus - q_minus) / (2.0 * h);
4052        assert!((q_derivative - fd).abs() <= 1e-7);
4053        assert!(instant_hazard > 0.0);
4054    }
4055
4056    #[test]
4057    fn marginal_slope_baseline_is_evaluable_at_the_survival_curve_origin() {
4058        // Regression for #1024: the probit/marginal-slope baseline evaluator must
4059        // be defined at the survival-curve origin t = 0 (where S0(0) = 1, so the
4060        // probit index q(0) = -Phi^{-1}(1) = -inf and there is no finite offset),
4061        // exactly like its log-cumulative-hazard sibling `evaluate_survival_baseline`.
4062        // Before the fix the shared `age <= 0` hazard guard aborted, so a survival
4063        // prediction grid whose first node is the origin (the `Surv(time, event)`
4064        // right-censored shorthand) could not be evaluated for the location-scale /
4065        // marginal-slope likelihoods.
4066        let configs = [
4067            SurvivalBaselineConfig {
4068                target: SurvivalBaselineTarget::Linear,
4069                scale: None,
4070                shape: None,
4071                rate: None,
4072                makeham: None,
4073            },
4074            SurvivalBaselineConfig {
4075                target: SurvivalBaselineTarget::Weibull,
4076                scale: Some(2.5),
4077                shape: Some(1.3),
4078                rate: None,
4079                makeham: None,
4080            },
4081            SurvivalBaselineConfig {
4082                target: SurvivalBaselineTarget::Gompertz,
4083                scale: None,
4084                shape: Some(0.05),
4085                rate: Some(0.01),
4086                makeham: None,
4087            },
4088            SurvivalBaselineConfig {
4089                target: SurvivalBaselineTarget::GompertzMakeham,
4090                scale: None,
4091                shape: Some(0.07),
4092                rate: Some(0.012),
4093                makeham: Some(0.003),
4094            },
4095        ];
4096        for cfg in &configs {
4097            // The probit baseline returns a finite zero offset at the origin for
4098            // every target (the survival surface anchors S(0) = 1 directly).
4099            let (q0, q0_derivative) = evaluate_survival_marginal_slope_baseline(0.0, cfg)
4100                .expect("marginal-slope baseline must be evaluable at the origin");
4101            assert_eq!(q0, 0.0);
4102            assert_eq!(q0_derivative, 0.0);
4103
4104            // The log-cumulative-hazard sibling is likewise finite at the origin —
4105            // this parity is the whole point (the transformation likelihood already
4106            // worked because it rides this evaluator).
4107            let (eta0, eta0_derivative) =
4108                evaluate_survival_baseline(0.0, cfg).expect("log-cum-hazard baseline at origin");
4109            assert!(eta0_derivative.is_finite());
4110            assert!(eta0.is_finite() || eta0 == f64::NEG_INFINITY);
4111
4112            // The batched offset builder must not abort when a query exit age is the
4113            // origin (this is the exact call the location-scale predict path makes on
4114            // the default surface grid). Entry stays at the origin, exit spans 0 -> t.
4115            let age_entry = array![0.0, 0.0];
4116            let age_exit = array![0.0, 1.5];
4117            let (entry, exit, derivative) =
4118                build_survival_marginal_slope_baseline_offsets(&age_entry, &age_exit, cfg)
4119                    .expect("probit baseline offsets must build through the origin");
4120            assert!(entry.iter().all(|v| v.is_finite()));
4121            assert!(exit.iter().all(|v| v.is_finite()));
4122            assert!(derivative.iter().all(|v| v.is_finite()));
4123            // The origin exit column carries no probit offset.
4124            assert_eq!(exit[0], 0.0);
4125        }
4126    }
4127
4128    #[test]
4129    fn marginal_slope_baseline_offsets_use_true_gompertz_makeham_survival() {
4130        let cfg = SurvivalBaselineConfig {
4131            target: SurvivalBaselineTarget::GompertzMakeham,
4132            scale: None,
4133            shape: Some(0.03),
4134            rate: Some(0.01),
4135            makeham: Some(0.002),
4136        };
4137        let age_entry = array![2.0, 4.0];
4138        let age_exit = array![5.0, 9.0];
4139        let (entry, exit, derivative) =
4140            build_survival_marginal_slope_baseline_offsets(&age_entry, &age_exit, &cfg)
4141                .expect("marginal-slope baseline offsets");
4142        for i in 0..age_entry.len() {
4143            let entry_h = cfg.makeham.expect("makeham") * age_entry[i]
4144                + (cfg.rate.expect("rate") / cfg.shape.expect("shape"))
4145                    * ((cfg.shape.expect("shape") * age_entry[i]).exp() - 1.0);
4146            let exit_h = cfg.makeham.expect("makeham") * age_exit[i]
4147                + (cfg.rate.expect("rate") / cfg.shape.expect("shape"))
4148                    * ((cfg.shape.expect("shape") * age_exit[i]).exp() - 1.0);
4149            assert!((normal_cdf(-entry[i]) - (-entry_h).exp()).abs() <= 1e-12);
4150            assert!((normal_cdf(-exit[i]) - (-exit_h).exp()).abs() <= 1e-12);
4151            assert!(derivative[i].is_finite() && derivative[i] > 0.0);
4152        }
4153    }
4154
4155    fn fd_marginal_slope_baseline_offset(
4156        age: f64,
4157        cfg: &SurvivalBaselineConfig,
4158        steps: &[f64],
4159    ) -> Vec<(f64, f64)> {
4160        let theta = survival_baseline_theta_from_config(cfg)
4161            .expect("theta")
4162            .expect("non-linear baseline");
4163        assert_eq!(
4164            steps.len(),
4165            theta.len(),
4166            "fd_marginal_slope_baseline_offset: step vector length must match θ dimension"
4167        );
4168        (0..theta.len())
4169            .map(|k| {
4170                let h = steps[k];
4171                let mut theta_plus = theta.clone();
4172                theta_plus[k] += h;
4173                let mut theta_minus = theta.clone();
4174                theta_minus[k] -= h;
4175                let cfg_plus =
4176                    survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("plus cfg");
4177                let cfg_minus = survival_baseline_config_from_theta(cfg.target, &theta_minus)
4178                    .expect("minus cfg");
4179                let (q_p, qt_p) =
4180                    evaluate_survival_marginal_slope_baseline(age, &cfg_plus).expect("q+");
4181                let (q_m, qt_m) =
4182                    evaluate_survival_marginal_slope_baseline(age, &cfg_minus).expect("q-");
4183                ((q_p - q_m) / (2.0 * h), (qt_p - qt_m) / (2.0 * h))
4184            })
4185            .collect()
4186    }
4187
4188    #[test]
4189    fn marginal_slope_baseline_theta_partials_match_fd_for_gompertz_makeham() {
4190        let cfg = SurvivalBaselineConfig {
4191            target: SurvivalBaselineTarget::GompertzMakeham,
4192            scale: None,
4193            shape: Some(0.04),
4194            rate: Some(0.013),
4195            makeham: Some(0.002),
4196        };
4197        let age = 17.0;
4198        let analytic = marginal_slope_baseline_offset_theta_partials(age, &cfg)
4199            .expect("partials")
4200            .expect("nonlinear");
4201        let fd = fd_marginal_slope_baseline_offset(age, &cfg, &[1e-5, 1e-5, 1e-5]);
4202        assert_eq!(analytic.len(), fd.len());
4203        for (k, ((aq, aqt), (fq, fqt))) in analytic.iter().zip(fd.iter()).enumerate() {
4204            assert_close(*aq, *fq, 1e-6, &format!("gm-probit q theta[{k}]"));
4205            assert_close(*aqt, *fqt, 1e-6, &format!("gm-probit q' theta[{k}]"));
4206        }
4207    }
4208
4209    #[test]
4210    fn marginal_slope_baseline_theta_partials_match_fd_near_zero_gompertz_shape() {
4211        let cfg = SurvivalBaselineConfig {
4212            target: SurvivalBaselineTarget::GompertzMakeham,
4213            scale: None,
4214            shape: Some(1e-14),
4215            rate: Some(0.013),
4216            makeham: Some(0.002),
4217        };
4218        let age = 17.0;
4219        let analytic = marginal_slope_baseline_offset_theta_partials(age, &cfg)
4220            .expect("partials")
4221            .expect("nonlinear");
4222        let fd = fd_marginal_slope_baseline_offset(age, &cfg, &[1e-5, 1e-11, 1e-5]);
4223        assert_eq!(analytic.len(), fd.len());
4224        for (k, ((aq, aqt), (fq, fqt))) in analytic.iter().zip(fd.iter()).enumerate() {
4225            assert_close(*aq, *fq, 1e-5, &format!("near-zero gm-probit q theta[{k}]"));
4226            assert_close(
4227                *aqt,
4228                *fqt,
4229                1e-5,
4230                &format!("near-zero gm-probit q' theta[{k}]"),
4231            );
4232        }
4233    }
4234
4235    fn shifted_quadratic_offset_residuals(
4236        age_entry: ndarray::ArrayView1<'_, f64>,
4237        age_exit: ndarray::ArrayView1<'_, f64>,
4238        base_cfg: &SurvivalBaselineConfig,
4239        candidate_cfg: &SurvivalBaselineConfig,
4240        base: &OffsetChannelResiduals,
4241        curvatures: &OffsetChannelCurvatures,
4242    ) -> OffsetChannelResiduals {
4243        let n = age_exit.len();
4244        let mut entry = base.entry.clone();
4245        let mut exit = base.exit.clone();
4246        let mut derivative = base.derivative.clone();
4247        for row in 0..n {
4248            let (_, base_exit, base_deriv) =
4249                baseline_marginal_slope_channels(age_exit[row], base_cfg);
4250            let (_, cand_exit, cand_deriv) =
4251                baseline_marginal_slope_channels(age_exit[row], candidate_cfg);
4252            let base_entry = if base.entry[row] == 0.0 {
4253                0.0
4254            } else {
4255                baseline_marginal_slope_channels(age_entry[row], base_cfg).1
4256            };
4257            let cand_entry = if base.entry[row] == 0.0 {
4258                0.0
4259            } else {
4260                baseline_marginal_slope_channels(age_entry[row], candidate_cfg).1
4261            };
4262            let delta = [
4263                cand_entry - base_entry,
4264                cand_exit - base_exit,
4265                cand_deriv - base_deriv,
4266            ];
4267            let mut shift = [0.0; 3];
4268            for i in 0..3 {
4269                for j in 0..3 {
4270                    shift[i] += curvatures.rows[row][i][j] * delta[j];
4271                }
4272            }
4273            if base.entry[row] != 0.0 {
4274                entry[row] += shift[0];
4275            }
4276            exit[row] += shift[1];
4277            derivative[row] += shift[2];
4278        }
4279        OffsetChannelResiduals {
4280            entry,
4281            exit,
4282            derivative,
4283            right: base.right.clone(),
4284        }
4285    }
4286
4287    fn baseline_marginal_slope_channels(age: f64, cfg: &SurvivalBaselineConfig) -> (f64, f64, f64) {
4288        let (q, q_t) = evaluate_survival_marginal_slope_baseline(age, cfg).expect("baseline");
4289        (q, q, q_t)
4290    }
4291
4292    #[test]
4293    fn marginal_slope_baseline_chain_rule_hessian_matches_fd_gradient() {
4294        let cfg = SurvivalBaselineConfig {
4295            target: SurvivalBaselineTarget::GompertzMakeham,
4296            scale: None,
4297            shape: Some(0.025),
4298            rate: Some(0.012),
4299            makeham: Some(0.003),
4300        };
4301        let theta = survival_baseline_theta_from_config(&cfg)
4302            .expect("theta")
4303            .expect("nonlinear");
4304        let age_entry = array![2.5, 0.0, 5.0];
4305        let age_exit = array![7.5, 11.0, 15.0];
4306        let base_residuals = OffsetChannelResiduals {
4307            entry: array![0.2, 0.0, -0.1],
4308            exit: array![0.6, -0.3, 0.4],
4309            derivative: array![-0.5, 0.25, 0.15],
4310            right: Array1::<f64>::zeros(3),
4311        };
4312        let curvatures = OffsetChannelCurvatures {
4313            rows: vec![
4314                [[1.4, 0.2, -0.1], [0.2, 1.1, 0.05], [-0.1, 0.05, 0.7]],
4315                [[0.9, -0.15, 0.0], [-0.15, 1.3, 0.12], [0.0, 0.12, 0.8]],
4316                [[1.2, 0.05, 0.09], [0.05, 0.95, -0.04], [0.09, -0.04, 0.6]],
4317            ],
4318        };
4319        let analytic = marginal_slope_baseline_chain_rule_hessian(
4320            age_entry.view(),
4321            age_exit.view(),
4322            &cfg,
4323            &base_residuals,
4324            &curvatures,
4325        )
4326        .expect("hessian")
4327        .expect("nonlinear");
4328
4329        let gradient_at = |theta_candidate: &Array1<f64>| -> Array1<f64> {
4330            let candidate = survival_baseline_config_from_theta(cfg.target, theta_candidate)
4331                .expect("candidate cfg");
4332            let residuals = shifted_quadratic_offset_residuals(
4333                age_entry.view(),
4334                age_exit.view(),
4335                &cfg,
4336                &candidate,
4337                &base_residuals,
4338                &curvatures,
4339            );
4340            marginal_slope_baseline_chain_rule_gradient(
4341                age_entry.view(),
4342                age_exit.view(),
4343                &candidate,
4344                &residuals,
4345            )
4346            .expect("gradient")
4347            .expect("nonlinear")
4348        };
4349
4350        for j in 0..theta.len() {
4351            let step = if j == 1 { 2e-5 } else { 1e-5 };
4352            let mut plus = theta.clone();
4353            plus[j] += step;
4354            let mut minus = theta.clone();
4355            minus[j] -= step;
4356            let fd_col = (&gradient_at(&plus) - &gradient_at(&minus)) / (2.0 * step);
4357            for i in 0..theta.len() {
4358                assert_close(
4359                    analytic[[i, j]],
4360                    fd_col[i],
4361                    2e-5,
4362                    &format!("baseline Hessian ({i},{j})"),
4363                );
4364            }
4365        }
4366    }
4367
4368    #[test]
4369    fn marginal_slope_baseline_chain_rule_gradient_contracts_probit_partials() {
4370        let cfg = SurvivalBaselineConfig {
4371            target: SurvivalBaselineTarget::GompertzMakeham,
4372            scale: None,
4373            shape: Some(0.03),
4374            rate: Some(0.01),
4375            makeham: Some(0.002),
4376        };
4377        let age_entry = array![3.0, 6.0];
4378        let age_exit = array![8.0, 12.0];
4379        let residuals = OffsetChannelResiduals {
4380            exit: array![0.7, -0.2],
4381            entry: array![0.1, 0.4],
4382            derivative: array![1.3, -0.6],
4383            right: Array1::<f64>::zeros(2),
4384        };
4385        let grad = marginal_slope_baseline_chain_rule_gradient(
4386            age_entry.view(),
4387            age_exit.view(),
4388            &cfg,
4389            &residuals,
4390        )
4391        .expect("gradient")
4392        .expect("nonlinear");
4393
4394        let mut expected = Array1::<f64>::zeros(3);
4395        for i in 0..age_exit.len() {
4396            let exit_partials = marginal_slope_baseline_offset_theta_partials(age_exit[i], &cfg)
4397                .expect("exit partials")
4398                .expect("nonlinear");
4399            let entry_partials = marginal_slope_baseline_offset_theta_partials(age_entry[i], &cfg)
4400                .expect("entry partials")
4401                .expect("nonlinear");
4402            for k in 0..3 {
4403                expected[k] += residuals.exit[i] * exit_partials[k].0
4404                    + residuals.derivative[i] * exit_partials[k].1
4405                    + residuals.entry[i] * entry_partials[k].0;
4406            }
4407        }
4408        for k in 0..3 {
4409            assert_close(
4410                grad[k],
4411                expected[k],
4412                1e-12,
4413                &format!("gm-probit chain gradient theta[{k}]"),
4414            );
4415        }
4416    }
4417
4418    /// Parity guard for the shared `baseline_chain_rule_gradient_with_partials`
4419    /// engine (issue #429): both public gradient functions delegate to it with a
4420    /// different partials provider. This test reimplements the pre-unification
4421    /// inline contraction (the serial reference) and asserts bit-for-bit equality
4422    /// against the unified engine's output for BOTH providers on the same data —
4423    /// the RP-eta provider (`baseline_offset_theta_partials`) and the probit-q
4424    /// provider (`marginal_slope_baseline_offset_theta_partials`). Any drift in
4425    /// the extracted contraction (length checks, theta-dim probe, exit/derivative
4426    /// combination, or entry gating) breaks this with an exact (0.0) tolerance.
4427    #[test]
4428    fn baseline_chain_rule_gradient_engine_matches_inline_reference() {
4429        let cfg = SurvivalBaselineConfig {
4430            target: SurvivalBaselineTarget::GompertzMakeham,
4431            scale: None,
4432            shape: Some(0.028),
4433            rate: Some(0.011),
4434            makeham: Some(0.0025),
4435        };
4436        // Mixed entry interval: row 1 is origin-entry (age_entry==0, r_entry==0)
4437        // to exercise the entry-gating branch in the shared engine.
4438        let age_entry = array![3.0, 0.0, 5.5];
4439        let age_exit = array![8.0, 12.0, 16.0];
4440        let residuals = OffsetChannelResiduals {
4441            exit: array![0.7, -0.2, 0.45],
4442            entry: array![0.1, 0.0, -0.3],
4443            derivative: array![1.3, -0.6, 0.2],
4444            right: Array1::<f64>::zeros(3),
4445        };
4446
4447        // Serial reference contraction matching the original inline body. Mirrors
4448        // the engine's exit+derivative/entry split and origin-entry gating.
4449        let reference_gradient = |partials: &dyn Fn(
4450            f64,
4451            &SurvivalBaselineConfig,
4452        )
4453            -> Result<Option<Vec<(f64, f64)>>, String>|
4454         -> Array1<f64> {
4455            let theta_dim = partials(age_exit[0], &cfg)
4456                .expect("probe partials")
4457                .expect("nonlinear")
4458                .len();
4459            let mut acc = Array1::<f64>::zeros(theta_dim);
4460            for i in 0..age_exit.len() {
4461                let p_exit = partials(age_exit[i], &cfg)
4462                    .expect("exit partials")
4463                    .expect("nonlinear");
4464                let r_x = residuals.exit[i];
4465                let r_d = residuals.derivative[i];
4466                for k in 0..theta_dim {
4467                    acc[k] += r_x * p_exit[k].0 + r_d * p_exit[k].1;
4468                }
4469                let r_e = residuals.entry[i];
4470                if r_e != 0.0 {
4471                    let p_entry = partials(age_entry[i], &cfg)
4472                        .expect("entry partials")
4473                        .expect("nonlinear");
4474                    for k in 0..theta_dim {
4475                        acc[k] += r_e * p_entry[k].0;
4476                    }
4477                }
4478            }
4479            acc
4480        };
4481
4482        // RP-eta provider parity.
4483        let rp_engine = baseline_chain_rule_gradient(
4484            age_entry.view(),
4485            age_exit.view(),
4486            age_exit.view(),
4487            &cfg,
4488            &residuals,
4489        )
4490        .expect("rp gradient")
4491        .expect("rp nonlinear");
4492        let rp_reference = reference_gradient(&baseline_offset_theta_partials);
4493        assert_eq!(rp_engine.len(), rp_reference.len());
4494        for k in 0..rp_engine.len() {
4495            assert_close(
4496                rp_engine[k],
4497                rp_reference[k],
4498                0.0,
4499                &format!("rp engine vs inline reference theta[{k}]"),
4500            );
4501        }
4502
4503        // Probit-q provider parity.
4504        let probit_engine = marginal_slope_baseline_chain_rule_gradient(
4505            age_entry.view(),
4506            age_exit.view(),
4507            &cfg,
4508            &residuals,
4509        )
4510        .expect("probit gradient")
4511        .expect("probit nonlinear");
4512        let probit_reference = reference_gradient(&marginal_slope_baseline_offset_theta_partials);
4513        assert_eq!(probit_engine.len(), probit_reference.len());
4514        for k in 0..probit_engine.len() {
4515            assert_close(
4516                probit_engine[k],
4517                probit_reference[k],
4518                0.0,
4519                &format!("probit engine vs inline reference theta[{k}]"),
4520            );
4521        }
4522    }
4523
4524    /// Finite-difference verification of the analytic θ-gradient used by the
4525    /// survival location-scale workflow path.
4526    ///
4527    /// At a converged β, the envelope theorem reduces the profile-NLL gradient
4528    /// w.r.t. the baseline-config θ to a per-row residual contraction against
4529    /// the per-row offset-channel partials ∂o/∂θ:
4530    ///
4531    ///   d(NLL)/dθ_k = Σ_i [ r_X[i]·∂η_exit/∂θ_k + r_E[i]·∂η_entry/∂θ_k
4532    ///                       + r_D[i]·∂o_D_exit/∂θ_k ]
4533    ///
4534    /// (`baseline_chain_rule_gradient`). Because β is fixed, an explicit loss
4535    /// `L(θ) = Σ_i [ r_X[i]·η(t_exit_i; θ) + r_E[i]·η(t_entry_i; θ)
4536    ///              + r_D[i]·o_D(t_exit_i; θ) ]`
4537    /// has gradient identically equal to the chain-rule output. Comparing the
4538    /// analytic gradient to a central-difference of L over `evaluate_survival_baseline`
4539    /// therefore exercises every piece of the chain rule (incl. the Gompertz
4540    /// rate / shape / Makeham partials at both entry and exit ages) without
4541    /// needing the full location-scale fit pipeline inside this unit-test
4542    /// module. If the chain rule disagrees with FD here, the workflow's
4543    /// gradient is wrong by exactly the same amount.
4544    #[test]
4545    fn gompertz_makeham_baseline_chain_rule_gradient_matches_finite_difference() {
4546        let cfg = SurvivalBaselineConfig {
4547            target: SurvivalBaselineTarget::GompertzMakeham,
4548            scale: None,
4549            shape: Some(0.05),
4550            rate: Some(0.012),
4551            makeham: Some(0.003),
4552        };
4553        // n = 8 small synthetic dataset spanning a realistic age range.
4554        let age_entry = array![5.0, 8.0, 12.0, 0.5, 20.0, 30.0, 45.0, 60.0];
4555        let age_exit = array![10.0, 15.0, 25.0, 4.0, 35.0, 50.0, 65.0, 80.0];
4556        // Synthetic per-row NLL residuals on the three offset channels. Mix of
4557        // signs / magnitudes / one zero-entry row (origin entry → r_E=0).
4558        let residuals = OffsetChannelResiduals {
4559            exit: array![0.42, -0.18, 0.73, -0.91, 0.05, -0.27, 0.61, -0.34],
4560            entry: array![-0.12, 0.31, -0.44, 0.0, 0.16, -0.22, 0.07, -0.51],
4561            derivative: array![1.04, -0.65, 0.18, -1.21, 0.42, -0.13, 0.88, -0.27],
4562            right: Array1::<f64>::zeros(8),
4563        };
4564
4565        let analytic = baseline_chain_rule_gradient(
4566            age_entry.view(),
4567            age_exit.view(),
4568            age_exit.view(),
4569            &cfg,
4570            &residuals,
4571        )
4572        .expect("analytic gradient ok")
4573        .expect("GM baseline has a θ-gradient");
4574        assert_eq!(analytic.len(), 3, "GM θ has 3 components");
4575
4576        // Evaluate the offset-projected loss at a perturbed θ. Mirrors the
4577        // chain rule's algebra: the entry channel is only added for rows whose
4578        // r_E is nonzero (matching baseline_chain_rule_gradient's gating that
4579        // avoids calling evaluate_survival_baseline at age 0 for origin-entry
4580        // rows).
4581        let loss_at_cfg = |cfg_eval: &SurvivalBaselineConfig| -> f64 {
4582            let mut acc = 0.0;
4583            for i in 0..age_exit.len() {
4584                let (eta_exit_i, od_exit_i) =
4585                    evaluate_survival_baseline(age_exit[i], cfg_eval).expect("eval exit");
4586                acc += residuals.exit[i] * eta_exit_i + residuals.derivative[i] * od_exit_i;
4587                if residuals.entry[i] != 0.0 {
4588                    let (eta_entry_i, _) =
4589                        evaluate_survival_baseline(age_entry[i], cfg_eval).expect("eval entry");
4590                    acc += residuals.entry[i] * eta_entry_i;
4591                }
4592            }
4593            acc
4594        };
4595
4596        let theta0 = survival_baseline_theta_from_config(&cfg)
4597            .expect("theta seed")
4598            .expect("GM has θ");
4599        // Spec requested δ = 1e-4 per axis. Use central differences over θ.
4600        let delta = 1e-4;
4601        let mut fd = Array1::<f64>::zeros(analytic.len());
4602        for k in 0..analytic.len() {
4603            let mut theta_plus = theta0.clone();
4604            theta_plus[k] += delta;
4605            let mut theta_minus = theta0.clone();
4606            theta_minus[k] -= delta;
4607            let cfg_plus =
4608                survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("cfg(θ+δ)");
4609            let cfg_minus =
4610                survival_baseline_config_from_theta(cfg.target, &theta_minus).expect("cfg(θ-δ)");
4611            let lp = loss_at_cfg(&cfg_plus);
4612            let lm = loss_at_cfg(&cfg_minus);
4613            fd[k] = (lp - lm) / (2.0 * delta);
4614        }
4615
4616        let analytic_norm = analytic.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4617        let max_err = analytic
4618            .iter()
4619            .zip(fd.iter())
4620            .map(|(a, b)| (a - b).abs())
4621            .fold(0.0_f64, f64::max);
4622        let rel = max_err / (analytic_norm + 1e-12);
4623        // Print so the deliverable can quote the exact max-error number.
4624        eprintln!(
4625            "gompertz_makeham_baseline_chain_rule_gradient_matches_finite_difference: \
4626             analytic={analytic:?} fd={fd:?} max_err={max_err:.3e} \
4627             analytic_inf_norm={analytic_norm:.3e} rel={rel:.3e}"
4628        );
4629        assert!(
4630            rel < 1e-2,
4631            "analytic θ-gradient disagrees with central FD beyond 1%: \
4632             analytic={analytic:?}, fd={fd:?}, max_err={max_err:.3e}, \
4633             rel={rel:.3e} (analytic_inf_norm={analytic_norm:.3e})"
4634        );
4635    }
4636
4637    /// Weibull (dim=2) companion to
4638    /// `gompertz_makeham_baseline_chain_rule_gradient_matches_finite_difference`.
4639    ///
4640    /// This is the FD gate for the analytic outer θ-gradient that the
4641    /// transformation/Weibull survival baseline optimizers now feed to BFGS
4642    /// (`optimize_survival_baseline_config_with_gradient_only`). At a *fixed* β
4643    /// the profile-NLL surface is
4644    /// `L(θ) = Σ_i [ r_X[i]·η(t_exit_i;θ) + r_E[i]·η(t_entry_i;θ)
4645    ///              + r_D[i]·o_D(t_exit_i;θ) ]`,
4646    /// whose exact gradient is `baseline_chain_rule_gradient`. Comparing it to a
4647    /// central difference of `L` over `evaluate_survival_baseline` exercises the
4648    /// Weibull scale/shape partials at both entry and exit ages. If this
4649    /// disagrees with FD, the workflow's outer gradient is wrong by the same
4650    /// amount.
4651    #[test]
4652    fn weibull_baseline_chain_rule_gradient_matches_finite_difference() {
4653        let cfg = SurvivalBaselineConfig {
4654            target: SurvivalBaselineTarget::Weibull,
4655            scale: Some(11.0),
4656            shape: Some(1.4),
4657            rate: None,
4658            makeham: None,
4659        };
4660        let age_entry = array![5.0, 8.0, 12.0, 0.5, 20.0, 30.0, 45.0, 60.0];
4661        let age_exit = array![10.0, 15.0, 25.0, 4.0, 35.0, 50.0, 65.0, 80.0];
4662        let residuals = OffsetChannelResiduals {
4663            exit: array![0.42, -0.18, 0.73, -0.91, 0.05, -0.27, 0.61, -0.34],
4664            entry: array![-0.12, 0.31, -0.44, 0.0, 0.16, -0.22, 0.07, -0.51],
4665            derivative: array![1.04, -0.65, 0.18, -1.21, 0.42, -0.13, 0.88, -0.27],
4666            right: Array1::<f64>::zeros(8),
4667        };
4668
4669        let analytic = baseline_chain_rule_gradient(
4670            age_entry.view(),
4671            age_exit.view(),
4672            age_exit.view(),
4673            &cfg,
4674            &residuals,
4675        )
4676        .expect("analytic gradient ok")
4677        .expect("Weibull baseline has a θ-gradient");
4678        assert_eq!(analytic.len(), 2, "Weibull θ has 2 components");
4679
4680        let loss_at_cfg = |cfg_eval: &SurvivalBaselineConfig| -> f64 {
4681            let mut acc = 0.0;
4682            for i in 0..age_exit.len() {
4683                let (eta_exit_i, od_exit_i) =
4684                    evaluate_survival_baseline(age_exit[i], cfg_eval).expect("eval exit");
4685                acc += residuals.exit[i] * eta_exit_i + residuals.derivative[i] * od_exit_i;
4686                if residuals.entry[i] != 0.0 {
4687                    let (eta_entry_i, _) =
4688                        evaluate_survival_baseline(age_entry[i], cfg_eval).expect("eval entry");
4689                    acc += residuals.entry[i] * eta_entry_i;
4690                }
4691            }
4692            acc
4693        };
4694
4695        let theta0 = survival_baseline_theta_from_config(&cfg)
4696            .expect("theta seed")
4697            .expect("Weibull has θ");
4698        let delta = 1e-4;
4699        let mut fd = Array1::<f64>::zeros(analytic.len());
4700        for k in 0..analytic.len() {
4701            let mut theta_plus = theta0.clone();
4702            theta_plus[k] += delta;
4703            let mut theta_minus = theta0.clone();
4704            theta_minus[k] -= delta;
4705            let cfg_plus =
4706                survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("cfg(θ+δ)");
4707            let cfg_minus =
4708                survival_baseline_config_from_theta(cfg.target, &theta_minus).expect("cfg(θ-δ)");
4709            let lp = loss_at_cfg(&cfg_plus);
4710            let lm = loss_at_cfg(&cfg_minus);
4711            fd[k] = (lp - lm) / (2.0 * delta);
4712        }
4713
4714        let analytic_norm = analytic.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4715        let max_err = analytic
4716            .iter()
4717            .zip(fd.iter())
4718            .map(|(a, b)| (a - b).abs())
4719            .fold(0.0_f64, f64::max);
4720        let rel = max_err / (analytic_norm + 1e-12);
4721        eprintln!(
4722            "weibull_baseline_chain_rule_gradient_matches_finite_difference: \
4723             analytic={analytic:?} fd={fd:?} max_err={max_err:.3e} \
4724             analytic_inf_norm={analytic_norm:.3e} rel={rel:.3e}"
4725        );
4726        assert!(
4727            rel < 1e-2,
4728            "analytic θ-gradient disagrees with central FD beyond 1%: \
4729             analytic={analytic:?}, fd={fd:?}, max_err={max_err:.3e}, \
4730             rel={rel:.3e} (analytic_inf_norm={analytic_norm:.3e})"
4731        );
4732    }
4733
4734    // ─── baseline_offset_theta_partials — analytic vs central-difference ─
4735
4736    /// Central-difference of (eta, o_D) at fixed age wrt each θ component in
4737    /// the theta layout defined by `survival_baseline_theta_from_config`.
4738    ///
4739    /// `steps` is per-θ-component: the caller picks the step size appropriate
4740    /// for each channel. Gompertz / Gompertz–Makeham need a tiny step on the
4741    /// shape channel near the Taylor pivot |shape| < 1e-10 (so θ±h stays on
4742    /// the same branch), but a normal-scale step on log_rate / log_makeham;
4743    /// using the tiny shape-step on every channel corrupts the log_rate
4744    /// channel with `eps/(2h)` cancellation noise and has nothing to do with
4745    /// correctness of the analytic derivative.
4746    fn fd_baseline_offset(
4747        age: f64,
4748        cfg: &SurvivalBaselineConfig,
4749        steps: &[f64],
4750    ) -> Vec<(f64, f64)> {
4751        let theta = survival_baseline_theta_from_config(cfg)
4752            .expect("theta")
4753            .expect("non-linear baseline");
4754        assert_eq!(
4755            steps.len(),
4756            theta.len(),
4757            "fd_baseline_offset: step vector length must match θ dimension"
4758        );
4759        (0..theta.len())
4760            .map(|k| {
4761                let h = steps[k];
4762                let mut theta_plus = theta.clone();
4763                theta_plus[k] += h;
4764                let mut theta_minus = theta.clone();
4765                theta_minus[k] -= h;
4766                let cfg_plus =
4767                    survival_baseline_config_from_theta(cfg.target, &theta_plus).expect("plus cfg");
4768                let cfg_minus = survival_baseline_config_from_theta(cfg.target, &theta_minus)
4769                    .expect("minus cfg");
4770                let (eta_p, od_p) = evaluate_survival_baseline(age, &cfg_plus).expect("eta+");
4771                let (eta_m, od_m) = evaluate_survival_baseline(age, &cfg_minus).expect("eta-");
4772                ((eta_p - eta_m) / (2.0 * h), (od_p - od_m) / (2.0 * h))
4773            })
4774            .collect()
4775    }
4776
4777    fn assert_close(actual: f64, expected: f64, tol: f64, what: &str) {
4778        // `<=` so that bit-equal values satisfy tol = 0. With `<`, |a−e| < 0
4779        // is unsatisfiable and a zero-tolerance "must match exactly" call
4780        // would reject identical numbers.
4781        let ok = if expected.abs() < 1.0 {
4782            (actual - expected).abs() <= tol
4783        } else {
4784            (actual - expected).abs() <= tol * expected.abs().max(1.0)
4785        };
4786        assert!(
4787            ok,
4788            "{what}: analytic={actual:.6e} fd={expected:.6e} (tol={tol:.1e})"
4789        );
4790    }
4791
4792    #[test]
4793    fn gompertz_offset_partials_match_central_diff() {
4794        // Several (rate, shape, age) combinations spanning the small-shape
4795        // Taylor branch (|shape| < 1e-10) and the normal branch
4796        // (shape >> 1e-10), plus sign-reversed shape.
4797        let cases = [
4798            (0.5_f64, 0.01_f64, 30.0_f64),
4799            (0.2, 0.05, 60.0),
4800            (1.0, 0.001, 10.0),
4801            (0.4, 5e-11, 25.0),
4802            (0.4, -5e-11, 25.0),
4803            (0.3, -0.02, 40.0),
4804            (0.8, 0.2, 5.0),
4805        ];
4806        for &(rate, shape, age) in &cases {
4807            let cfg = SurvivalBaselineConfig {
4808                target: SurvivalBaselineTarget::Gompertz,
4809                scale: None,
4810                shape: Some(shape),
4811                rate: Some(rate),
4812                makeham: None,
4813            };
4814            let analytic = baseline_offset_theta_partials(age, &cfg)
4815                .expect("ok")
4816                .expect("non-linear");
4817            // Keep the FD probe inside the Taylor branch for tiny |shape| so
4818            // the numeric derivative matches the same small-shape map as the
4819            // analytic helper. log_rate always uses the normal step — rate
4820            // is a moderate-scale parameter and a 1e-11 step would swamp the
4821            // FD with cancellation noise.
4822            let h_shape = if shape.abs() < 1e-9 { 1e-11 } else { 1e-5 };
4823            let fd = fd_baseline_offset(age, &cfg, &[1e-5, h_shape]);
4824            assert_eq!(analytic.len(), 2);
4825            // Gompertz θ=(log_rate, shape). Rate channel: ∂eta/∂log_rate=1, ∂o_D/∂log_rate=0.
4826            assert_close(
4827                analytic[0].0,
4828                fd[0].0,
4829                1e-7,
4830                &format!("gompertz ∂eta/∂log_rate (rate={rate}, shape={shape}, age={age})"),
4831            );
4832            assert_close(
4833                analytic[0].1,
4834                fd[0].1,
4835                1e-7,
4836                &format!("gompertz ∂o_D/∂log_rate (rate={rate}, shape={shape}, age={age})"),
4837            );
4838            // shape channel — larger tol because finite-differencing near
4839            // shape=0 amplifies rounding; 1e-5 is fine.
4840            assert_close(
4841                analytic[1].0,
4842                fd[1].0,
4843                1e-5,
4844                &format!("gompertz ∂eta/∂shape (rate={rate}, shape={shape}, age={age})"),
4845            );
4846            assert_close(
4847                analytic[1].1,
4848                fd[1].1,
4849                1e-5,
4850                &format!("gompertz ∂o_D/∂shape (rate={rate}, shape={shape}, age={age})"),
4851            );
4852        }
4853    }
4854
4855    #[test]
4856    fn gompertz_offset_partials_log_rate_channel_is_trivial() {
4857        // Pure Gompertz: rate cancels in o_D, so ∂o_D/∂log_rate must be
4858        // exactly 0 and ∂eta/∂log_rate must be exactly 1. Verify the
4859        // analytic implementation returns the exact values, not FD-close.
4860        let cfg = SurvivalBaselineConfig {
4861            target: SurvivalBaselineTarget::Gompertz,
4862            scale: None,
4863            shape: Some(0.05),
4864            rate: Some(0.3),
4865            makeham: None,
4866        };
4867        let partials = baseline_offset_theta_partials(42.0, &cfg)
4868            .expect("ok")
4869            .expect("non-linear");
4870        assert_eq!(partials[0].0, 1.0);
4871        assert_eq!(partials[0].1, 0.0);
4872    }
4873
4874    #[test]
4875    fn gompertz_offset_partials_small_shape_taylor_agrees_with_direct_branch() {
4876        // Both branches of gompertz_shape_derivatives should agree to high
4877        // precision at shape = 1e-10 + epsilon on the direct side vs
4878        // shape = 1e-10 - epsilon on the Taylor side. Here we spot-check
4879        // the continuity at the branch cutoff: shape slightly above and
4880        // slightly below 1e-10 must give values within O(shape²·t²)
4881        // (the Taylor truncation error).
4882        let age = 25.0;
4883        let rate = 0.4;
4884        let cfg_taylor = SurvivalBaselineConfig {
4885            target: SurvivalBaselineTarget::Gompertz,
4886            scale: None,
4887            shape: Some(0.5e-10),
4888            rate: Some(rate),
4889            makeham: None,
4890        };
4891        let cfg_direct = SurvivalBaselineConfig {
4892            target: SurvivalBaselineTarget::Gompertz,
4893            scale: None,
4894            shape: Some(2.0e-10),
4895            rate: Some(rate),
4896            makeham: None,
4897        };
4898        let p_t = baseline_offset_theta_partials(age, &cfg_taylor)
4899            .expect("ok")
4900            .expect("nl");
4901        let p_d = baseline_offset_theta_partials(age, &cfg_direct)
4902            .expect("ok")
4903            .expect("nl");
4904        // ∂eta/∂shape at shape≈0 should be t/2 = 12.5 on both sides.
4905        assert_close(p_t[1].0, 12.5, 1e-8, "taylor ∂eta/∂shape near 0");
4906        assert_close(p_d[1].0, 12.5, 1e-8, "direct ∂eta/∂shape near 0");
4907        // ∂o_D/∂shape at shape≈0 should be 1/2.
4908        assert_close(p_t[1].1, 0.5, 1e-8, "taylor ∂o_D/∂shape near 0");
4909        assert_close(p_d[1].1, 0.5, 1e-8, "direct ∂o_D/∂shape near 0");
4910    }
4911
4912    // ----------------------------------------------------------------------
4913    // Gompertz hazard-channel shape derivatives: FD oracle + Taylor-branch
4914    // continuity. These feed `survival_hazard_theta_partials` /
4915    // `survival_hazard_theta_first_second` (the marginal-slope probit
4916    // baseline). Before this test, the only coverage of
4917    // `gompertz_cumulative_shape_{,second_}derivative` was the indirect
4918    // marginal-slope Hessian FD at shape=0.025, which never touches the
4919    // small-shape (`|shape| < 1e-10`) Taylor branch nor directly FD-checks
4920    // these analytic shape derivatives.
4921    // ----------------------------------------------------------------------
4922
4923    #[test]
4924    fn gompertz_hazard_shape_derivatives_match_central_diff() {
4925        // shape stays well above the 1e-10 Taylor cutoff so the exact
4926        // closed-form branch is exercised and the expm1/exp arithmetic is
4927        // numerically clean. FD on the analytic value/first-derivative
4928        // confirms the first and second shape derivatives.
4929        let cases = [
4930            (10.0_f64, 0.012_f64, 0.05_f64),
4931            (2.5, 0.5, 0.2),
4932            (15.0, 0.003, 0.01),
4933            (40.0, 0.3, 0.001),
4934        ];
4935        let h = 1e-6;
4936        for &(age, rate, shape) in &cases {
4937            // First shape derivative of (H_G, h_G) vs central diff of value.
4938            let (d_cum, d_inst) = gompertz_cumulative_shape_derivative(age, rate, shape);
4939            let (cum_p, inst_p) = gompertz_hazard_components(age, rate, shape + h);
4940            let (cum_m, inst_m) = gompertz_hazard_components(age, rate, shape - h);
4941            assert_close(
4942                d_cum,
4943                (cum_p - cum_m) / (2.0 * h),
4944                1e-6,
4945                &format!("∂H_G/∂shape (age={age}, rate={rate}, shape={shape})"),
4946            );
4947            assert_close(
4948                d_inst,
4949                (inst_p - inst_m) / (2.0 * h),
4950                1e-6,
4951                &format!("∂h_G/∂shape (age={age}, rate={rate}, shape={shape})"),
4952            );
4953
4954            // Second shape derivative vs central diff of the first derivative.
4955            let (d2_cum, d2_inst) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
4956            let (dcum_p, dinst_p) = gompertz_cumulative_shape_derivative(age, rate, shape + h);
4957            let (dcum_m, dinst_m) = gompertz_cumulative_shape_derivative(age, rate, shape - h);
4958            assert_close(
4959                d2_cum,
4960                (dcum_p - dcum_m) / (2.0 * h),
4961                1e-5,
4962                &format!("∂²H_G/∂shape² (age={age}, rate={rate}, shape={shape})"),
4963            );
4964            assert_close(
4965                d2_inst,
4966                (dinst_p - dinst_m) / (2.0 * h),
4967                1e-5,
4968                &format!("∂²h_G/∂shape² (age={age}, rate={rate}, shape={shape})"),
4969            );
4970        }
4971    }
4972
4973    #[test]
4974    fn gompertz_hazard_shape_derivatives_small_shape_match_analytic_limit() {
4975        // At small x = shape·age the shape derivatives collapse to closed-form
4976        // limits. These MUST hold even for large ages with tiny shapes, which
4977        // is precisely the regime where the (cancelling) exact branch loses all
4978        // precision and the x-based pivot routes to the Taylor branch.
4979        //   ∂H_G/∂shape   -> rate·t²/2
4980        //   ∂h_G/∂shape   -> rate·t
4981        //   ∂²H_G/∂shape² -> rate·t³/3
4982        //   ∂²h_G/∂shape² -> rate·t²
4983        // The bug this guards: the second derivative's old `shape < 1e-10`
4984        // pivot ignored `age`, so e.g. (age=100, shape=1e-5 -> x=1e-3) took the
4985        // cancelling exact branch and returned a wildly wrong curvature.
4986        let cases = [
4987            (25.0_f64, 0.4_f64, 1e-9_f64),
4988            (100.0, 0.4, 1e-6),   // x = 1e-4
4989            (100.0, 0.012, 1e-6), // x = 1e-4, the old-pivot band (large age, tiny shape)
4990            (50.0, 1.2, 1e-8),
4991        ];
4992        // NOTE: every quantity below is compared against its shape->0 *limit*.
4993        // For the cancelling cumulative branches (∂H/∂shape, ∂²H/∂shape²,
4994        // ∂²h/∂shape²) the limit is the correct shape->0 target and the
4995        // implementation routes through Taylor in this band. But the
4996        // instantaneous first derivative ∂h_G/∂shape = rate·age·e^x carries NO
4997        // cancellation: it is exact, and its departure from the limit rate·t is
4998        // a genuine O(x) effect. At x=1e-3 that departure is ~1.2e-3 (> tol),
4999        // so the cases here keep x <= 1e-4 where the limit is a valid 1e-3
5000        // oracle for *all four* quantities. The cancelling-branch regression at
5001        // larger x is covered by gompertz_second_shape_derivative_is_accurate_in_old_pivot_gap.
5002        for &(age, rate, shape) in &cases {
5003            let t = age;
5004            let (d_cum, d_inst) = gompertz_cumulative_shape_derivative(age, rate, shape);
5005            assert_close(
5006                d_cum,
5007                rate * t * t / 2.0,
5008                1e-3,
5009                &format!("∂H_G/∂shape limit (age={age}, shape={shape})"),
5010            );
5011            assert_close(
5012                d_inst,
5013                rate * t,
5014                1e-3,
5015                &format!("∂h_G/∂shape limit (age={age}, shape={shape})"),
5016            );
5017
5018            let (d2_cum, d2_inst) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
5019            assert_close(
5020                d2_cum,
5021                rate * t * t * t / 3.0,
5022                1e-3,
5023                &format!("∂²H_G/∂shape² limit (age={age}, shape={shape})"),
5024            );
5025            assert_close(
5026                d2_inst,
5027                rate * t * t,
5028                1e-3,
5029                &format!("∂²h_G/∂shape² limit (age={age}, shape={shape})"),
5030            );
5031        }
5032    }
5033
5034    #[test]
5035    fn gompertz_second_shape_derivative_is_accurate_in_old_pivot_gap() {
5036        // Regression: in the band shape ∈ [1e-10, ~1e-4] with a realistic age,
5037        // the OLD `shape < 1e-10` pivot sent ∂²H_G/∂shape² through the
5038        // catastrophically-cancelling exact branch. With age=100, shape=1e-9
5039        // (x=1e-7) the exact branch returned ~+5e1 vs the true ~rate·t³/3.
5040        // Assert the implementation now matches the closed-form limit to high
5041        // precision throughout that band, across several decades of shape.
5042        let age = 100.0;
5043        let rate = 0.4;
5044        let t = age;
5045        let truth = rate * t * t * t / 3.0; // 1.333e5
5046        // Start at shape=1e-5 (x=1e-3): below this the second derivative is,
5047        // to better than 1e-3 relative, equal to its shape->0 limit, so the
5048        // limit is a valid oracle. (At x=1e-2 the true value legitimately
5049        // departs from the limit by ~7e-3, which is a real O(x) correction,
5050        // not an error — so we do not extend the band up to shape=1e-4.)
5051        for k in 5..=12 {
5052            let shape = 10f64.powi(-(k as i32)); // 1e-5 .. 1e-12
5053            let (d2_cum, _) = gompertz_cumulative_shape_second_derivative(age, rate, shape);
5054            assert_close(
5055                d2_cum,
5056                truth,
5057                1e-3,
5058                &format!("∂²H_G/∂shape² in old-pivot gap (age={age}, shape=1e-{k})"),
5059            );
5060        }
5061    }
5062
5063    #[test]
5064    fn weibull_offset_partials_match_central_diff() {
5065        let cases = [
5066            (0.5_f64, 1.2_f64, 25.0_f64),
5067            (2.0, 0.8, 60.0),
5068            (0.1, 3.0, 10.0),
5069        ];
5070        for &(scale, shape, age) in &cases {
5071            let cfg = SurvivalBaselineConfig {
5072                target: SurvivalBaselineTarget::Weibull,
5073                scale: Some(scale),
5074                shape: Some(shape),
5075                rate: None,
5076                makeham: None,
5077            };
5078            let analytic = baseline_offset_theta_partials(age, &cfg)
5079                .expect("ok")
5080                .expect("nl");
5081            let fd = fd_baseline_offset(age, &cfg, &[1e-5, 1e-5]);
5082            assert_eq!(analytic.len(), 2);
5083            for k in 0..2 {
5084                assert_close(
5085                    analytic[k].0,
5086                    fd[k].0,
5087                    1e-7,
5088                    &format!("weibull ∂eta/∂θ[{k}] (scale={scale}, shape={shape}, age={age})"),
5089                );
5090                assert_close(
5091                    analytic[k].1,
5092                    fd[k].1,
5093                    1e-7,
5094                    &format!("weibull ∂o_D/∂θ[{k}] (scale={scale}, shape={shape}, age={age})"),
5095                );
5096            }
5097            // Weibull o_D = shape/t is independent of scale; verify exactly.
5098            assert_eq!(analytic[0].1, 0.0);
5099        }
5100    }
5101
5102    #[test]
5103    fn gompertz_makeham_offset_partials_match_central_diff() {
5104        let cases = [
5105            (0.3_f64, 0.05_f64, 0.002_f64, 40.0_f64),
5106            (0.5, 0.01, 0.01, 25.0),
5107            (0.2, 0.001, 0.005, 60.0),
5108            (0.4, 5e-11, 0.01, 25.0),
5109            (0.4, -5e-11, 0.01, 25.0),
5110            (0.8, 0.2, 0.05, 5.0),
5111        ];
5112        for &(rate, shape, makeham, age) in &cases {
5113            let cfg = SurvivalBaselineConfig {
5114                target: SurvivalBaselineTarget::GompertzMakeham,
5115                scale: None,
5116                shape: Some(shape),
5117                rate: Some(rate),
5118                makeham: Some(makeham),
5119            };
5120            let analytic = baseline_offset_theta_partials(age, &cfg)
5121                .expect("ok")
5122                .expect("nl");
5123            // See gompertz_offset_partials_match_central_diff: tiny shape-step
5124            // is only needed for the shape component; log_rate and
5125            // log_makeham take the normal-scale step.
5126            let h_shape = if shape.abs() < 1e-9 { 1e-11 } else { 1e-5 };
5127            let fd = fd_baseline_offset(age, &cfg, &[1e-5, h_shape, 1e-5]);
5128            assert_eq!(analytic.len(), 3);
5129            for k in 0..3 {
5130                assert_close(
5131                    analytic[k].0,
5132                    fd[k].0,
5133                    1e-5,
5134                    &format!(
5135                        "gm ∂eta/∂θ[{k}] (rate={rate}, shape={shape}, mk={makeham}, age={age})"
5136                    ),
5137                );
5138                assert_close(
5139                    analytic[k].1,
5140                    fd[k].1,
5141                    1e-5,
5142                    &format!(
5143                        "gm ∂o_D/∂θ[{k}] (rate={rate}, shape={shape}, mk={makeham}, age={age})"
5144                    ),
5145                );
5146            }
5147        }
5148    }
5149
5150    #[test]
5151    fn linear_baseline_has_no_theta_partials() {
5152        let cfg = SurvivalBaselineConfig {
5153            target: SurvivalBaselineTarget::Linear,
5154            scale: None,
5155            shape: None,
5156            rate: None,
5157            makeham: None,
5158        };
5159        assert!(baseline_offset_theta_partials(5.0, &cfg).unwrap().is_none());
5160    }
5161
5162    #[test]
5163    fn baseline_offset_partials_reject_non_positive_ages() {
5164        let cfg = SurvivalBaselineConfig {
5165            target: SurvivalBaselineTarget::Gompertz,
5166            scale: None,
5167            shape: Some(0.01),
5168            rate: Some(0.5),
5169            makeham: None,
5170        };
5171        assert!(baseline_offset_theta_partials(0.0, &cfg).is_err());
5172        assert!(baseline_offset_theta_partials(-1.0, &cfg).is_err());
5173        assert!(baseline_offset_theta_partials(f64::NAN, &cfg).is_err());
5174    }
5175
5176    // ─── baseline_chain_rule_gradient — mechanical and FD-vs-θ tests ─────
5177
5178    /// Mechanical sanity check: with only one event observation at known
5179    /// (r_X, r_E, r_D, age_exit, age_entry), the Gompertz chain-rule gradient
5180    /// reduces to the analytic linear combination of `baseline_offset_theta_partials`.
5181    #[test]
5182    fn chain_rule_gradient_single_obs_reduces_to_pointwise_contract() {
5183        let cfg = SurvivalBaselineConfig {
5184            target: SurvivalBaselineTarget::Gompertz,
5185            scale: None,
5186            shape: Some(0.05),
5187            rate: Some(0.3),
5188            makeham: None,
5189        };
5190        let age_entry = array![10.0_f64];
5191        let age_exit = array![25.0_f64];
5192        let residuals = OffsetChannelResiduals {
5193            exit: array![0.7_f64],
5194            entry: array![-0.2_f64],
5195            derivative: array![-0.4_f64],
5196            right: Array1::<f64>::zeros(1),
5197        };
5198        let grad = baseline_chain_rule_gradient(
5199            age_entry.view(),
5200            age_exit.view(),
5201            age_exit.view(),
5202            &cfg,
5203            &residuals,
5204        )
5205        .expect("ok")
5206        .expect("non-linear");
5207        // Hand-compute: grad[k] = r_X·∂eta_exit/∂θ_k + r_D·∂o_D_exit/∂θ_k + r_E·∂eta_entry/∂θ_k.
5208        let p_exit = baseline_offset_theta_partials(age_exit[0], &cfg)
5209            .unwrap()
5210            .unwrap();
5211        let p_entry = baseline_offset_theta_partials(age_entry[0], &cfg)
5212            .unwrap()
5213            .unwrap();
5214        for k in 0..p_exit.len() {
5215            let expected = 0.7 * p_exit[k].0 + (-0.4) * p_exit[k].1 + (-0.2) * p_entry[k].0;
5216            assert!(
5217                (grad[k] - expected).abs() < 1e-12,
5218                "chain-rule contract mismatch at k={k}: got={:.6e} expected={:.6e}",
5219                grad[k],
5220                expected
5221            );
5222        }
5223    }
5224
5225    /// Origin-entry rows (r_entry == 0) must skip the baseline partials call at
5226    /// `age_entry = 0`, which would otherwise fail the positive-age precondition.
5227    #[test]
5228    fn chain_rule_gradient_skips_entry_call_for_origin_entry_rows() {
5229        let cfg = SurvivalBaselineConfig {
5230            target: SurvivalBaselineTarget::Gompertz,
5231            scale: None,
5232            shape: Some(0.05),
5233            rate: Some(0.3),
5234            makeham: None,
5235        };
5236        let age_entry = array![0.0_f64, 5.0_f64];
5237        let age_exit = array![10.0_f64, 20.0_f64];
5238        let residuals = OffsetChannelResiduals {
5239            exit: array![0.5_f64, 0.3_f64],
5240            entry: array![0.0_f64, -0.1_f64], // row 0 is origin-entry (r_E = 0)
5241            derivative: array![-0.2_f64, 0.0_f64],
5242            right: Array1::<f64>::zeros(2),
5243        };
5244        // Must not error despite age_entry[0] == 0.
5245        let grad = baseline_chain_rule_gradient(
5246            age_entry.view(),
5247            age_exit.view(),
5248            age_exit.view(),
5249            &cfg,
5250            &residuals,
5251        )
5252        .expect("must not fail on origin-entry row with r_entry=0")
5253        .expect("non-linear");
5254        assert_eq!(grad.len(), 2);
5255        // Row 1's entry channel contributes, row 0's does not.
5256        let p_exit_0 = baseline_offset_theta_partials(10.0, &cfg).unwrap().unwrap();
5257        let p_exit_1 = baseline_offset_theta_partials(20.0, &cfg).unwrap().unwrap();
5258        let p_entry_1 = baseline_offset_theta_partials(5.0, &cfg).unwrap().unwrap();
5259        for k in 0..2 {
5260            let expected = 0.5 * p_exit_0[k].0
5261                + (-0.2) * p_exit_0[k].1
5262                + 0.3 * p_exit_1[k].0
5263                + (-0.1) * p_entry_1[k].0;
5264            assert!(
5265                (grad[k] - expected).abs() < 1e-12,
5266                "origin-entry contract at k={k}: got={:.6e} expected={:.6e}",
5267                grad[k],
5268                expected
5269            );
5270        }
5271    }
5272
5273    /// Linear target has no θ-parameters; contractor returns None.
5274    #[test]
5275    fn chain_rule_gradient_linear_target_returns_none() {
5276        let cfg = SurvivalBaselineConfig {
5277            target: SurvivalBaselineTarget::Linear,
5278            scale: None,
5279            shape: None,
5280            rate: None,
5281            makeham: None,
5282        };
5283        let age_entry = array![1.0_f64];
5284        let age_exit = array![2.0_f64];
5285        let residuals = OffsetChannelResiduals {
5286            exit: array![0.1_f64],
5287            entry: array![0.0_f64],
5288            derivative: array![0.0_f64],
5289            right: Array1::<f64>::zeros(1),
5290        };
5291        let grad = baseline_chain_rule_gradient(
5292            age_entry.view(),
5293            age_exit.view(),
5294            age_exit.view(),
5295            &cfg,
5296            &residuals,
5297        )
5298        .expect("ok");
5299        assert!(grad.is_none());
5300    }
5301
5302    /// End-to-end envelope-theorem check: the chain-rule gradient at
5303    /// residuals-evaluated-at-β-fixed matches the central FD of the
5304    /// unpenalized NLL with respect to θ when the OFFSETS are recomputed
5305    /// from the perturbed cfg and β is held at its base value.
5306    ///
5307    /// This is the mathematical content of the envelope theorem applied to
5308    /// the penalized-deviance cost at fixed β: if β solves ∂C/∂β = 0 at
5309    /// (θ, β*), then the total derivative of C at (θ±h) when β is held at
5310    /// β* equals the partial derivative of C wrt θ at the base — up to
5311    /// O(h²) in the truncation error of central differences. For THIS test
5312    /// we're directly differencing NLL (the unpenalized piece that carries
5313    /// all the θ dependence), so the envelope identity is exact up to FD
5314    /// truncation.
5315    ///
5316    /// The test synthesizes a plausible residual set by hand rather than
5317    /// running PIRLS — what we're validating is the chain-rule contractor,
5318    /// not the fit. A PIRLS-based end-to-end check belongs in an
5319    /// integration test, not this unit-test module.
5320    #[test]
5321    fn chain_rule_gradient_matches_fd_of_nll_through_offset_perturbation() {
5322        // Toy 3-observation case with two events (one origin-entry, one not)
5323        // and one censored row at large age.
5324        let cfg = SurvivalBaselineConfig {
5325            target: SurvivalBaselineTarget::Gompertz,
5326            scale: None,
5327            shape: Some(0.03),
5328            rate: Some(0.25),
5329            makeham: None,
5330        };
5331        let age_entry = array![0.0_f64, 5.0, 8.0];
5332        let age_exit = array![4.0_f64, 12.0, 20.0];
5333        // Weighted residuals at a notional β*. Values chosen in a plausible
5334        // range (~same order as w·exp(η)).
5335        let weights = array![1.0_f64, 2.0, 0.5];
5336        let events = [1.0_f64, 1.0, 0.0];
5337        // Fake a β* that yields finite eta_entry ± eta_exit ± s values by
5338        // directly specifying eta quantities. Contractor only consumes the
5339        // residuals, so the fake is sufficient.
5340        let eta_entry_vals = [-100.0_f64, 0.5, 0.8]; // row 0 doesn't matter (origin entry)
5341        let eta_exit_vals = [0.4_f64, 0.9, 1.3];
5342        let s_vals = [0.7_f64, 1.1, 1.5];
5343        let (r_x, r_e, r_d) = {
5344            let mut rx = Array1::<f64>::zeros(3);
5345            let mut re = Array1::<f64>::zeros(3);
5346            let mut rd = Array1::<f64>::zeros(3);
5347            for i in 0..3 {
5348                let w = weights[i];
5349                let d = events[i];
5350                rx[i] = w * (eta_exit_vals[i].exp() - d);
5351                re[i] = if i == 0 {
5352                    0.0 // origin entry
5353                } else {
5354                    -w * eta_entry_vals[i].exp()
5355                };
5356                rd[i] = if d > 0.0 { -w * d / s_vals[i] } else { 0.0 };
5357            }
5358            (rx, re, rd)
5359        };
5360        let residuals = OffsetChannelResiduals {
5361            exit: r_x.clone(),
5362            entry: r_e.clone(),
5363            derivative: r_d.clone(),
5364            right: Array1::<f64>::zeros(3),
5365        };
5366        let grad = baseline_chain_rule_gradient(
5367            age_entry.view(),
5368            age_exit.view(),
5369            age_exit.view(),
5370            &cfg,
5371            &residuals,
5372        )
5373        .expect("ok")
5374        .expect("non-linear");
5375
5376        // Construct NLL(θ) with β* held to the same eta/s values by treating
5377        // eta_i, s_i as fixed "linear predictor" samples and shifting by
5378        // (offset(θ) - offset(θ_base)). That's exactly the RP NLL with β*
5379        // held constant and offsets varied through θ.
5380        let nll = |theta_plus: &Array1<f64>| -> f64 {
5381            let cfg_p = survival_baseline_config_from_theta(cfg.target, theta_plus).expect("cfg_p");
5382            let mut sum = 0.0_f64;
5383            for i in 0..3 {
5384                let (eta_x_p, d_x_p) = evaluate_survival_baseline(age_exit[i], &cfg_p).unwrap();
5385                let base = evaluate_survival_baseline(age_exit[i], &cfg).unwrap();
5386                let d_eta_x = eta_x_p - base.0;
5387                let d_d_x = d_x_p - base.1;
5388                let eta_exit_new = eta_exit_vals[i] + d_eta_x;
5389                let s_new = s_vals[i] + d_d_x;
5390                let interval_entry = if i == 0 {
5391                    0.0_f64
5392                } else {
5393                    let (eta_e_p, _) = evaluate_survival_baseline(age_entry[i], &cfg_p).unwrap();
5394                    let base_e = evaluate_survival_baseline(age_entry[i], &cfg).unwrap();
5395                    let d_eta_e = eta_e_p - base_e.0;
5396                    let eta_entry_new = eta_entry_vals[i] + d_eta_e;
5397                    eta_entry_new.exp()
5398                };
5399                let w = weights[i];
5400                let d = events[i];
5401                let nll_i =
5402                    w * (eta_exit_new.exp() - interval_entry - d * (eta_exit_new + s_new.ln()));
5403                sum += nll_i;
5404            }
5405            sum
5406        };
5407
5408        let theta_base = survival_baseline_theta_from_config(&cfg).unwrap().unwrap();
5409        let h = 1e-6;
5410        for k in 0..theta_base.len() {
5411            let mut tp = theta_base.clone();
5412            let mut tm = theta_base.clone();
5413            tp[k] += h;
5414            tm[k] -= h;
5415            let fd = (nll(&tp) - nll(&tm)) / (2.0 * h);
5416            assert!(
5417                (grad[k] - fd).abs() < 1e-5 * grad[k].abs().max(1.0),
5418                "chain-rule θ[{k}]: analytic={:.6e} fd={:.6e}",
5419                grad[k],
5420                fd
5421            );
5422        }
5423    }
5424
5425    /// Length-mismatch surfaces as an error, not a silent contraction.
5426    #[test]
5427    fn chain_rule_gradient_rejects_length_mismatch() {
5428        let cfg = SurvivalBaselineConfig {
5429            target: SurvivalBaselineTarget::Gompertz,
5430            scale: None,
5431            shape: Some(0.05),
5432            rate: Some(0.3),
5433            makeham: None,
5434        };
5435        let age_entry = array![1.0_f64, 2.0]; // length 2
5436        let age_exit = array![5.0_f64, 6.0, 7.0]; // length 3
5437        let residuals = OffsetChannelResiduals {
5438            exit: array![0.1_f64, 0.2, 0.3],
5439            entry: array![0.0_f64, 0.0, 0.0],
5440            derivative: array![0.0_f64, 0.0, 0.0],
5441            right: Array1::<f64>::zeros(3),
5442        };
5443        let err = baseline_chain_rule_gradient(
5444            age_entry.view(),
5445            age_exit.view(),
5446            age_exit.view(),
5447            &cfg,
5448            &residuals,
5449        )
5450        .expect_err("length mismatch must error");
5451        assert!(err.contains("length mismatch"), "err={err}");
5452    }
5453}