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