Skip to main content

gam_models/survival/
predict.rs

1//! Library-side survival prediction pipeline.
2//!
3//! Extracts the hazard/survival/cumulative-hazard math from the CLI's
4//! `run_predict_survival` so that both the CLI and the Python FFI can
5//! share a single entry point. The CLI retains ownership of progress
6//! bars, CSV writing, and uncertainty bounds; everything else (design
7//! build, baseline + time basis evaluation, link/time wiggles, and
8//! hazard/survival conversion) flows through [`predict_survival`].
9
10use std::collections::HashMap;
11
12use ndarray::{Array1, Array2, ArrayView2, s};
13
14use crate::fit_orchestration::prepare_survival_time_stack;
15use crate::inference::model::{
16    FittedFamily, FittedModel as SavedModel, FittedModelPayload,
17    SavedBaselineTimeWiggleRuntime, load_survival_time_basis_config_from_model,
18    survival_baseline_config_from_model,
19};
20use gam_data::EncodedDataset;
21use crate::inference::predict_io::{
22    BernoulliMarginalSlopePredictor, LatentConditioningSpan, PredictInput,
23};
24use crate::model_types::{BlockRole, FittedBlock, FittedLinkState, UnifiedFitResult};
25use crate::probability::signed_probit_logcdf_and_mills_ratio;
26use crate::survival::construction::{
27    SurvivalBaselineConfig, SurvivalBaselineTarget, SurvivalLikelihoodMode,
28    SurvivalTimeBuildOutput, add_survival_time_derivative_guard_offset, build_survival_time_basis,
29    build_survival_time_offsets_for_likelihood, build_survival_timewiggle_derivative_design,
30    center_survival_time_designs_at_anchor, evaluate_survival_time_basis_row,
31    normalize_survival_time_pair, parse_survival_likelihood_mode,
32    require_structural_survival_time_basis, resolved_survival_time_basis_config_from_build,
33    survival_derivative_guard_for_likelihood, survival_likelihood_modename,
34};
35use crate::survival::location_scale::SurvivalCovariateTimeBasis;
36use crate::survival::latent::fixed_latent_hazard_frailty;
37use crate::survival::lognormal_kernel::FrailtySpec;
38use crate::survival::{CompetingRisksCifResult, assemble_competing_risks_cif_from_endpoints};
39use crate::wiggle::monotone_wiggle_basis_with_derivative_order;
40use gam_linalg::matrix::DesignMatrix;
41use gam_problem::{InverseLink, LikelihoodSpec, ResponseFamily, StandardLink};
42use gam_solve::mixture_link::inverse_link_jet_for_inverse_link;
43use gam_terms::smooth::TermCollectionSpec;
44use gam_terms::smooth::build_term_collection_design;
45use gam_terms::term_builder::resolve_role_col;
46
47/// Resolved survival entry/exit column indices for a saved survival model.
48///
49/// `entry_col` is `None` when the model was trained with the right-censored
50/// shorthand `Surv(time, event)`; callers synthesize a zero entry time per
51/// row in that case via [`SurvivalTimeColumns::row_entry_time`]. Mirrors
52/// the CLI predict path so every site that consumes saved survival
53/// metadata applies the same fallback contract.
54pub struct SurvivalTimeColumns {
55    pub entry_col: Option<usize>,
56    pub exit_col: usize,
57}
58
59impl SurvivalTimeColumns {
60    /// Entry time for row `i`, defaulting to `0.0` when the saved model has
61    /// no `survival_entry` column (right-censored shorthand).
62    #[inline]
63    pub fn row_entry_time(&self, data: ArrayView2<'_, f64>, i: usize) -> f64 {
64        self.entry_col.map_or(0.0, |idx| data[[i, idx]])
65    }
66}
67
68/// Resolve saved survival entry/exit column names against the runtime
69/// `col_map`, treating an absent `survival_entry` as the right-censored
70/// shorthand (entry times synthesized as zero downstream).
71pub fn resolve_saved_survival_time_columns(
72    model: &SavedModel,
73    col_map: &HashMap<String, usize>,
74) -> Result<SurvivalTimeColumns, String> {
75    let entry_col: Option<usize> = model
76        .survival_entry
77        .as_deref()
78        .map(|name| resolve_role_col(col_map, name, "entry"))
79        .transpose()?;
80    let exitname = model
81        .survival_exit
82        .as_ref()
83        .ok_or_else(|| "survival model missing exit column metadata".to_string())?;
84    let exit_col = resolve_role_col(col_map, exitname, "exit")?;
85    Ok(SurvivalTimeColumns {
86        entry_col,
87        exit_col,
88    })
89}
90
91/// Smallest positive survival probability we admit before taking
92/// `-ln(S)` for the cumulative hazard. Using `f64::MIN_POSITIVE` (≈ 2.2e-308)
93/// would let `-ln(S)` reach ~709 and risk downstream `exp(-cum)` underflow
94/// patterns that don't round-trip through `clamp(0,1)`. `1e-300` keeps
95/// `-ln(S) ≤ ~691` and matches the location-scale predict contract upstream.
96const SURVIVAL_PROB_MIN_FOR_LOG: f64 = 1e-300;
97
98/// Typed errors emitted by the survival prediction pipeline.
99///
100/// Each variant carries a pre-formatted `reason` string so `Display` is
101/// byte-equivalent to the original `format!(...)` outputs the module used
102/// before the typed-error migration. The category split lets callers
103/// pattern-match on the failure kind without dragging the string apart.
104#[derive(Debug, Clone)]
105pub enum SurvivalPredictError {
106    /// Request-level input did not satisfy the predict contract: bad offset
107    /// lengths, malformed time grids, empty grids, non-finite times.
108    InvalidInput { reason: String },
109    /// The saved model is missing metadata required to drive the prediction
110    /// (anchor, link/distribution tags, likelihood-mode marker, etc.) or
111    /// carries legacy metadata that the current runtime refuses to consume.
112    MissingFitMetadata { reason: String },
113    /// Saved coefficient blocks, design columns, or baseline-timewiggle
114    /// runtime dimensions disagree with the rebuilt prediction designs.
115    IncompatibleSchema { reason: String },
116    /// The requested combination of saved-model mode and predict-time
117    /// options is not implemented in this library entry point yet (e.g.
118    /// uncertainty for a plug-in non-location-scale prediction or latent
119    /// window prediction).
120    UnsupportedConfiguration { reason: String },
121    /// Posterior-mean prediction requires the fitted joint coefficient
122    /// covariance in exactly the same block-concatenated coordinate system as
123    /// the saved coefficient vector. Missing, malformed, or dimensionally
124    /// incompatible covariance is an error; it must never change the requested
125    /// estimand by falling back to a plug-in surface.
126    PosteriorCovariance { reason: String },
127    /// A numerical step (hazard / derivative / survival reconstruction)
128    /// produced a non-finite or out-of-domain value that downstream code
129    /// cannot consume.
130    NumericalFailure { reason: String },
131    /// Saved-model validation failed below this prediction layer; the model
132    /// source error keeps its own payload/schema category.
133    ModelPayload {
134        context: &'static str,
135        source: crate::inference::model::FittedModelError,
136    },
137}
138
139impl std::fmt::Display for SurvivalPredictError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            SurvivalPredictError::InvalidInput { reason }
143            | SurvivalPredictError::MissingFitMetadata { reason }
144            | SurvivalPredictError::IncompatibleSchema { reason }
145            | SurvivalPredictError::UnsupportedConfiguration { reason }
146            | SurvivalPredictError::PosteriorCovariance { reason }
147            | SurvivalPredictError::NumericalFailure { reason } => f.write_str(reason),
148            SurvivalPredictError::ModelPayload { context, source } => {
149                write!(f, "{context}: {source}")
150            }
151        }
152    }
153}
154
155impl std::error::Error for SurvivalPredictError {
156    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
157        match self {
158            SurvivalPredictError::ModelPayload { source, .. } => Some(source),
159            SurvivalPredictError::InvalidInput { .. }
160            | SurvivalPredictError::MissingFitMetadata { .. }
161            | SurvivalPredictError::IncompatibleSchema { .. }
162            | SurvivalPredictError::UnsupportedConfiguration { .. }
163            | SurvivalPredictError::PosteriorCovariance { .. }
164            | SurvivalPredictError::NumericalFailure { .. } => None,
165        }
166    }
167}
168
169impl From<SurvivalPredictError> for String {
170    fn from(err: SurvivalPredictError) -> String {
171        err.to_string()
172    }
173}
174
175impl From<String> for SurvivalPredictError {
176    /// Inbound conversion from the many `Result<_, String>` helpers this
177    /// module still calls into (basis builders, fit deserializers,
178    /// term-collection assembly). The text is preserved verbatim; we only
179    /// pick a category so external messages flow through `?` without
180    /// per-callsite `.map_err`.
181    fn from(reason: String) -> SurvivalPredictError {
182        SurvivalPredictError::InvalidInput { reason }
183    }
184}
185
186impl From<gam_data::DataError> for SurvivalPredictError {
187    /// Column-resolution failures from `resolve_role_col` / `resolve_col`
188    /// land as `InvalidInput` since they reflect a mismatch between the
189    /// caller-supplied predict frame and the model's expected schema.
190    fn from(err: gam_data::DataError) -> SurvivalPredictError {
191        SurvivalPredictError::InvalidInput {
192            reason: err.to_string(),
193        }
194    }
195}
196
197/// Statistical target returned by the survival prediction API.
198///
199/// Survival, cumulative hazard, and hazard are nonlinear in the fitted
200/// coefficients, so evaluating them at the posterior centre is not the same
201/// estimand as integrating the coefficient posterior. The default is the
202/// posterior-predictive surface. Callers that specifically need the historical
203/// coefficient-mode surface must opt in to [`Self::Plugin`].
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub enum SurvivalPredictEstimand {
206    #[default]
207    PosteriorMean,
208    Plugin,
209}
210
211/// Exact coefficient-covariance definition used for competing-risks
212/// uncertainty.
213///
214/// Selection is strict: requesting [`Self::SmoothingCorrected`] requires a
215/// saved smoothing-corrected covariance and never substitutes the conditional
216/// covariance.  The resolved value is carried on
217/// [`CompetingRisksPredictResult`] so public frontends report what they used.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum SurvivalPredictionCovarianceMode {
220    Conditional,
221    SmoothingCorrected,
222}
223
224impl SurvivalPredictionCovarianceMode {
225    pub const fn as_str(self) -> &'static str {
226        match self {
227            Self::Conditional => "conditional",
228            Self::SmoothingCorrected => "smoothing-corrected",
229        }
230    }
231}
232
233/// Inputs to the unified survival predict pipeline.
234pub struct SurvivalPredictRequest<'a> {
235    pub model: &'a SavedModel,
236    pub data: ArrayView2<'a, f64>,
237    pub col_map: &'a HashMap<String, usize>,
238    pub training_headers: Option<&'a Vec<String>>,
239    pub primary_offset: &'a Array1<f64>,
240    pub noise_offset: &'a Array1<f64>,
241    /// If `None`, every row is evaluated at its own `age_exit`. If
242    /// `Some(grid)`, every row is evaluated at every time in the grid.
243    pub time_grid: Option<&'a [f64]>,
244    /// When true, the result also carries posterior standard errors for the
245    /// reported surfaces and linear predictors. Posterior-mean prediction uses
246    /// the same joint coefficient quadrature as the point estimand; explicit
247    /// plug-in single-event prediction retains its model-specific uncertainty
248    /// implementation.
249    pub with_uncertainty: bool,
250    /// Response-scale estimand. [`SurvivalPredictEstimand::PosteriorMean`] is
251    /// the default; plug-in prediction is available only as an explicit opt-in.
252    pub estimand: SurvivalPredictEstimand,
253}
254
255/// Result of [`predict_survival`].
256pub struct SurvivalPredictResult {
257    pub times: Vec<f64>,
258    pub hazard: Array2<f64>,
259    pub survival: Array2<f64>,
260    pub cumulative_hazard: Array2<f64>,
261    pub linear_predictor: Array1<f64>,
262    pub likelihood_mode: SurvivalLikelihoodMode,
263    /// Per-cell delta-method SE on the survival surface.  Same shape as
264    /// `survival`.  Populated only when the request set
265    /// `with_uncertainty = true` and the model class supports it.
266    pub survival_se: Option<Array2<f64>>,
267    /// Per-row delta-method SE on the linear predictor at the row's own
268    /// exit time.  Length `n`.  Populated under the same conditions as
269    /// `survival_se`.
270    pub eta_se: Option<Array1<f64>>,
271    /// Exact coefficient-covariance definition behind `survival_se`/`eta_se`.
272    /// Result-owned provenance (#2296): presenters must serialize this, never
273    /// the requested mode. `None` iff the result carries no uncertainty
274    /// surfaces.
275    pub covariance_source: Option<SurvivalPredictionCovarianceMode>,
276}
277
278/// Exact plug-in survival probability over each requested latent-hazard window.
279///
280/// The latent survival and latent-binary fits share the same persisted hazard
281/// law; they differ only in which response functional is presented to users.
282/// This result deliberately exposes the common probability
283/// `P(T > exit | T > entry, x)`. Observation generation can therefore sample
284/// the fitted window event indicator as Bernoulli with probability
285/// `1 - window_survival` without reconstructing a censoring or inspection law.
286pub struct LatentWindowSurvivalResult {
287    pub window_survival: Array1<f64>,
288    pub likelihood_mode: SurvivalLikelihoodMode,
289}
290
291/// Evaluate the saved latent hazard-multiplier law over the rows' own windows.
292///
293/// This is the library authority for both `latent` and `latent-binary` saved
294/// models. It replays the persisted covariate design, anchored time basis,
295/// loaded/unloaded baseline decomposition, fitted mean/time coefficients, and
296/// fixed lognormal hazard multiplier. No response column, refit, or surrogate
297/// family participates in the calculation.
298pub fn predict_latent_window_survival(
299    req: SurvivalPredictRequest<'_>,
300) -> Result<LatentWindowSurvivalResult, SurvivalPredictError> {
301    let SurvivalPredictRequest {
302        model,
303        data,
304        col_map,
305        training_headers,
306        primary_offset,
307        noise_offset,
308        time_grid,
309        with_uncertainty,
310        estimand,
311    } = req;
312    if time_grid.is_some() {
313        return Err(SurvivalPredictError::InvalidInput {
314            reason: "latent-window prediction consumes each row's saved entry/exit columns; an independent time_grid is not a window law".to_string(),
315        });
316    }
317    if with_uncertainty || estimand != SurvivalPredictEstimand::Plugin {
318        return Err(SurvivalPredictError::UnsupportedConfiguration {
319            reason: "latent-window observation generation requires the fitted plug-in hazard law; posterior coefficient integration is a different sampling target".to_string(),
320        });
321    }
322
323    let likelihood_mode = require_saved_survival_likelihood_mode(model)?;
324    if !matches!(
325        likelihood_mode,
326        SurvivalLikelihoodMode::Latent | SurvivalLikelihoodMode::LatentBinary
327    ) {
328        return Err(SurvivalPredictError::UnsupportedConfiguration {
329            reason: format!(
330                "latent-window prediction requires latent or latent-binary likelihood mode, got {}",
331                survival_likelihood_modename(likelihood_mode)
332            ),
333        });
334    }
335    if model.has_baseline_time_wiggle() {
336        return Err(SurvivalPredictError::IncompatibleSchema {
337            reason:
338                "saved latent survival/binary model contains forbidden baseline timewiggle metadata"
339                    .to_string(),
340        });
341    }
342
343    let n = data.nrows();
344    if primary_offset.len() != n || noise_offset.len() != n {
345        return Err(SurvivalPredictError::InvalidInput {
346            reason: format!(
347                "latent-window offset length mismatch: rows={n}, primary={}, noise={}",
348                primary_offset.len(),
349                noise_offset.len()
350            ),
351        });
352    }
353    if noise_offset.iter().any(|value| *value != 0.0) {
354        return Err(SurvivalPredictError::InvalidInput {
355            reason: "latent-window survival has no secondary offset coordinate".to_string(),
356        });
357    }
358
359    let termspec = resolve_termspec_for_prediction(
360        &model.resolved_termspec,
361        training_headers,
362        col_map,
363        "resolved_termspec",
364    )?;
365    let clipped = model.axis_clip_to_training_ranges(data, col_map);
366    let covariate_input = clipped.as_ref().map_or(data, |array| array.view());
367    let covariate_design = build_term_collection_design(covariate_input, &termspec)
368        .map_err(|error| format!("failed to build latent-window covariate design: {error}"))?;
369    let effective_primary_offset = covariate_design
370        .compose_offset(primary_offset.view(), "latent-window covariate block")
371        .map_err(|error| error.to_string())?;
372
373    let time_columns = resolve_saved_survival_time_columns(model, col_map)?;
374    let mut age_entry = Array1::<f64>::zeros(n);
375    let mut age_exit = Array1::<f64>::zeros(n);
376    for row in 0..n {
377        let (entry, exit) = normalize_survival_time_pair(
378            time_columns.row_entry_time(data, row),
379            data[[row, time_columns.exit_col]],
380            row,
381        )?;
382        age_entry[row] = entry;
383        age_exit[row] = exit;
384    }
385
386    let time_config = load_survival_time_basis_config_from_model(model)?;
387    let mut time_build = build_survival_time_basis(&age_entry, &age_exit, time_config, None)?;
388    let resolved_time_config = resolved_survival_time_basis_config_from_build(
389        &time_build.basisname,
390        time_build.degree,
391        time_build.knots.as_ref(),
392        time_build.keep_cols.as_ref(),
393        time_build.smooth_lambda,
394    )?;
395    let time_anchor =
396        model
397            .survival_time_anchor
398            .ok_or_else(|| SurvivalPredictError::MissingFitMetadata {
399                reason: "saved latent-window model is missing survival_time_anchor".to_string(),
400            })?;
401    let anchor_row = evaluate_survival_time_basis_row(time_anchor, &resolved_time_config)?;
402    center_survival_time_designs_at_anchor(
403        &mut time_build.x_entry_time,
404        &mut time_build.x_exit_time,
405        &anchor_row,
406    )?;
407    require_structural_survival_time_basis(
408        &time_build.basisname,
409        "saved latent-window prediction",
410    )?;
411
412    let frailty =
413        model
414            .family_state
415            .frailty()
416            .ok_or_else(|| SurvivalPredictError::MissingFitMetadata {
417                reason: "saved latent-window model is missing its hazard-multiplier frailty"
418                    .to_string(),
419            })?;
420    let (sigma, loading) = fixed_latent_hazard_frailty(frailty, "saved latent-window prediction")
421        .map_err(|reason| SurvivalPredictError::MissingFitMetadata { reason })?;
422    let baseline_config = saved_survival_runtime_baseline_config(model)?;
423    let prepared = prepare_survival_time_stack(
424        &age_entry,
425        &age_exit,
426        &baseline_config,
427        likelihood_mode,
428        None,
429        time_anchor,
430        survival_derivative_guard_for_likelihood(likelihood_mode),
431        &time_build,
432        None,
433        Some(loading),
434    )?;
435
436    let fit = fit_result_from_saved_model_for_prediction(model)?;
437    let mean_block = fit.block_by_role(BlockRole::Mean).ok_or_else(|| {
438        SurvivalPredictError::MissingFitMetadata {
439            reason: "saved latent-window model is missing its mean coefficient block".to_string(),
440        }
441    })?;
442    let time_block = fit.block_by_role(BlockRole::Time).ok_or_else(|| {
443        SurvivalPredictError::MissingFitMetadata {
444            reason: "saved latent-window model is missing its time coefficient block".to_string(),
445        }
446    })?;
447    if mean_block.beta.len() != covariate_design.design.ncols() {
448        return Err(SurvivalPredictError::IncompatibleSchema {
449            reason: format!(
450                "latent-window mean/design mismatch: beta has {} coefficients but design has {} columns",
451                mean_block.beta.len(),
452                covariate_design.design.ncols()
453            ),
454        });
455    }
456    if time_block.beta.len() != prepared.time_design_exit.ncols() {
457        let hint = stale_weibull_time_basis_hint(
458            &time_build.basisname,
459            time_block.beta.len() == prepared.time_design_exit.ncols() + 1,
460        );
461        return Err(SurvivalPredictError::IncompatibleSchema {
462            reason: format!(
463                "latent-window time/design mismatch: beta has {} coefficients but design has {} columns{hint}",
464                time_block.beta.len(),
465                prepared.time_design_exit.ncols()
466            ),
467        });
468    }
469
470    let eta = covariate_design.design.dot(&mean_block.beta) + &effective_primary_offset;
471    let q_entry = prepared.time_design_entry.dot(&time_block.beta) + &prepared.eta_offset_entry;
472    let q_exit = prepared.time_design_exit.dot(&time_block.beta) + &prepared.eta_offset_exit;
473    let quadrature = gam_solve::quadrature::QuadratureContext::new();
474    let mut window_survival = Array1::<f64>::zeros(n);
475    for row in 0..n {
476        let latent_row = crate::survival::lognormal_kernel::LatentSurvivalRow::right_censored(
477            q_entry[row].exp(),
478            q_exit[row].exp(),
479            prepared.unloaded_mass_entry[row],
480            prepared.unloaded_mass_exit[row],
481        );
482        let jet = crate::survival::lognormal_kernel::LatentSurvivalRowJet::evaluate(
483            &quadrature,
484            &latent_row,
485            eta[row],
486            sigma,
487        )
488        .map_err(|error| SurvivalPredictError::NumericalFailure {
489            reason: format!("latent-window row {row} evaluation failed: {error}"),
490        })?;
491        let survival = jet.log_lik.exp();
492        if !(survival.is_finite() && (0.0..=1.0).contains(&survival)) {
493            return Err(SurvivalPredictError::NumericalFailure {
494                reason: format!(
495                    "latent-window row {row} produced invalid conditional survival {survival}"
496                ),
497            });
498        }
499        window_survival[row] = survival;
500    }
501
502    Ok(LatentWindowSurvivalResult {
503        window_survival,
504        likelihood_mode,
505    })
506}
507
508fn select_survival_prediction_covariance<'a>(
509    conditional: Option<&'a Array2<f64>>,
510    smoothing_corrected: Option<&'a Array2<f64>>,
511    mode: SurvivalPredictionCovarianceMode,
512) -> Result<&'a Array2<f64>, SurvivalPredictError> {
513    match mode {
514        SurvivalPredictionCovarianceMode::Conditional => {
515            conditional.ok_or_else(|| SurvivalPredictError::PosteriorCovariance {
516                reason: "fit result does not contain conditional covariance".to_string(),
517            })
518        }
519        SurvivalPredictionCovarianceMode::SmoothingCorrected => {
520            smoothing_corrected.ok_or_else(|| SurvivalPredictError::PosteriorCovariance {
521                reason: "fit result does not contain smoothing-corrected covariance".to_string(),
522            })
523        }
524    }
525}
526
527/// Exact selected posterior covariance projected onto coefficients that can
528/// affect a survival prediction. The absorbed stage-one influence block in a
529/// marginal-slope fit is persisted for inference provenance but deliberately
530/// drops out of deployment, so its trailing coordinates are not quadrature
531/// dimensions.
532fn survival_prediction_posterior_factor(
533    model: &SavedModel,
534    covariance_mode: SurvivalPredictionCovarianceMode,
535) -> Result<(Array1<f64>, Array2<f64>, Vec<usize>), SurvivalPredictError> {
536    let fit = fit_result_from_saved_model_for_prediction(model)?;
537    let inactive_tail = if require_saved_survival_likelihood_mode(model)?
538        == SurvivalLikelihoodMode::MarginalSlope
539    {
540        model
541            .saved_prediction_runtime()?
542            .influence_absorber_width
543            .unwrap_or(0)
544    } else {
545        0
546    };
547    let active_len = fit.beta.len().checked_sub(inactive_tail).ok_or_else(|| {
548        SurvivalPredictError::IncompatibleSchema {
549            reason: format!(
550                "saved survival influence-absorber width {inactive_tail} exceeds the {} fitted coefficients",
551                fit.beta.len()
552            ),
553        }
554    })?;
555    let covariance = select_survival_prediction_covariance(
556        fit.beta_covariance(),
557        fit.beta_covariance_corrected(),
558        covariance_mode,
559    )?;
560    if covariance.nrows() != fit.beta.len() || covariance.ncols() != fit.beta.len() {
561        return Err(SurvivalPredictError::PosteriorCovariance {
562            reason: format!(
563                "saved survival {} covariance has shape {}x{}, expected {}x{} in fitted block order",
564                covariance_mode.as_str(),
565                covariance.nrows(),
566                covariance.ncols(),
567                fit.beta.len(),
568                fit.beta.len(),
569            ),
570        });
571    }
572    let cone_coords = survival_posterior_cone_coordinates(model, active_len)?;
573    Ok((
574        fit.beta.clone(),
575        covariance.slice(s![..active_len, ..active_len]).to_owned(),
576        cone_coords,
577    ))
578}
579
580fn saved_model_with_survival_coefficients(
581    model: &SavedModel,
582    coefficients: &Array1<f64>,
583) -> Result<SavedModel, SurvivalPredictError> {
584    let mut draw_model = model.clone();
585    let payload = match &mut draw_model {
586        SavedModel::Standard { payload }
587        | SavedModel::LocationScale { payload }
588        | SavedModel::MarginalSlope { payload }
589        | SavedModel::Survival { payload }
590        | SavedModel::TransformationNormal { payload } => payload,
591    };
592
593    let (beta_time, beta_threshold, beta_log_sigma, beta_link_wiggle, beta_time_blocks) = {
594        let fit = payload.fit_result.as_mut().ok_or_else(|| {
595            SurvivalPredictError::MissingFitMetadata {
596                reason: "saved survival model is missing canonical fit_result".to_string(),
597            }
598        })?;
599        if coefficients.len() != fit.beta.len() {
600            return Err(SurvivalPredictError::IncompatibleSchema {
601                reason: format!(
602                    "posterior survival coefficient draw has length {}, expected {}",
603                    coefficients.len(),
604                    fit.beta.len()
605                ),
606            });
607        }
608        fit.beta.assign(coefficients);
609        let mut cursor = 0usize;
610        for block in &mut fit.blocks {
611            let end = cursor + block.beta.len();
612            block.beta.assign(&coefficients.slice(s![cursor..end]));
613            cursor = end;
614        }
615        if cursor != coefficients.len() {
616            return Err(SurvivalPredictError::IncompatibleSchema {
617                reason: format!(
618                    "saved survival coefficient blocks total {cursor} entries, but the joint vector has {}",
619                    coefficients.len()
620                ),
621            });
622        }
623        (
624            fit.block_by_role(BlockRole::Time)
625                .map(|block| block.beta.to_vec()),
626            fit.block_by_role(BlockRole::Threshold)
627                .map(|block| block.beta.to_vec()),
628            fit.block_by_role(BlockRole::Scale)
629                .map(|block| block.beta.to_vec()),
630            fit.block_by_role(BlockRole::LinkWiggle)
631                .map(|block| block.beta.to_vec()),
632            fit.blocks
633                .iter()
634                .map(|block| block.beta.to_vec())
635                .collect::<Vec<_>>(),
636        )
637    };
638
639    if payload.survival_beta_time.is_some() {
640        payload.survival_beta_time = beta_time.clone();
641    }
642    if payload.survival_beta_threshold.is_some() {
643        payload.survival_beta_threshold = beta_threshold;
644    }
645    if payload.survival_beta_log_sigma.is_some() {
646        payload.survival_beta_log_sigma = beta_log_sigma;
647    }
648    if payload.beta_link_wiggle.is_some() {
649        payload.beta_link_wiggle = beta_link_wiggle;
650    }
651    if let (Some(saved), Some(time_beta)) = (
652        payload.beta_baseline_timewiggle.as_mut(),
653        beta_time.as_ref(),
654    ) {
655        if saved.len() > time_beta.len() {
656            return Err(SurvivalPredictError::IncompatibleSchema {
657                reason: format!(
658                    "saved baseline-timewiggle has {} coefficients, but the time block has {}",
659                    saved.len(),
660                    time_beta.len()
661                ),
662            });
663        }
664        *saved = time_beta[time_beta.len() - saved.len()..].to_vec();
665    }
666    if let Some(saved_by_cause) = payload.beta_baseline_timewiggle_by_cause.as_mut() {
667        if saved_by_cause.len() != beta_time_blocks.len() {
668            return Err(SurvivalPredictError::IncompatibleSchema {
669                reason: format!(
670                    "saved cause-specific timewiggles have {} blocks, but the fit has {} cause blocks",
671                    saved_by_cause.len(),
672                    beta_time_blocks.len()
673                ),
674            });
675        }
676        for (saved, block) in saved_by_cause.iter_mut().zip(&beta_time_blocks) {
677            if saved.len() > block.len() {
678                return Err(SurvivalPredictError::IncompatibleSchema {
679                    reason: format!(
680                        "saved cause-specific timewiggle has {} coefficients, but its endpoint block has {}",
681                        saved.len(),
682                        block.len()
683                    ),
684                });
685            }
686            *saved = block[block.len() - saved.len()..].to_vec();
687        }
688    }
689    Ok(draw_model)
690}
691
692fn conditional_event_density(
693    survival: f64,
694    cumulative_hazard: f64,
695    hazard: f64,
696) -> Result<f64, SurvivalPredictError> {
697    if hazard == 0.0 {
698        return Ok(0.0);
699    }
700    if survival > 0.0 && hazard.is_finite() {
701        return Ok(survival * hazard);
702    }
703    if cumulative_hazard.is_finite() && hazard > 0.0 {
704        return Ok((hazard.ln() - cumulative_hazard).exp());
705    }
706    if cumulative_hazard == f64::INFINITY && hazard.is_finite() && hazard >= 0.0 {
707        return Ok(0.0);
708    }
709    Err(SurvivalPredictError::NumericalFailure {
710        reason: format!(
711            "posterior survival quadrature could not resolve conditional density from S={survival}, H={cumulative_hazard}, h={hazard}"
712        ),
713    })
714}
715
716/// Third-degree spherical-radial quadrature for a possibly singular Gaussian
717/// coefficient posterior.  The `2r` equal-weight nodes are exact for every
718/// polynomial through total degree three in the active rank-`r` subspace, use
719/// the full covariance (including cross-block/cross-cause terms), and require
720/// no sampling seed or dimension-specific tuning constant.
721///
722/// `cone_coords` names the coefficient positions (indices into the active
723/// subspace `0..active_len`) that were constrained to the nonnegativity cone
724/// `β_j ≥ 0` when the fit was certified — the structural monotone-I-spline
725/// baseline time columns of a Royston-Parmar survival fit. The parameter space
726/// of such a model is the cone `C = {β_j ≥ 0 : j ∈ cone}`, so the Laplace
727/// posterior is `N(β̂, Vb)` **truncated to `C`**, not the untruncated Gaussian.
728/// Its quadrature nodes must lie in `C`; a node that pokes a structural time
729/// coefficient below zero manufactures a non-monotone baseline log-cumulative
730/// hazard whose derivative the plugin evaluator then (correctly) refuses
731/// (`royston_parmar_survival_hazard_components`, #2375). For each factor
732/// direction we therefore shrink the symmetric ±step to the largest value that
733/// keeps BOTH nodes feasible (the standard fraction-to-boundary rule):
734///
735/// ```text
736///   α_k = min( √rank,  min_{j ∈ cone, f_{j,k} ≠ 0}  β̂_j / |f_{j,k}| )
737///   nodes = β̂ ± α_k · f_{·,k},   weight 1/(2·rank)  (unchanged)
738/// ```
739///
740/// This keeps the rule symmetric about `β̂` (so it stays exact for linear
741/// functionals and leaves the posterior mean unbiased), keeps every node inside
742/// `C` by construction, and represents `(α_k/√rank)² · Vb` of the spread along
743/// a constrained direction — the right direction of travel, since a truncated
744/// Gaussian genuinely has smaller variance than its untruncated parent. Passing
745/// an empty `cone_coords` recovers the exact unconstrained rule verbatim.
746fn for_each_survival_posterior_node<F>(
747    posterior_mean: &Array1<f64>,
748    active_covariance: &Array2<f64>,
749    cone_coords: &[usize],
750    mut consume: F,
751) -> Result<(), SurvivalPredictError>
752where
753    F: FnMut(&Array1<f64>, f64) -> Result<(), SurvivalPredictError>,
754{
755    let active_len = active_covariance.nrows();
756    if active_covariance.ncols() != active_len || active_len > posterior_mean.len() {
757        return Err(SurvivalPredictError::PosteriorCovariance {
758            reason: format!(
759                "survival posterior quadrature received mean length {} and active covariance {}x{}",
760                posterior_mean.len(),
761                active_covariance.nrows(),
762                active_covariance.ncols(),
763            ),
764        });
765    }
766    let factorization = crate::survival::location_scale::factorize_psd_covariance(
767        active_covariance,
768        "survival posterior coefficient covariance",
769    )
770    .map_err(|reason| SurvivalPredictError::PosteriorCovariance { reason })?;
771    let rank = factorization.factor.ncols();
772    if rank == 0 {
773        return consume(posterior_mean, 1.0);
774    }
775    let nominal_scale = (rank as f64).sqrt();
776    let weight = 1.0 / (2 * rank) as f64;
777    for column in 0..rank {
778        // Fraction-to-boundary step for the cone `{β_j ≥ 0 : j ∈ cone}`. Each
779        // symmetric node is `β̂ ± scale · f_{·,column}`; feasibility of both
780        // nodes at coordinate `j` requires `|scale · f_{j,column}| ≤ β̂_j`, i.e.
781        // `scale ≤ β̂_j / |f_{j,column}|`. `β̂_j` is clamped at 0 so a coordinate
782        // already numerically pinned at the wall collapses that direction's
783        // step to 0 rather than admitting an infeasible (negative-step) node.
784        let mut scale = nominal_scale;
785        for &j in cone_coords {
786            if j >= active_len {
787                continue;
788            }
789            let load = factorization.factor[[j, column]].abs();
790            if load == 0.0 {
791                continue;
792            }
793            let limit = posterior_mean[j].max(0.0) / load;
794            if limit < scale {
795                scale = limit;
796            }
797        }
798        for sign in [-1.0_f64, 1.0_f64] {
799            let mut node = posterior_mean.clone();
800            for row in 0..active_len {
801                node[row] += sign * scale * factorization.factor[[row, column]];
802            }
803            consume(&node, weight)?;
804        }
805    }
806    Ok(())
807}
808
809/// Structural-monotonicity cone coordinates for a saved survival model's
810/// posterior quadrature — the coefficient positions the fit constrained to
811/// `β_j ≥ 0` (the leading I-spline baseline time columns, per cause).
812///
813/// Only the transformation (Royston-Parmar) family carries a *coordinate* cone:
814/// the fit realizes structural monotonicity as a per-coordinate lower-bound box
815/// `lb[j] = 0` over the leading `p_time_base + p_timewiggle` columns of every
816/// cause block (`fit_survival_transformation_model` /
817/// `fit_cause_specific_survival_transformation_custom`). Weibull carries a
818/// parametric `log t` baseline with no structural cone; marginal-slope enforces
819/// monotonicity with row-wise (not coordinate) constraints; location-scale and
820/// latent posteriors are not routed through this quadrature. In all those cases
821/// this returns an empty cone, recovering the untruncated quadrature verbatim.
822fn survival_posterior_cone_coordinates(
823    model: &SavedModel,
824    active_len: usize,
825) -> Result<Vec<usize>, SurvivalPredictError> {
826    if require_saved_survival_likelihood_mode(model)? != SurvivalLikelihoodMode::Transformation {
827        return Ok(Vec::new());
828    }
829    // Baseline I-spline width (time-independent: it is fixed by the saved knots
830    // / kept columns, not the evaluation times). The timewiggle arm saves the
831    // base basis as `None`, in which case the whole learned time block is the
832    // monotone wiggle tail counted separately below.
833    let time_cfg = load_survival_time_basis_config_from_model(model)
834        .map_err(|err| SurvivalPredictError::MissingFitMetadata {
835            reason: err.to_string(),
836        })?;
837    let p_time_base = if matches!(
838        time_cfg,
839        crate::survival::construction::SurvivalTimeBasisConfig::None
840    ) {
841        0
842    } else {
843        let dummy = Array1::from_elem(1, 1.0_f64);
844        build_survival_time_basis(&dummy, &dummy, time_cfg, None)
845            .map_err(|reason| SurvivalPredictError::MissingFitMetadata { reason })?
846            .x_exit_time
847            .ncols()
848    };
849
850    let fit = fit_result_from_saved_model_for_prediction(model)?;
851    let cause_count = model
852        .survival_cause_count
853        .unwrap_or(fit.blocks.len())
854        .max(1);
855    // Per-cause monotone timewiggle width (0 when the model carries none). The
856    // cone spans the leading `p_time_base + p_timewiggle` coefficients of each
857    // cause block — exactly the structural columns the fit lower-bounds at 0.
858    let per_cause_wiggle: Vec<usize> = if cause_count > 1 {
859        saved_cause_specific_timewiggles(model, &fit, cause_count)?
860            .iter()
861            .map(|w| w.as_ref().map_or(0, |runtime| runtime.beta.len()))
862            .collect()
863    } else {
864        vec![
865            model
866                .saved_baseline_time_wiggle()
867                .map_err(|err| SurvivalPredictError::MissingFitMetadata {
868                    reason: err.to_string(),
869                })?
870                .map_or(0, |runtime| runtime.beta.len()),
871        ]
872    };
873
874    let mut cone = Vec::new();
875    let mut cursor = 0usize;
876    for (cause, block) in fit.blocks.iter().enumerate() {
877        let block_len = block.beta.len();
878        let width = (p_time_base + per_cause_wiggle.get(cause).copied().unwrap_or(0)).min(block_len);
879        for j in cursor..cursor + width {
880            if j < active_len {
881                cone.push(j);
882            }
883        }
884        cursor += block_len;
885    }
886    Ok(cone)
887}
888
889fn posterior_standard_error_matrix(
890    mean: &Array2<f64>,
891    second_moment: &Array2<f64>,
892    label: &str,
893) -> Result<Array2<f64>, SurvivalPredictError> {
894    if second_moment.dim() != mean.dim() {
895        return Err(SurvivalPredictError::IncompatibleSchema {
896            reason: format!(
897                "posterior {label} moment shape mismatch: mean={:?}, second={:?}",
898                mean.dim(),
899                second_moment.dim(),
900            ),
901        });
902    }
903    let mut standard_error = Array2::<f64>::zeros(mean.raw_dim());
904    for ((row, column), slot) in standard_error.indexed_iter_mut() {
905        let first = mean[[row, column]];
906        let second = second_moment[[row, column]];
907        if !(first.is_finite() && second.is_finite()) {
908            return Err(SurvivalPredictError::NumericalFailure {
909                reason: format!(
910                    "posterior {label} moments must be finite at row {row}, time column {column}: mean={first}, second={second}"
911                ),
912            });
913        }
914        let variance = second - first * first;
915        let roundoff_tolerance =
916            128.0 * f64::EPSILON * second.abs().max((first * first).abs()).max(1.0);
917        if variance < -roundoff_tolerance {
918            return Err(SurvivalPredictError::NumericalFailure {
919                reason: format!(
920                    "posterior {label} variance is negative beyond roundoff at row {row}, time column {column}: {variance}"
921                ),
922            });
923        }
924        *slot = variance.max(0.0).sqrt();
925    }
926    Ok(standard_error)
927}
928
929fn posterior_standard_error_vector(
930    mean: &Array1<f64>,
931    second_moment: &Array1<f64>,
932    label: &str,
933) -> Result<Array1<f64>, SurvivalPredictError> {
934    if second_moment.len() != mean.len() {
935        return Err(SurvivalPredictError::IncompatibleSchema {
936            reason: format!(
937                "posterior {label} moment length mismatch: mean={}, second={}",
938                mean.len(),
939                second_moment.len(),
940            ),
941        });
942    }
943    let mut standard_error = Array1::<f64>::zeros(mean.len());
944    for row in 0..mean.len() {
945        let first = mean[row];
946        let second = second_moment[row];
947        if !(first.is_finite() && second.is_finite()) {
948            return Err(SurvivalPredictError::NumericalFailure {
949                reason: format!(
950                    "posterior {label} moments must be finite at row {row}: mean={first}, second={second}"
951                ),
952            });
953        }
954        let variance = second - first * first;
955        let roundoff_tolerance =
956            128.0 * f64::EPSILON * second.abs().max((first * first).abs()).max(1.0);
957        if variance < -roundoff_tolerance {
958            return Err(SurvivalPredictError::NumericalFailure {
959                reason: format!(
960                    "posterior {label} variance is negative beyond roundoff at row {row}: {variance}"
961                ),
962            });
963        }
964        standard_error[row] = variance.max(0.0).sqrt();
965    }
966    Ok(standard_error)
967}
968
969fn posterior_standard_error_surfaces(
970    mean: &[Array2<f64>],
971    second_moment: &[Array2<f64>],
972    label: &str,
973) -> Result<Vec<Array2<f64>>, SurvivalPredictError> {
974    if second_moment.len() != mean.len() {
975        return Err(SurvivalPredictError::IncompatibleSchema {
976            reason: format!(
977                "posterior {label} cause count mismatch: mean={}, second={}",
978                mean.len(),
979                second_moment.len(),
980            ),
981        });
982    }
983    mean.iter()
984        .zip(second_moment)
985        .enumerate()
986        .map(|(cause, (first, second))| {
987            posterior_standard_error_matrix(first, second, &format!("{label} cause {}", cause + 1))
988        })
989        .collect()
990}
991
992fn posterior_standard_error_vectors(
993    mean: &[Array1<f64>],
994    second_moment: &[Array1<f64>],
995    label: &str,
996) -> Result<Vec<Array1<f64>>, SurvivalPredictError> {
997    if second_moment.len() != mean.len() {
998        return Err(SurvivalPredictError::IncompatibleSchema {
999            reason: format!(
1000                "posterior {label} cause count mismatch: mean={}, second={}",
1001                mean.len(),
1002                second_moment.len(),
1003            ),
1004        });
1005    }
1006    mean.iter()
1007        .zip(second_moment)
1008        .enumerate()
1009        .map(|(cause, (first, second))| {
1010            posterior_standard_error_vector(first, second, &format!("{label} cause {}", cause + 1))
1011        })
1012        .collect()
1013}
1014
1015fn predict_survival_posterior_mean(
1016    req: SurvivalPredictRequest<'_>,
1017    covariance_mode: SurvivalPredictionCovarianceMode,
1018) -> Result<SurvivalPredictResult, SurvivalPredictError> {
1019    let (posterior_mean, active_covariance, cone_coords) =
1020        survival_prediction_posterior_factor(req.model, covariance_mode)?;
1021    let mut result = predict_survival(
1022        SurvivalPredictRequest {
1023            model: req.model,
1024            data: req.data,
1025            col_map: req.col_map,
1026            training_headers: req.training_headers,
1027            primary_offset: req.primary_offset,
1028            noise_offset: req.noise_offset,
1029            time_grid: req.time_grid,
1030            with_uncertainty: false,
1031            estimand: SurvivalPredictEstimand::Plugin,
1032        },
1033        covariance_mode,
1034    )?;
1035    let (n_rows, n_times) = result.survival.dim();
1036    let mut survival_mean = Array2::<f64>::zeros((n_rows, n_times));
1037    let mut survival_second = Array2::<f64>::zeros((n_rows, n_times));
1038    let mut density_mean = Array2::<f64>::zeros((n_rows, n_times));
1039    let mut hazard_mean = Array2::<f64>::zeros((n_rows, n_times));
1040    let mut eta_mean = Array1::<f64>::zeros(n_rows);
1041    let mut eta_second = Array1::<f64>::zeros(n_rows);
1042
1043    for_each_survival_posterior_node(&posterior_mean, &active_covariance, &cone_coords, |node, weight| {
1044        let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
1045        let draw = predict_survival(
1046            SurvivalPredictRequest {
1047                model: &draw_model,
1048                data: req.data,
1049                col_map: req.col_map,
1050                training_headers: req.training_headers,
1051                primary_offset: req.primary_offset,
1052                noise_offset: req.noise_offset,
1053                time_grid: req.time_grid,
1054                with_uncertainty: false,
1055                estimand: SurvivalPredictEstimand::Plugin,
1056            },
1057            covariance_mode,
1058        )?;
1059        if draw.survival.dim() != (n_rows, n_times)
1060            || draw.hazard.dim() != (n_rows, n_times)
1061            || draw.cumulative_hazard.dim() != (n_rows, n_times)
1062            || draw.linear_predictor.len() != n_rows
1063            || draw.times != result.times
1064            || draw.likelihood_mode != result.likelihood_mode
1065        {
1066            return Err(SurvivalPredictError::IncompatibleSchema {
1067                reason: "posterior survival quadrature node changed the prediction schema"
1068                    .to_string(),
1069            });
1070        }
1071        for row in 0..n_rows {
1072            let eta = draw.linear_predictor[row];
1073            eta_mean[row] += weight * eta;
1074            eta_second[row] += weight * eta * eta;
1075            for time in 0..n_times {
1076                let survival = draw.survival[[row, time]];
1077                let hazard = draw.hazard[[row, time]];
1078                let density = conditional_event_density(
1079                    survival,
1080                    draw.cumulative_hazard[[row, time]],
1081                    hazard,
1082                )?;
1083                survival_mean[[row, time]] += weight * survival;
1084                survival_second[[row, time]] += weight * survival * survival;
1085                density_mean[[row, time]] += weight * density;
1086                hazard_mean[[row, time]] += weight * hazard;
1087            }
1088        }
1089        Ok(())
1090    })?;
1091
1092    for row in 0..n_rows {
1093        for time in 0..n_times {
1094            let survival = survival_mean[[row, time]].clamp(0.0, 1.0);
1095            let density = density_mean[[row, time]];
1096            if !(density.is_finite() && density >= 0.0) {
1097                return Err(SurvivalPredictError::NumericalFailure {
1098                    reason: format!(
1099                        "posterior survival density is invalid at row {row}, time column {time}: {density}"
1100                    ),
1101                });
1102            }
1103            result.survival[[row, time]] = survival;
1104            result.cumulative_hazard[[row, time]] = -survival.ln();
1105            result.hazard[[row, time]] = if survival > 0.0 {
1106                density / survival
1107            } else if hazard_mean[[row, time]] == 0.0 {
1108                0.0
1109            } else {
1110                f64::INFINITY
1111            };
1112        }
1113    }
1114    result.survival_se = req.with_uncertainty.then(|| {
1115        Array2::from_shape_fn((n_rows, n_times), |(row, time)| {
1116            (survival_second[[row, time]] - survival_mean[[row, time]] * survival_mean[[row, time]])
1117                .max(0.0)
1118                .sqrt()
1119        })
1120    });
1121    result.eta_se = req.with_uncertainty.then(|| {
1122        Array1::from_shape_fn(n_rows, |row| {
1123            (eta_second[row] - eta_mean[row] * eta_mean[row])
1124                .max(0.0)
1125                .sqrt()
1126        })
1127    });
1128    result.covariance_source = req.with_uncertainty.then_some(covariance_mode);
1129    Ok(result)
1130}
1131
1132fn predict_competing_risks_with_posterior(
1133    req: SurvivalPredictRequest<'_>,
1134    covariance_mode: SurvivalPredictionCovarianceMode,
1135) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
1136    let posterior_mean_estimand = req.estimand == SurvivalPredictEstimand::PosteriorMean;
1137    let (posterior_mean, active_covariance, cone_coords) =
1138        survival_prediction_posterior_factor(req.model, covariance_mode)?;
1139    // The public posterior-mean point is always the conditional-posterior
1140    // estimand. A smoothing-corrected interval changes only its reported
1141    // uncertainty, exactly as on the standard-family path. If corrected
1142    // covariance becomes available for this model class, compute the
1143    // conditional point once and the corrected second moments separately;
1144    // never silently change the point estimand with the interval mode.
1145    let separate_conditional_point = posterior_mean_estimand
1146        && req.with_uncertainty
1147        && covariance_mode == SurvivalPredictionCovarianceMode::SmoothingCorrected;
1148    let mut result = if separate_conditional_point {
1149        predict_competing_risks_with_posterior(
1150            SurvivalPredictRequest {
1151                model: req.model,
1152                data: req.data,
1153                col_map: req.col_map,
1154                training_headers: req.training_headers,
1155                primary_offset: req.primary_offset,
1156                noise_offset: req.noise_offset,
1157                time_grid: req.time_grid,
1158                with_uncertainty: false,
1159                estimand: SurvivalPredictEstimand::PosteriorMean,
1160            },
1161            SurvivalPredictionCovarianceMode::Conditional,
1162        )?
1163    } else {
1164        predict_competing_risks_survival(
1165            SurvivalPredictRequest {
1166                model: req.model,
1167                data: req.data,
1168                col_map: req.col_map,
1169                training_headers: req.training_headers,
1170                primary_offset: req.primary_offset,
1171                noise_offset: req.noise_offset,
1172                time_grid: req.time_grid,
1173                with_uncertainty: false,
1174                estimand: SurvivalPredictEstimand::Plugin,
1175            },
1176            SurvivalPredictionCovarianceMode::Conditional,
1177        )?
1178    };
1179    let cause_count = result.cif.len();
1180    let (n_rows, n_times) = result.overall_survival.dim();
1181    let mut survival_mean = (0..cause_count)
1182        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1183        .collect::<Vec<_>>();
1184    let mut survival_second = (0..cause_count)
1185        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1186        .collect::<Vec<_>>();
1187    let mut hazard_mean = (0..cause_count)
1188        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1189        .collect::<Vec<_>>();
1190    let mut hazard_second = (0..cause_count)
1191        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1192        .collect::<Vec<_>>();
1193    let mut cumulative_hazard_mean = (0..cause_count)
1194        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1195        .collect::<Vec<_>>();
1196    let mut cumulative_hazard_second = (0..cause_count)
1197        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1198        .collect::<Vec<_>>();
1199    let mut cif_mean = (0..cause_count)
1200        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1201        .collect::<Vec<_>>();
1202    let mut cif_second = (0..cause_count)
1203        .map(|_| Array2::<f64>::zeros((n_rows, n_times)))
1204        .collect::<Vec<_>>();
1205    let mut overall_mean = Array2::<f64>::zeros((n_rows, n_times));
1206    let mut overall_second = Array2::<f64>::zeros((n_rows, n_times));
1207    let mut eta_mean = (0..cause_count)
1208        .map(|_| Array1::<f64>::zeros(n_rows))
1209        .collect::<Vec<_>>();
1210    let mut eta_second = (0..cause_count)
1211        .map(|_| Array1::<f64>::zeros(n_rows))
1212        .collect::<Vec<_>>();
1213
1214    for_each_survival_posterior_node(&posterior_mean, &active_covariance, &cone_coords, |node, weight| {
1215        let draw_model = saved_model_with_survival_coefficients(req.model, node)?;
1216        let draw = predict_competing_risks_survival(
1217            SurvivalPredictRequest {
1218                model: &draw_model,
1219                data: req.data,
1220                col_map: req.col_map,
1221                training_headers: req.training_headers,
1222                primary_offset: req.primary_offset,
1223                noise_offset: req.noise_offset,
1224                time_grid: req.time_grid,
1225                with_uncertainty: false,
1226                estimand: SurvivalPredictEstimand::Plugin,
1227            },
1228            SurvivalPredictionCovarianceMode::Conditional,
1229        )?;
1230        if draw.cif.len() != cause_count
1231            || draw.survival.len() != cause_count
1232            || draw.hazard.len() != cause_count
1233            || draw.cumulative_hazard.len() != cause_count
1234            || draw.linear_predictor.len() != cause_count
1235            || draw.overall_survival.dim() != (n_rows, n_times)
1236            || draw.times != result.times
1237            || draw.endpoint_names != result.endpoint_names
1238            || draw.likelihood_mode != result.likelihood_mode
1239        {
1240            return Err(SurvivalPredictError::IncompatibleSchema {
1241                reason: "posterior competing-risks quadrature node changed the prediction schema"
1242                    .to_string(),
1243            });
1244        }
1245        for cause in 0..cause_count {
1246            if draw.survival[cause].dim() != (n_rows, n_times)
1247                || draw.hazard[cause].dim() != (n_rows, n_times)
1248                || draw.cumulative_hazard[cause].dim() != (n_rows, n_times)
1249                || draw.cif[cause].dim() != (n_rows, n_times)
1250                || draw.linear_predictor[cause].len() != n_rows
1251            {
1252                return Err(SurvivalPredictError::IncompatibleSchema {
1253                    reason: format!(
1254                        "posterior competing-risks quadrature node changed cause {} surface dimensions",
1255                        cause + 1
1256                    ),
1257                });
1258            }
1259            for row in 0..n_rows {
1260                let eta = draw.linear_predictor[cause][row];
1261                eta_mean[cause][row] += weight * eta;
1262                eta_second[cause][row] += weight * eta * eta;
1263                for time in 0..n_times {
1264                    let survival = draw.survival[cause][[row, time]];
1265                    let hazard = draw.hazard[cause][[row, time]];
1266                    let cumulative_hazard = draw.cumulative_hazard[cause][[row, time]];
1267                    let cif = draw.cif[cause][[row, time]];
1268                    survival_mean[cause][[row, time]] += weight * survival;
1269                    survival_second[cause][[row, time]] += weight * survival * survival;
1270                    hazard_mean[cause][[row, time]] += weight * hazard;
1271                    hazard_second[cause][[row, time]] += weight * hazard * hazard;
1272                    cumulative_hazard_mean[cause][[row, time]] += weight * cumulative_hazard;
1273                    cumulative_hazard_second[cause][[row, time]] +=
1274                        weight * cumulative_hazard * cumulative_hazard;
1275                    cif_mean[cause][[row, time]] += weight * cif;
1276                    cif_second[cause][[row, time]] += weight * cif * cif;
1277                }
1278            }
1279        }
1280        for row in 0..n_rows {
1281            for time in 0..n_times {
1282                let overall_survival = draw.overall_survival[[row, time]];
1283                overall_mean[[row, time]] += weight * overall_survival;
1284                overall_second[[row, time]] += weight * overall_survival * overall_survival;
1285            }
1286        }
1287        Ok(())
1288    })?;
1289
1290    let (hazard_se, survival_se, cumulative_hazard_se, cif_se, overall_survival_se, eta_se) =
1291        if req.with_uncertainty {
1292            (
1293                Some(posterior_standard_error_surfaces(
1294                    &hazard_mean,
1295                    &hazard_second,
1296                    "competing-risks hazard",
1297                )?),
1298                Some(posterior_standard_error_surfaces(
1299                    &survival_mean,
1300                    &survival_second,
1301                    "competing-risks survival",
1302                )?),
1303                Some(posterior_standard_error_surfaces(
1304                    &cumulative_hazard_mean,
1305                    &cumulative_hazard_second,
1306                    "competing-risks cumulative hazard",
1307                )?),
1308                Some(posterior_standard_error_surfaces(
1309                    &cif_mean,
1310                    &cif_second,
1311                    "competing-risks cumulative incidence",
1312                )?),
1313                Some(posterior_standard_error_matrix(
1314                    &overall_mean,
1315                    &overall_second,
1316                    "competing-risks overall survival",
1317                )?),
1318                Some(posterior_standard_error_vectors(
1319                    &eta_mean,
1320                    &eta_second,
1321                    "competing-risks linear predictor",
1322                )?),
1323            )
1324        } else {
1325            (None, None, None, None, None, None)
1326        };
1327
1328    if posterior_mean_estimand && !separate_conditional_point {
1329        result.hazard = hazard_mean;
1330        result.survival = survival_mean
1331            .into_iter()
1332            .map(|surface| surface.mapv(|value| value.clamp(0.0, 1.0)))
1333            .collect();
1334        result.cumulative_hazard = cumulative_hazard_mean;
1335        result.cif = cif_mean
1336            .into_iter()
1337            .map(|surface| surface.mapv(|value| value.clamp(0.0, 1.0)))
1338            .collect();
1339        result.overall_survival = overall_mean.mapv(|value| value.clamp(0.0, 1.0));
1340        result.linear_predictor = eta_mean;
1341    }
1342    result.hazard_se = hazard_se;
1343    result.survival_se = survival_se;
1344    result.cumulative_hazard_se = cumulative_hazard_se;
1345    result.cif_se = cif_se;
1346    result.overall_survival_se = overall_survival_se;
1347    result.eta_se = eta_se;
1348    result.covariance_source = req.with_uncertainty.then_some(covariance_mode);
1349    Ok(result)
1350}
1351
1352/// Trapezoidal integral of a per-row survival curve `s(t)` sampled at the shared
1353/// increasing `times` grid, restricted to `[0, tau]` — the restricted mean
1354/// survival time (RMST) at horizon `tau`.
1355///
1356/// `RMST_i(tau) = \int_0^{tau} S_i(t) dt`. This is the standard clinical-trial
1357/// survival summary (`survRM2`, lifelines `restricted_mean_survival_time`,
1358/// flexsurv `rmst_*`): the area under the survival curve up to `tau`, equal to
1359/// the mean of `min(T_i, tau)`. The curve is integrated with the trapezoid rule
1360/// over the prediction grid; the head segment `[0, times[0]]` uses `S(0) = 1`
1361/// (every subject is alive at the time origin), and when `tau` falls strictly
1362/// inside a grid cell the survival value at `tau` is linearly interpolated so the
1363/// partial cell contributes exactly. Grid points beyond `tau` are dropped.
1364///
1365/// Returns `None` when the grid is empty or `tau <= 0` (no area to accumulate),
1366/// or when any sampled survival value on the integrated span is non-finite.
1367fn restricted_mean_survival_time_from_curve(
1368    times: &[f64],
1369    survival_row: ndarray::ArrayView1<'_, f64>,
1370    tau: f64,
1371) -> Option<f64> {
1372    if times.is_empty() || !(tau > 0.0) || !tau.is_finite() {
1373        return None;
1374    }
1375    if times.len() != survival_row.len() {
1376        return None;
1377    }
1378
1379    // Survival at the cell boundaries we sweep through, starting from S(0) = 1.
1380    let mut prev_t = 0.0_f64;
1381    let mut prev_s = 1.0_f64;
1382    let mut area = 0.0_f64;
1383
1384    for (idx, &t) in times.iter().enumerate() {
1385        if !t.is_finite() || t < prev_t {
1386            return None;
1387        }
1388        let s = survival_row[idx];
1389        if !s.is_finite() {
1390            return None;
1391        }
1392        if t >= tau {
1393            // tau lands in (prev_t, t]; interpolate S(tau) and add the partial cell.
1394            let span = t - prev_t;
1395            let s_tau = if span > 0.0 {
1396                let w = (tau - prev_t) / span;
1397                prev_s + w * (s - prev_s)
1398            } else {
1399                prev_s
1400            };
1401            area += 0.5 * (prev_s + s_tau) * (tau - prev_t);
1402            return Some(area);
1403        }
1404        area += 0.5 * (prev_s + s) * (t - prev_t);
1405        prev_t = t;
1406        prev_s = s;
1407    }
1408
1409    // tau is beyond the last grid point: extend the last survival value flat to
1410    // tau (conservative, matches survRM2's tau-at-or-before-last-event contract;
1411    // callers wanting a strict horizon pass a tau within the grid).
1412    area += prev_s * (tau - prev_t);
1413    Some(area)
1414}
1415
1416/// A restricted-mean-survival column together with the horizon it was
1417/// integrated to.
1418///
1419/// RMST is meaningless without its `tau` — "8.4 months" answers a different
1420/// question at a 12-month horizon than at a 24-month one — so the two travel
1421/// together and every reporting surface emits both.
1422#[derive(Clone, Debug)]
1423pub struct RestrictedMeanSurvival {
1424    /// The restriction horizon the area was accumulated to, in time units.
1425    pub tau: f64,
1426    /// Per-row `\int_0^{tau} S_i(t) dt`, one entry per predicted row.
1427    pub values: Array1<f64>,
1428}
1429
1430/// The horizon a prediction grid supports: its last time point.
1431///
1432/// `None` when the grid is empty or its last point is not a positive finite
1433/// time, which are exactly the cases the RMST integral rejects.
1434fn prediction_horizon(times: &[f64]) -> Option<f64> {
1435    let tau = *times.last()?;
1436    (tau.is_finite() && tau > 0.0).then_some(tau)
1437}
1438
1439impl SurvivalPredictResult {
1440    /// Restricted mean survival time over the prediction horizon — the area
1441    /// under each row's survival curve out to the last time on the grid.
1442    ///
1443    /// This is the reporting default: the horizon is the grid the caller
1444    /// already chose, so no separate knob decides it. Callers wanting a
1445    /// different `tau` use [`Self::restricted_mean_survival_time`] directly.
1446    pub fn rmst_over_prediction_horizon(&self) -> Option<RestrictedMeanSurvival> {
1447        let tau = prediction_horizon(&self.times)?;
1448        Some(RestrictedMeanSurvival {
1449            tau,
1450            values: self.restricted_mean_survival_time(tau)?,
1451        })
1452    }
1453
1454    /// Per-row restricted mean survival time `\int_0^{tau} S_i(t) dt` from the
1455    /// predicted survival surface. `tau` is the restriction horizon (e.g. the
1456    /// study follow-up bound). Length-`n` vector, one RMST per predicted row.
1457    ///
1458    /// Returns `None` if the prediction grid is empty, `tau <= 0`, or any row's
1459    /// survival curve carries a non-finite value on `[0, tau]`.
1460    pub fn restricted_mean_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
1461        let n = self.survival.nrows();
1462        let mut out = Array1::<f64>::zeros(n);
1463        for i in 0..n {
1464            let rmst =
1465                restricted_mean_survival_time_from_curve(&self.times, self.survival.row(i), tau)?;
1466            out[i] = rmst;
1467        }
1468        Some(out)
1469    }
1470}
1471
1472impl CompetingRisksPredictResult {
1473    /// All-cause restricted mean survival time over the prediction horizon.
1474    ///
1475    /// The competing-risks counterpart of
1476    /// [`SurvivalPredictResult::rmst_over_prediction_horizon`], taken on the
1477    /// overall survival `exp(-sum_k H_k(t))`.
1478    pub fn overall_rmst_over_prediction_horizon(&self) -> Option<RestrictedMeanSurvival> {
1479        let tau = prediction_horizon(&self.times)?;
1480        Some(RestrictedMeanSurvival {
1481            tau,
1482            values: self.restricted_mean_overall_survival_time(tau)?,
1483        })
1484    }
1485
1486    /// Per-row restricted mean survival time of the OVERALL (all-cause) survival
1487    /// curve, `\int_0^{tau} S_overall_i(t) dt`. For competing risks the relevant
1488    /// restricted-mean summary is taken on the all-cause survival
1489    /// `exp(-sum_k H_k(t))`; cause-specific restricted-mean-time-lost is
1490    /// `tau - RMST` partitioned by CIF and is left to the CIF surface directly.
1491    pub fn restricted_mean_overall_survival_time(&self, tau: f64) -> Option<Array1<f64>> {
1492        let n = self.overall_survival.nrows();
1493        let mut out = Array1::<f64>::zeros(n);
1494        for i in 0..n {
1495            let rmst = restricted_mean_survival_time_from_curve(
1496                &self.times,
1497                self.overall_survival.row(i),
1498                tau,
1499            )?;
1500            out[i] = rmst;
1501        }
1502        Some(out)
1503    }
1504}
1505
1506/// Harrell's concordance index (C-index) of a survival risk score against
1507/// held-out outcomes. A larger `risk[i]` must predict a SHORTER survival time
1508/// (higher hazard). Over every orderable pair — pairs whose earlier observed
1509/// time is a genuine event, so the failure ordering is observed — a pair is
1510/// concordant when the earlier-failing subject carries the larger risk; equal
1511/// risks score half credit. `C = (concordant + 0.5·tied) / comparable`.
1512/// `C = 0.5` is random ranking, `C = 1.0` a perfect ordering.
1513///
1514/// This is the standard discrimination metric (`survival::concordance`,
1515/// `lifelines.utils.concordance_index`, scikit-survival `concordance_index_censored`).
1516/// `time`, `event` (1 = event, 0 = censored), and `risk` must share length `n`.
1517/// Returns `None` if there are no comparable pairs (e.g. all rows censored).
1518pub fn harrell_concordance(time: &[f64], event: &[f64], risk: &[f64]) -> Option<f64> {
1519    let n = time.len();
1520    if n != event.len() || n != risk.len() {
1521        return None;
1522    }
1523    let mut comparable = 0.0_f64;
1524    let mut concordant = 0.0_f64;
1525    for i in 0..n {
1526        for j in (i + 1)..n {
1527            let (early, late) = if time[i] < time[j] {
1528                (i, j)
1529            } else if time[j] < time[i] {
1530                (j, i)
1531            } else {
1532                // Tied times are comparable only if both failed; such a pair is a
1533                // pure tie (no strict outcome ordering).
1534                if event[i] > 0.5 && event[j] > 0.5 {
1535                    comparable += 1.0;
1536                    concordant += 0.5;
1537                }
1538                continue;
1539            };
1540            if event[early] < 0.5 {
1541                // The earlier subject was censored: the true ordering is unknown.
1542                continue;
1543            }
1544            comparable += 1.0;
1545            if risk[early] > risk[late] {
1546                concordant += 1.0;
1547            } else if risk[early] == risk[late] {
1548                concordant += 0.5;
1549            }
1550        }
1551    }
1552    if comparable == 0.0 {
1553        return None;
1554    }
1555    Some(concordant / comparable)
1556}
1557
1558/// IPCW (inverse-probability-of-censoring-weighted) Brier score of a predicted
1559/// survival probability at a fixed horizon `tau` against held-out outcomes — the
1560/// Graf et al. (1999) estimator used by scikit-survival `brier_score`, `pec`, and
1561/// `survival::brier`.
1562///
1563/// `s_pred[i]` is the model's predicted survival probability `S(tau | x_i)`.
1564/// `time`/`event` are the held-out observed time and event indicator. `g_cens`
1565/// is the censoring survival distribution `G(t) = P(C > t)` evaluated at the two
1566/// weighting times the estimator needs per subject — supplied as a callable so
1567/// the caller can pass a Kaplan–Meier fit of the censoring process. Each
1568/// subject's squared residual `(target − Ŝ_i(τ))²` is reweighted by the inverse
1569/// censoring probability:
1570///   * event at/before `τ` (`T_i ≤ τ, δ_i = 1`) → target `0` (dead), weight `1/G(T_i)`;
1571///   * still alive past `τ` (`T_i > τ`)         → target `1` (alive), weight `1/G(τ)`;
1572///   * censored at/before `τ`                    → target undefined, contributes `0`.
1573///
1574/// The score is the **sample mean over all valid subjects** (Graf normalization,
1575/// dividing by `n`, not by the sum of weights):
1576///   `BS(τ) = (1/n) Σ_i w_i·(target_i − Ŝ_i(τ))²`.
1577/// This is the convention scikit-survival / pec / `survival::brier` report, so
1578/// the value is directly comparable to those packages. Lower is better; `0` is
1579/// perfect. Returns `None` on length mismatch or when no subject is valid.
1580///
1581/// Subjects with non-finite or non-positive `time`/`event` are dropped from both
1582/// numerator and denominator. When `G` collapses to `0` at a weighting time the
1583/// IPCW weight is undefined; such a subject contributes `0` (rather than `∞`),
1584/// which keeps the estimator finite at the extreme tail where the censoring KM
1585/// runs out of support.
1586pub fn ipcw_brier_score(
1587    s_pred: &[f64],
1588    time: &[f64],
1589    event: &[f64],
1590    tau: f64,
1591    g_cens: impl Fn(f64) -> f64,
1592) -> Option<f64> {
1593    let n = s_pred.len();
1594    if n != time.len() || n != event.len() {
1595        return None;
1596    }
1597    let mut n_valid = 0.0_f64;
1598    let mut acc = 0.0_f64;
1599    for i in 0..n {
1600        if !time[i].is_finite() || !event[i].is_finite() || time[i] <= 0.0 {
1601            continue;
1602        }
1603        // Every valid subject counts toward the Graf denominator, even when its
1604        // IPCW contribution is zero (censored before τ, or G undefined).
1605        n_valid += 1.0;
1606        let (target, weight) = if time[i] <= tau && event[i] > 0.5 {
1607            // Failed at or before the horizon: contributes via 1/G(T_i).
1608            let g = g_cens(time[i]);
1609            if !(g > 0.0) {
1610                continue;
1611            }
1612            (0.0, 1.0 / g)
1613        } else if time[i] > tau {
1614            // Survived past the horizon: contributes via 1/G(τ).
1615            let g = g_cens(tau);
1616            if !(g > 0.0) {
1617                continue;
1618            }
1619            (1.0, 1.0 / g)
1620        } else {
1621            // Censored at or before τ (and not an event past τ): no info.
1622            continue;
1623        };
1624        let resid = target - s_pred[i];
1625        acc += weight * resid * resid;
1626    }
1627    if n_valid == 0.0 {
1628        return None;
1629    }
1630    Some(acc / n_valid)
1631}
1632
1633/// Per-subject scores read off a predicted survival path, from
1634/// [`monotone_survival_and_hazard_scores`].
1635#[derive(Clone, Debug, Default)]
1636pub struct HazardPathScores {
1637    /// `H(T_i) − δ_i·ln h(T_i)`: the negative log-likelihood of subject `i`
1638    /// under the piecewise-constant hazard implied by its survival path.
1639    pub log_losses: Vec<f64>,
1640    /// `½∫₀^{T_i} h² − δ_i·h(T_i)`: a proper score for the hazard model, and
1641    /// **not** a Brier score — see [`integrated_ipcw_brier_score`] for that.
1642    pub hazard_quadratic_losses: Vec<f64>,
1643}
1644
1645/// Repair a predicted survival matrix into a valid survival path, then read the
1646/// per-subject hazard scores off it.
1647///
1648/// Each row of `raw` is clamped into `[eps, 1]`, forced non-increasing, and
1649/// pinned to `1.0` in the first grid column. The piecewise-constant hazard on
1650/// interval `k` is then `(H(t_{k+1}) − H(t_k)) / Δt_k` with `H = −ln S`, and
1651/// each subject is scored at its own event time `T_i` — exactly on a grid point
1652/// when one coincides, otherwise by linear accumulation inside the containing
1653/// interval.
1654///
1655/// Returns the repaired matrix alongside the scores, because callers need the
1656/// same repaired matrix for [`integrated_ipcw_brier_score`]; scoring a
1657/// differently-repaired matrix would make the two metrics disagree about which
1658/// prediction they scored.
1659///
1660/// `grid` must be strictly increasing with at least two points, `observed[i]`
1661/// is `δ_i`, and every `event_times[i]` must be finite and positive — callers
1662/// validate that, since what to do about a malformed input is theirs to decide.
1663pub fn monotone_survival_and_hazard_scores(
1664    raw: ArrayView2<f64>,
1665    event_times: &[f64],
1666    observed: &[bool],
1667    grid: &[f64],
1668    eps: f64,
1669) -> (Array2<f64>, HazardPathScores) {
1670    let mut surv = raw.to_owned();
1671    for mut row in surv.rows_mut() {
1672        row[0] = 1.0;
1673        let mut prev = 1.0;
1674        for value in row.iter_mut() {
1675            *value = value.clamp(eps, 1.0).min(prev);
1676            prev = *value;
1677        }
1678    }
1679    let dt: Vec<f64> = grid.windows(2).map(|pair| pair[1] - pair[0]).collect();
1680    let cumhaz = surv.mapv(|value| -value.clamp(eps, 1.0).ln());
1681    let mut haz = Array2::<f64>::zeros((surv.nrows(), surv.ncols() - 1));
1682    for row in 0..surv.nrows() {
1683        for col in 0..surv.ncols() - 1 {
1684            haz[[row, col]] = ((cumhaz[[row, col + 1]] - cumhaz[[row, col]]) / dt[col]).max(0.0);
1685        }
1686    }
1687    let mut haz_sq_prefix = Array2::<f64>::zeros((surv.nrows(), surv.ncols()));
1688    for row in 0..surv.nrows() {
1689        for col in 0..haz.ncols() {
1690            haz_sq_prefix[[row, col + 1]] =
1691                haz_sq_prefix[[row, col]] + haz[[row, col]] * haz[[row, col]] * dt[col];
1692        }
1693    }
1694    let mut log_losses = vec![0.0; event_times.len()];
1695    let mut hazard_quadratic_losses = vec![0.0; event_times.len()];
1696    for (row, &time) in event_times.iter().enumerate() {
1697        let mut j = grid.partition_point(|value| *value < time);
1698        if j >= grid.len() {
1699            j = grid.len() - 1;
1700        }
1701        let interval_idx = j.saturating_sub(1);
1702        let (h_z, h2_int, hcum_z) = if (grid[j] - time).abs() <= GRID_COINCIDENCE_TOLERANCE {
1703            (
1704                haz[[row, interval_idx]],
1705                haz_sq_prefix[[row, j]],
1706                cumhaz[[row, j]],
1707            )
1708        } else {
1709            let elapsed = time - grid[interval_idx];
1710            let h = haz[[row, interval_idx]];
1711            (
1712                h,
1713                haz_sq_prefix[[row, interval_idx]] + h * h * elapsed,
1714                cumhaz[[row, interval_idx]] + h * elapsed,
1715            )
1716        };
1717        log_losses[row] = hcum_z - if observed[row] { h_z.max(eps).ln() } else { 0.0 };
1718        hazard_quadratic_losses[row] = 0.5 * h2_int - if observed[row] { h_z } else { 0.0 };
1719    }
1720    (
1721        surv,
1722        HazardPathScores {
1723            log_losses,
1724            hazard_quadratic_losses,
1725        },
1726    )
1727}
1728
1729/// An event time this close to a grid point is treated as landing exactly on
1730/// it, so the score reads the prefix sums directly instead of adding a
1731/// zero-width interval correction.
1732const GRID_COINCIDENCE_TOLERANCE: f64 = 1.0e-12;
1733
1734/// Integrated IPCW Brier score (IBS) — the time-integrated [`ipcw_brier_score`],
1735/// matching scikit-survival's `integrated_brier_score` and `pec`'s integrated
1736/// prediction-error curve.
1737///
1738/// `s_pred` is the `n × m` matrix of predicted survival probabilities whose
1739/// column `k` is `Ŝ_i(grid[k])`; `grid` is the strictly-increasing set of
1740/// evaluation times. The per-time Graf Brier `BS(grid[k])` is integrated by the
1741/// trapezoidal rule over the grid and normalized by the integration span:
1742///   `IBS = (1 / (t_max − t_min)) ∫_{t_min}^{t_max} BS(t) dt`.
1743///
1744/// `g_cens` is the censoring survival `G(t) = P(C > t)` (see [`KaplanMeier`]).
1745/// Integration is restricted to grid points within `[grid[0], horizon]`; pass
1746/// `horizon = f64::INFINITY` to integrate the full grid. Restricting to the
1747/// observed support is the standard guard against the extrapolation tail where
1748/// no subject remains at risk and the IPCW weights become unstable.
1749///
1750/// Returns `None` if the grid is malformed (fewer than two usable points, wrong
1751/// width, non-increasing) or every per-time Brier is undefined.
1752pub fn integrated_ipcw_brier_score(
1753    s_pred: ArrayView2<f64>,
1754    time: &[f64],
1755    event: &[f64],
1756    grid: &[f64],
1757    horizon: f64,
1758    g_cens: impl Fn(f64) -> f64,
1759) -> Option<f64> {
1760    let m = grid.len();
1761    if m < 2 || s_pred.ncols() != m || s_pred.nrows() != time.len() {
1762        return None;
1763    }
1764    if grid.windows(2).any(|pair| !(pair[1] > pair[0])) {
1765        return None;
1766    }
1767    // Collect (time, Brier) at every grid point inside the integration window.
1768    let mut pts: Vec<(f64, f64)> = Vec::with_capacity(m);
1769    for k in 0..m {
1770        if grid[k] > horizon {
1771            break;
1772        }
1773        let col = s_pred.column(k);
1774        let col_slice: Vec<f64> = col.to_vec();
1775        if let Some(bs) = ipcw_brier_score(&col_slice, time, event, grid[k], &g_cens) {
1776            pts.push((grid[k], bs));
1777        }
1778    }
1779    if pts.len() < 2 {
1780        return None;
1781    }
1782    let span = pts[pts.len() - 1].0 - pts[0].0;
1783    if !(span > 0.0) {
1784        return None;
1785    }
1786    let mut integral = 0.0_f64;
1787    for w in pts.windows(2) {
1788        integral += 0.5 * (w[1].1 + w[0].1) * (w[1].0 - w[0].0);
1789    }
1790    Some(integral / span)
1791}
1792
1793/// Right-continuous Kaplan–Meier survival estimator `Ŝ(t) = ∏_{t_j ≤ t}(1 − d_j/n_j)`.
1794///
1795/// Built from observed `(time, event)` pairs. To estimate the **censoring**
1796/// survival `G(t) = P(C > t)` required by the IPCW Brier score, fit with the
1797/// event indicator flipped (`1 − event`) so that censorings are the "events"
1798/// of the reversed process — see [`KaplanMeier::fit_censoring`].
1799#[derive(Clone, Debug, Default)]
1800pub struct KaplanMeier {
1801    /// `(event_time, survival_after_that_time)`, strictly increasing in time.
1802    steps: Vec<(f64, f64)>,
1803}
1804
1805impl KaplanMeier {
1806    /// Fit the survival of the process whose event indicator is `event > 0.5`.
1807    pub fn fit(time: &[f64], event: &[f64]) -> Self {
1808        let mut rows: Vec<(f64, bool)> = time
1809            .iter()
1810            .zip(event.iter())
1811            .filter_map(|(&t, &e)| {
1812                (t.is_finite() && e.is_finite() && t > 0.0).then_some((t, e > 0.5))
1813            })
1814            .collect();
1815        rows.sort_by(|a, b| a.0.total_cmp(&b.0));
1816        let mut steps = Vec::new();
1817        let mut at_risk = rows.len() as f64;
1818        let mut survival = 1.0_f64;
1819        let mut i = 0usize;
1820        while i < rows.len() {
1821            let t = rows[i].0;
1822            let mut j = i;
1823            let mut deaths = 0usize;
1824            while j < rows.len() && rows[j].0 == t {
1825                deaths += usize::from(rows[j].1);
1826                j += 1;
1827            }
1828            if deaths > 0 && at_risk > 0.0 {
1829                survival *= ((at_risk - deaths as f64) / at_risk).max(0.0);
1830                steps.push((t, survival));
1831            }
1832            at_risk -= (j - i) as f64;
1833            i = j;
1834        }
1835        Self { steps }
1836    }
1837
1838    /// Fit the censoring survival `G(t) = P(C > t)` by reversing the event role:
1839    /// a censored observation (`event ≤ 0.5`) is an "event" of the censoring
1840    /// process and a death (`event > 0.5`) is a censoring of it.
1841    pub fn fit_censoring(time: &[f64], event: &[f64]) -> Self {
1842        let flipped: Vec<f64> = event
1843            .iter()
1844            .map(|&e| if e > 0.5 { 0.0 } else { 1.0 })
1845            .collect();
1846        Self::fit(time, &flipped)
1847    }
1848
1849    /// [`Self::at`] evaluated across a whole grid. `steps` is sorted by
1850    /// construction, so each lookup is a binary search rather than the linear
1851    /// scan `at` does — the difference matters when a caller evaluates a dense
1852    /// grid against a step function with one step per event time.
1853    pub fn on_grid(&self, grid: &[f64]) -> Vec<f64> {
1854        grid.iter()
1855            .map(|&t| {
1856                let idx = self.steps.partition_point(|&(time, _)| time <= t);
1857                if idx == 0 { 1.0 } else { self.steps[idx - 1].1 }
1858            })
1859            .collect()
1860    }
1861
1862    /// Right-continuous step lookup: `Ŝ(t)` = survival at the last event time
1863    /// `≤ t` (and `1.0` before the first event).
1864    pub fn at(&self, t: f64) -> f64 {
1865        let mut s = 1.0_f64;
1866        for &(time, surv) in &self.steps {
1867            if time <= t {
1868                s = surv;
1869            } else {
1870                break;
1871            }
1872        }
1873        s
1874    }
1875}
1876
1877/// Joint cause-specific competing-risks prediction result.
1878pub struct CompetingRisksPredictResult {
1879    pub times: Vec<f64>,
1880    pub endpoint_names: Vec<String>,
1881    /// Cause-specific instantaneous hazards, shaped endpoint x row x time.
1882    pub hazard: Vec<Array2<f64>>,
1883    /// Endpoint-specific survival surfaces exp(-H_k(t)), endpoint x row x time.
1884    pub survival: Vec<Array2<f64>>,
1885    /// Cause-specific cumulative hazards, endpoint x row x time.
1886    pub cumulative_hazard: Vec<Array2<f64>>,
1887    /// Aalen-Johansen cumulative incidence, endpoint x row x time.
1888    pub cif: Vec<Array2<f64>>,
1889    /// Overall survival exp(-sum_k H_k(t)), row x time.
1890    pub overall_survival: Array2<f64>,
1891    /// Per-endpoint linear predictor at each row's own exit time, endpoint x row.
1892    pub linear_predictor: Vec<Array1<f64>>,
1893    pub likelihood_mode: SurvivalLikelihoodMode,
1894    /// Exact covariance definition used for posterior standard errors.
1895    /// `None` means no uncertainty was requested.
1896    pub covariance_source: Option<SurvivalPredictionCovarianceMode>,
1897    /// Posterior standard deviation of each cause-specific hazard surface.
1898    pub hazard_se: Option<Vec<Array2<f64>>>,
1899    /// Posterior standard deviation of each endpoint-specific survival surface.
1900    pub survival_se: Option<Vec<Array2<f64>>>,
1901    /// Posterior standard deviation of each cause-specific cumulative hazard.
1902    pub cumulative_hazard_se: Option<Vec<Array2<f64>>>,
1903    /// Posterior standard deviation of each cause-specific cumulative incidence.
1904    pub cif_se: Option<Vec<Array2<f64>>>,
1905    /// Posterior standard deviation of the all-cause survival surface.
1906    pub overall_survival_se: Option<Array2<f64>>,
1907    /// Posterior standard deviation of each cause-specific linear predictor.
1908    pub eta_se: Option<Vec<Array1<f64>>>,
1909}
1910
1911/// Run the survival prediction pipeline.
1912///
1913/// Pure library function: no progress bars, no file I/O, no uncertainty
1914/// bounds. The CLI wraps this with progress updates + CSV writes; the
1915/// FFI wraps it with JSON serialization.
1916pub fn predict_survival(
1917    req: SurvivalPredictRequest<'_>,
1918    covariance_mode: SurvivalPredictionCovarianceMode,
1919) -> Result<SurvivalPredictResult, SurvivalPredictError> {
1920    if req.estimand == SurvivalPredictEstimand::PosteriorMean {
1921        return predict_survival_posterior_mean(req, covariance_mode);
1922    }
1923    let SurvivalPredictRequest {
1924        model,
1925        data,
1926        col_map,
1927        training_headers,
1928        primary_offset,
1929        noise_offset,
1930        time_grid,
1931        with_uncertainty,
1932        estimand: _,
1933    } = req;
1934
1935    // `survival_entry == None` is the right-censored shorthand
1936    // `Surv(time, event)` produced by `gam fit` / `gamfit.fit`: no entry
1937    // column was supplied at training time, so entry ages default to
1938    // zero at prediction time too. The CLI's `run_predict_survival`
1939    // applies the same fallback; mirroring it here keeps `gam predict`,
1940    // `gam sample`, and the Python `model.predict` FFI symmetric across
1941    // every likelihood that lands in this code path (weibull,
1942    // transformation, ...).
1943    let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
1944    let exit_col = time_cols.exit_col;
1945
1946    let termspec = resolve_termspec_for_prediction(
1947        &model.resolved_termspec,
1948        training_headers,
1949        col_map,
1950        "resolved_termspec",
1951    )?;
1952    // Clip continuous covariate columns to the training range before basis
1953    // assembly so polyharmonic / spline terms cannot extrapolate outside the
1954    // data envelope. Times (`entry_col` / `exit_col`) are read from the
1955    // original `data` view further down so the hazard integration stays on
1956    // the raw timestamps the user supplied.
1957    let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
1958    let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
1959    let cov_design = build_term_collection_design(cov_input, &termspec)
1960        .map_err(|e| format!("failed to build survival prediction design: {e}"))?;
1961
1962    let n = data.nrows();
1963    if primary_offset.len() != n || noise_offset.len() != n {
1964        return Err(SurvivalPredictError::InvalidInput {
1965            reason: format!(
1966                "survival prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
1967                primary_offset.len(),
1968                noise_offset.len()
1969            ),
1970        });
1971    }
1972    let effective_primary_offset = cov_design
1973        .compose_offset(primary_offset.view(), "survival prediction covariate block")
1974        .map_err(|error| error.to_string())?;
1975
1976    use rayon::iter::{IntoParallelIterator, ParallelIterator};
1977    let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
1978        .into_par_iter()
1979        .map(|i| {
1980            normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
1981        })
1982        .collect();
1983    let pairs = pairs?;
1984    let mut age_entry = Array1::<f64>::zeros(n);
1985    let mut age_exit = Array1::<f64>::zeros(n);
1986    for (i, (t0, t1)) in pairs.into_iter().enumerate() {
1987        age_entry[i] = t0;
1988        age_exit[i] = t1;
1989    }
1990
1991    let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
1992
1993    // Latent modes emit binary event-window probabilities, not survival
1994    // curves. The CLI's `run_predict_saved_latent_*` helpers wrap them with
1995    // window quadrature + uncertainty pipelines that aren't ported yet.
1996    if matches!(
1997        saved_likelihood_mode,
1998        SurvivalLikelihoodMode::Latent | SurvivalLikelihoodMode::LatentBinary
1999    ) {
2000        return Err(SurvivalPredictError::UnsupportedConfiguration {
2001            reason: format!(
2002                "survival prediction via predict_survival does not support likelihood_mode={} yet; \
2003             latent window prediction lives in the CLI's run_predict_saved_latent_window_impl \
2004             pipeline and has not yet been ported to the library. Use the CLI predict command.",
2005                survival_likelihood_modename(saved_likelihood_mode)
2006            ),
2007        });
2008    }
2009    // Location-scale: handled via a dedicated batch path that calls
2010    // `predict_survival_location_scale` directly.
2011    if saved_likelihood_mode == SurvivalLikelihoodMode::LocationScale {
2012        return predict_survival_location_scale_batch(
2013            model,
2014            &age_entry,
2015            &age_exit,
2016            &cov_design,
2017            &effective_primary_offset,
2018            noise_offset,
2019            training_headers,
2020            col_map,
2021            data,
2022            time_grid,
2023            with_uncertainty,
2024            covariance_mode,
2025        )
2026        .map_err(SurvivalPredictError::from);
2027    }
2028    if with_uncertainty {
2029        return Err(SurvivalPredictError::from(format!(
2030            "predict_survival: with_uncertainty is currently supported only for the \
2031             location-scale likelihood mode; got {}",
2032            survival_likelihood_modename(saved_likelihood_mode)
2033        )));
2034    }
2035
2036    // Ambient time basis: built once with (age_entry, age_exit) so that
2037    // the saved anchor / monotonicity checks fire at construction time.
2038    let time_cfg = load_survival_time_basis_config_from_model(model)?;
2039    let mut time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
2040    let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
2041        &time_build.basisname,
2042        time_build.degree,
2043        time_build.knots.as_ref(),
2044        time_build.keep_cols.as_ref(),
2045        time_build.smooth_lambda,
2046    )?;
2047    // Single-cause Weibull without a learned baseline timewiggle carries its
2048    // ENTIRE log-cumulative-hazard baseline in the fitted `[1, log t]` linear
2049    // time-basis coefficients, not in a parametric offset. The fit centers that
2050    // basis at the survival time anchor (`center_survival_time_designs_at_anchor`
2051    // in the workflow), which zeroes the constant column so `beta[0]` is
2052    // unidentified and the fitted baseline is exactly
2053    // `beta[1] * (log t - log anchor)`. The model still SAVES a `Weibull`
2054    // baseline target (recovered scale/shape) for CIF/reporting, but that
2055    // metadata must NOT re-enter prediction as a parametric offset: doing so
2056    // double-counts the baseline (offset + beta) and, combined with predicting
2057    // against the UN-centered basis, collapses the survival surface to the
2058    // degenerate `S(t) == 1` (issue #897). Mirror the fit here: center the basis
2059    // at the anchor and carry a zero baseline offset, so predict reproduces the
2060    // fitted `beta[1] * (log t - log anchor)`. Weibull-WITH-timewiggle is a
2061    // different regime (the parametric offset is the baseline and beta carries
2062    // only the wiggle deviation), so it is excluded.
2063    let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
2064        && !model.has_baseline_time_wiggle();
2065    // The FIT centers the time design at the anchor UNCONDITIONALLY — every
2066    // front end routes through `center_survival_time_designs_at_anchor`
2067    // immediately after building the basis, for every likelihood mode
2068    // (`fit_orchestration::materialize::survival`, `gam-cli`'s survival path) —
2069    // so the saved `beta_time` are the coefficients of the CENTERED design.
2070    // Predict has to ask the same question, and it asks it of the same object:
2071    // the anchor the fit used is saved on the model.
2072    //
2073    // This used to be gated on an enumerated mode list (`LocationScale |
2074    // MarginalSlope`, plus bare Weibull), and the list silently omitted
2075    // `Transformation` — the Royston-Parmar default. Evaluating centered
2076    // coefficients against an uncentered basis shifts every reported
2077    // `log Λ(t)` by the CONSTANT `X(anchor)ᵀγ`, which is why the defect is
2078    // invisible on ordinary right-censored data: the anchor there is the
2079    // earliest entry, i.e. the time origin, where `I_k(left) = 0` exactly and
2080    // the shift is zero. It appears the moment the anchor moves — on any
2081    // genuinely left-truncated dataset, which takes the robust interior anchor
2082    // by rule (#751/#1790/#2631), and on any explicit `--survival-time-anchor`.
2083    //
2084    // Measured on a 1200-row `Surv(entry, exit, event) ~ s(x)` fit with hazard
2085    // `0.4·exp(0.9x)` (gam#2705): the same data fitted at anchor `1e-7` and at
2086    // anchor `1.19` reaches the SAME maximised log-likelihood to seven digits
2087    // (`-1.364394e3`, so the fit is invariant exactly as the anchor rule
2088    // documents), while the predicted `η` differs by `+2.859821842` at every
2089    // one of six (time, covariate) pairs — a constant, to eight digits, and a
2090    // factor `e^2.86 = 17.5` on the reported cumulative hazard.
2091    let mut time_anchor: Option<f64> = None;
2092    let mut time_anchor_row_cached: Option<Array1<f64>> = None;
2093    if time_build.x_exit_time.ncols() > 0 {
2094        let anchor = model
2095            .survival_time_anchor
2096            .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
2097        let time_anchor_row = evaluate_survival_time_basis_row(anchor, &resolved_time_cfg)?;
2098        center_survival_time_designs_at_anchor(
2099            &mut time_build.x_entry_time,
2100            &mut time_build.x_exit_time,
2101            &time_anchor_row,
2102        )?;
2103        time_anchor = Some(anchor);
2104        time_anchor_row_cached = Some(time_anchor_row);
2105    }
2106    if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
2107    {
2108        require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
2109    }
2110    let mut baseline_cfg = saved_survival_runtime_baseline_config(model)?;
2111    if weibull_baseline_in_beta {
2112        baseline_cfg = SurvivalBaselineConfig {
2113            target: SurvivalBaselineTarget::Linear,
2114            scale: None,
2115            shape: None,
2116            rate: None,
2117            makeham: None,
2118        };
2119    }
2120
2121    // Resolve the time-grid: either the explicit grid (same for every
2122    // row) or per-row exit times (one column per row).
2123    let per_row_eval = time_grid.is_none();
2124    let eval_times: Vec<f64> = match time_grid {
2125        Some(grid) => {
2126            if grid.is_empty() {
2127                return Err(SurvivalPredictError::InvalidInput {
2128                    reason: "survival time_grid must contain at least one time".to_string(),
2129                });
2130            }
2131            for (idx, &t) in grid.iter().enumerate() {
2132                if !t.is_finite() || t < 0.0 {
2133                    return Err(SurvivalPredictError::InvalidInput {
2134                        reason: format!(
2135                            "survival time_grid requires finite non-negative times (index {idx})",
2136                        ),
2137                    });
2138                }
2139            }
2140            grid.to_vec()
2141        }
2142        None => Vec::new(),
2143    };
2144
2145    let t_cols = if per_row_eval { 1 } else { eval_times.len() };
2146    let mut hazard = Array2::<f64>::zeros((n, t_cols));
2147    let mut survival = Array2::<f64>::zeros((n, t_cols));
2148    let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
2149    let mut linear_predictor = Array1::<f64>::zeros(n);
2150
2151    // For marginal-slope, build the saved predictor (with link-deviation +
2152    // score-warp blocks plumbed in) once. The per-(row, t) loop reuses this
2153    // predictor and only assembles the per-cell q-design slice. Without this,
2154    // the library skipped link-deviation and score-warp replay entirely and
2155    // disagreed with the CLI's `gam predict` on every flex model.
2156    let marginal_slope_ctx = if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
2157        // Baseline offsets at the predict-data's age_entry / age_exit. Used to
2158        // build the predictor's `pred_input` (which we discard) — the actual
2159        // per-(row, t) offset is rebuilt inside `evaluate_marginal_slope_row`.
2160        let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
2161            build_survival_time_offsets_for_likelihood(
2162                &age_entry,
2163                &age_exit,
2164                &baseline_cfg,
2165                saved_likelihood_mode,
2166                None,
2167            )?;
2168        add_survival_time_derivative_guard_offset(
2169            &age_entry,
2170            &age_exit,
2171            time_anchor.ok_or_else(|| {
2172                "saved survival marginal-slope model missing survival_time_anchor".to_string()
2173            })?,
2174            survival_derivative_guard_for_likelihood(saved_likelihood_mode),
2175            &mut eta_offset_entry,
2176            &mut eta_offset_exit,
2177            &mut derivative_offset_exit,
2178        )?;
2179        Some(build_marginal_slope_predict_context(
2180            model,
2181            data,
2182            col_map,
2183            training_headers,
2184            &cov_design.design,
2185            &effective_primary_offset,
2186            noise_offset,
2187            &time_build,
2188            &eta_offset_entry,
2189            &eta_offset_exit,
2190            &derivative_offset_exit,
2191            &age_exit,
2192        )?)
2193    } else {
2194        None
2195    };
2196
2197    // Evaluate each row independently.  For an explicit time grid, each worker
2198    // reuses the row's covariate slice across all grid times and returns a
2199    // complete row, avoiding synchronized writes into the output matrices.
2200    struct SurvivalPredictionRow {
2201        hazard: Vec<f64>,
2202        survival: Vec<f64>,
2203        cumulative_hazard: Vec<f64>,
2204        linear_predictor: f64,
2205    }
2206
2207    let row_results: Result<Vec<SurvivalPredictionRow>, SurvivalPredictError> = (0..n)
2208        .into_par_iter()
2209        .map(|i| {
2210            let cov_row = if matches!(
2211                saved_likelihood_mode,
2212                SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
2213            ) {
2214                Some(design_row_owned(
2215                    &cov_design.design,
2216                    i,
2217                    "survival predict covariate row",
2218                )?)
2219            } else {
2220                None
2221            };
2222            let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
2223                let t_entry = age_entry[i].min(t_query);
2224                let single_entry = Array1::from_elem(1, t_entry);
2225                let single_exit = Array1::from_elem(1, t_query);
2226                let mut row_time =
2227                    build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
2228                if let Some(anchor_row) = time_anchor_row_cached.as_ref() {
2229                    center_survival_time_designs_at_anchor(
2230                        &mut row_time.x_entry_time,
2231                        &mut row_time.x_exit_time,
2232                        anchor_row,
2233                    )?;
2234                }
2235                let (mut r_eta_entry, mut r_eta_exit, mut r_deriv_exit) =
2236                    build_survival_time_offsets_for_likelihood(
2237                        &single_entry,
2238                        &single_exit,
2239                        &baseline_cfg,
2240                        saved_likelihood_mode,
2241                        None,
2242                    )?;
2243                if saved_likelihood_mode == SurvivalLikelihoodMode::MarginalSlope {
2244                    add_survival_time_derivative_guard_offset(
2245                        &single_entry,
2246                        &single_exit,
2247                        time_anchor.ok_or_else(|| {
2248                            "saved survival marginal-slope model missing survival_time_anchor"
2249                                .to_string()
2250                        })?,
2251                        survival_derivative_guard_for_likelihood(saved_likelihood_mode),
2252                        &mut r_eta_entry,
2253                        &mut r_eta_exit,
2254                        &mut r_deriv_exit,
2255                    )?;
2256                }
2257
2258                match saved_likelihood_mode {
2259                    SurvivalLikelihoodMode::MarginalSlope => {
2260                        let ctx = marginal_slope_ctx.as_ref().ok_or_else(|| {
2261                            "internal error: marginal-slope context missing for marginal-slope mode"
2262                                .to_string()
2263                        })?;
2264                        evaluate_marginal_slope_row(
2265                            i,
2266                            ctx,
2267                            &row_time,
2268                            &r_eta_exit,
2269                            &r_deriv_exit,
2270                            effective_primary_offset[i],
2271                            t_query,
2272                        )
2273                    }
2274                    SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull => {
2275                        let cov_row = cov_row.as_ref().ok_or_else(|| {
2276                            "internal error: covariate row missing for Royston-Parmar prediction"
2277                                .to_string()
2278                        })?;
2279                        evaluate_rp_row(
2280                            model,
2281                            &row_time,
2282                            cov_row,
2283                            r_eta_exit[0],
2284                            r_deriv_exit[0],
2285                            effective_primary_offset[i],
2286                        )
2287                    }
2288                    SurvivalLikelihoodMode::Latent
2289                    | SurvivalLikelihoodMode::LatentBinary
2290                    | SurvivalLikelihoodMode::LocationScale => {
2291                        Err(SurvivalPredictError::NumericalFailure {
2292                            reason: "unreachable: unsupported likelihood_mode filtered earlier"
2293                                .to_string(),
2294                        })
2295                    }
2296                }
2297            };
2298
2299            let mut row = SurvivalPredictionRow {
2300                hazard: vec![0.0; t_cols],
2301                survival: vec![0.0; t_cols],
2302                cumulative_hazard: vec![0.0; t_cols],
2303                linear_predictor: 0.0,
2304            };
2305            if per_row_eval {
2306                let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
2307                row.linear_predictor = eta_t;
2308                row.hazard[0] = haz_t;
2309                row.cumulative_hazard[0] = cum_t;
2310                row.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
2311            } else {
2312                for (j, &t_query) in eval_times.iter().enumerate() {
2313                    if t_query <= 0.0 {
2314                        row.hazard[j] = 0.0;
2315                        row.cumulative_hazard[j] = 0.0;
2316                        row.survival[j] = 1.0;
2317                    } else {
2318                        let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
2319                        row.hazard[j] = haz_t;
2320                        row.cumulative_hazard[j] = cum_t;
2321                        row.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
2322                    }
2323                }
2324                let (eta_t, _, _) = evaluate_at(age_exit[i])?;
2325                row.linear_predictor = eta_t;
2326            }
2327            Ok(row)
2328        })
2329        .collect();
2330
2331    for (i, row) in row_results?.into_iter().enumerate() {
2332        linear_predictor[i] = row.linear_predictor;
2333        for j in 0..t_cols {
2334            hazard[[i, j]] = row.hazard[j];
2335            cumulative_hazard[[i, j]] = row.cumulative_hazard[j];
2336            survival[[i, j]] = row.survival[j];
2337        }
2338    }
2339
2340    let times_out: Vec<f64> = if per_row_eval {
2341        age_exit.to_vec()
2342    } else {
2343        eval_times
2344    };
2345
2346    Ok(SurvivalPredictResult {
2347        times: times_out,
2348        hazard,
2349        survival,
2350        cumulative_hazard,
2351        linear_predictor,
2352        likelihood_mode: saved_likelihood_mode,
2353        survival_se: None,
2354        eta_se: None,
2355        covariance_source: None,
2356    })
2357}
2358
2359pub fn predict_competing_risks_survival(
2360    req: SurvivalPredictRequest<'_>,
2361    covariance_mode: SurvivalPredictionCovarianceMode,
2362) -> Result<CompetingRisksPredictResult, SurvivalPredictError> {
2363    if req.estimand == SurvivalPredictEstimand::PosteriorMean || req.with_uncertainty {
2364        return predict_competing_risks_with_posterior(req, covariance_mode);
2365    }
2366    let SurvivalPredictRequest {
2367        model,
2368        data,
2369        col_map,
2370        training_headers,
2371        primary_offset,
2372        noise_offset,
2373        time_grid,
2374        with_uncertainty: _,
2375        estimand: _,
2376    } = req;
2377
2378    let saved_likelihood_mode = require_saved_survival_likelihood_mode(model)?;
2379    if !matches!(
2380        saved_likelihood_mode,
2381        SurvivalLikelihoodMode::Transformation | SurvivalLikelihoodMode::Weibull
2382    ) {
2383        return Err(SurvivalPredictError::UnsupportedConfiguration {
2384            reason: format!(
2385                "joint cause-specific prediction supports transformation/weibull survival only; got {}",
2386                survival_likelihood_modename(saved_likelihood_mode)
2387            ),
2388        });
2389    }
2390
2391    let fit = fit_result_from_saved_model_for_prediction(model)?;
2392    let cause_count = model
2393        .survival_cause_count
2394        .unwrap_or(fit.blocks.len())
2395        .max(1);
2396    if cause_count <= 1 {
2397        return Err(SurvivalPredictError::MissingFitMetadata {
2398            reason: "competing-risks survival prediction requires a saved model with at least two causes"
2399                .to_string(),
2400        });
2401    }
2402    if fit.blocks.len() != cause_count {
2403        return Err(SurvivalPredictError::IncompatibleSchema {
2404            reason: format!(
2405                "saved competing-risks survival fit has {} coefficient blocks but metadata says {cause_count} causes",
2406                fit.blocks.len()
2407            ),
2408        });
2409    }
2410    let endpoint_names = model.survival_endpoint_names.clone().unwrap_or_else(|| {
2411        (1..=cause_count)
2412            .map(|idx| format!("cause_{idx}"))
2413            .collect()
2414    });
2415    if endpoint_names.len() != cause_count {
2416        return Err(SurvivalPredictError::IncompatibleSchema {
2417            reason: format!(
2418                "saved competing-risks survival endpoint_names has length {}, expected {cause_count}",
2419                endpoint_names.len()
2420            ),
2421        });
2422    }
2423
2424    // Right-censored shorthand: same fallback as the single-cause path
2425    // above — entry ages default to zero when the model was fit without
2426    // an explicit entry column.
2427    let time_cols = resolve_saved_survival_time_columns(model, col_map)?;
2428    let exit_col = time_cols.exit_col;
2429
2430    let termspec = resolve_termspec_for_prediction(
2431        &model.resolved_termspec,
2432        training_headers,
2433        col_map,
2434        "resolved_termspec",
2435    )?;
2436    let cov_clipped = model.axis_clip_to_training_ranges(data, col_map);
2437    let cov_input = cov_clipped.as_ref().map_or(data, |arr| arr.view());
2438    let cov_design = build_term_collection_design(cov_input, &termspec)
2439        .map_err(|e| format!("failed to build competing-risks prediction design: {e}"))?;
2440
2441    let n = data.nrows();
2442    if primary_offset.len() != n || noise_offset.len() != n {
2443        return Err(SurvivalPredictError::InvalidInput {
2444            reason: format!(
2445                "competing-risks prediction offset length mismatch: rows={n}, offset={}, noise_offset={}",
2446                primary_offset.len(),
2447                noise_offset.len()
2448            ),
2449        });
2450    }
2451    let effective_primary_offset = cov_design
2452        .compose_offset(
2453            primary_offset.view(),
2454            "competing-risks prediction covariate block",
2455        )
2456        .map_err(|error| error.to_string())?;
2457
2458    use rayon::iter::{IntoParallelIterator, ParallelIterator};
2459    let pairs: Result<Vec<(f64, f64)>, String> = (0..n)
2460        .into_par_iter()
2461        .map(|i| {
2462            normalize_survival_time_pair(time_cols.row_entry_time(data, i), data[[i, exit_col]], i)
2463        })
2464        .collect();
2465    let pairs = pairs?;
2466    let mut age_entry = Array1::<f64>::zeros(n);
2467    let mut age_exit = Array1::<f64>::zeros(n);
2468    for (i, (t0, t1)) in pairs.into_iter().enumerate() {
2469        age_entry[i] = t0;
2470        age_exit[i] = t1;
2471    }
2472
2473    let time_cfg = load_survival_time_basis_config_from_model(model)?;
2474    let time_build = build_survival_time_basis(&age_entry, &age_exit, time_cfg.clone(), None)?;
2475    let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
2476        &time_build.basisname,
2477        time_build.degree,
2478        time_build.knots.as_ref(),
2479        time_build.keep_cols.as_ref(),
2480        time_build.smooth_lambda,
2481    )?;
2482    // See the single-cause `predict_survival` note: per-cause Weibull baselines
2483    // (no learned timewiggle) live in the anchor-centered linear time-basis
2484    // coefficients, so prediction must center the basis at the saved anchor and
2485    // carry a zero parametric baseline offset rather than re-adding the saved
2486    // (reporting-only) `Weibull` target as an offset (issues #897 / #689 / #690).
2487    // The ambient `time_build` is consumed only for the structural-basis check;
2488    // the per-(cause, row) loop rebuilds and centers its own `row_time`, so the
2489    // anchor row is all that needs threading through.
2490    let weibull_baseline_in_beta = saved_likelihood_mode == SurvivalLikelihoodMode::Weibull
2491        && !model.has_baseline_time_wiggle();
2492    // Unconditional, for the reason given at the single-cause site: the fit
2493    // centers every time design at the anchor, so the saved coefficients are
2494    // the centered ones and a per-cause Royston-Parmar baseline predicted
2495    // against an uncentered basis carries the constant `X(anchor)ᵀγ` shift
2496    // (gam#2705).
2497    let cr_time_anchor_row: Option<Array1<f64>> = if time_build.x_exit_time.ncols() > 0 {
2498        let anchor = model
2499            .survival_time_anchor
2500            .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
2501        Some(evaluate_survival_time_basis_row(
2502            anchor,
2503            &resolved_time_cfg,
2504        )?)
2505    } else {
2506        None
2507    };
2508    if saved_likelihood_mode != SurvivalLikelihoodMode::Weibull && !model.has_baseline_time_wiggle()
2509    {
2510        require_structural_survival_time_basis(
2511            &time_build.basisname,
2512            "saved competing-risks survival prediction",
2513        )?;
2514    }
2515    let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
2516
2517    let per_row_eval = time_grid.is_none();
2518    let eval_times: Vec<f64> = match time_grid {
2519        Some(grid) => {
2520            if grid.is_empty() {
2521                return Err(SurvivalPredictError::InvalidInput {
2522                    reason: "survival time_grid must contain at least one time".to_string(),
2523                });
2524            }
2525            for (idx, &t) in grid.iter().enumerate() {
2526                if !t.is_finite() || t < 0.0 {
2527                    return Err(SurvivalPredictError::InvalidInput {
2528                        reason: format!(
2529                            "survival time_grid requires finite non-negative times (index {idx})",
2530                        ),
2531                    });
2532                }
2533            }
2534            grid.to_vec()
2535        }
2536        None => Vec::new(),
2537    };
2538    let t_cols = if per_row_eval { 1 } else { eval_times.len() };
2539
2540    // Refined internal grid for the Aalen-Johansen CIF assembly (gam#1385).
2541    //
2542    // The discrete AJ increment ΔF_k = S(t_{j-1})·(1−exp(−ΔH_total))·ΔH_k/ΔH_total
2543    // assumes the cause-specific hazard *ratio* h_k/h_total is constant within
2544    // each interval. On a coarse user grid with differently-shaped competing
2545    // hazards that assumption is violated, making the returned CIF a function of
2546    // the requested grid resolution (up to ~22% pointwise error) rather than a
2547    // pure function of the query time. We assemble AJ on a refined grid (extra
2548    // points inserted from 0 to the first user time and between consecutive user
2549    // times — cause-specific cumulative hazards are cheap closed-form
2550    // evaluate_at calls) and then read CIF/overall-survival back at the user's
2551    // requested times. The per-cause hazard/survival/cumulative_hazard returned
2552    // to the caller stay on the user grid (those are pointwise and already
2553    // grid-independent); only the AJ assembly uses the refinement.
2554    //
2555    // `refined_times` is strictly increasing and is a superset of `eval_times`;
2556    // `user_time_to_refined_index[j]` is the position of the j-th user time
2557    // inside `refined_times`. Per-row eval keeps its single-time anchor path.
2558    const CIF_REFINE_SUBINTERVALS: usize = 32;
2559    let (refined_times, user_time_to_refined_index): (Vec<f64>, Vec<usize>) = if per_row_eval {
2560        (Vec::new(), Vec::new())
2561    } else {
2562        // The user grid may arrive in any order (and contain duplicates); the
2563        // AJ recurrence is a time-ordered prefix integral, so the refinement
2564        // walks the SORTED times and maps every user position back to its
2565        // refined index. Walking an unsorted grid directly is not merely
2566        // inaccurate: a decreasing grid produces negative gaps, skips the
2567        // fill, and silently maps later user times onto the wrong refined
2568        // column (e.g. grid [2, 1] returned the t=2 CIF for both queries).
2569        let mut order: Vec<usize> = (0..eval_times.len()).collect();
2570        order.sort_by(|&a, &b| {
2571            eval_times[a]
2572                .partial_cmp(&eval_times[b])
2573                .expect("survival time_grid entries are validated finite above")
2574        });
2575        let mut refined: Vec<f64> = Vec::new();
2576        let mut user_index: Vec<usize> = vec![0; eval_times.len()];
2577        let mut prev = 0.0_f64;
2578        for &j_user in &order {
2579            let t_user = eval_times[j_user];
2580            // Insert CIF_REFINE_SUBINTERVALS-1 strictly-interior points in
2581            // (prev, t_user], landing exactly on t_user as the last point. Skip
2582            // the interior fill for a zero-length gap (duplicate / origin user
2583            // time) so `refined` stays strictly increasing.
2584            let gap = t_user - prev;
2585            if gap > 0.0 {
2586                for s in 1..CIF_REFINE_SUBINTERVALS {
2587                    let t_mid = prev + gap * (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
2588                    // Guard against ties from floating-point rounding.
2589                    if refined.last().is_none_or(|&last| t_mid > last) {
2590                        refined.push(t_mid);
2591                    }
2592                }
2593            }
2594            if refined.last().is_none_or(|&last| t_user > last) {
2595                refined.push(t_user);
2596            }
2597            user_index[j_user] = refined.len() - 1;
2598            prev = t_user;
2599        }
2600        (refined, user_index)
2601    };
2602    // Per-row eval integrates each row's CIF on its own refined [0, age_exit]
2603    // subdivision (normalized-fraction grid; see the assembly step below).
2604    let refined_cols = if per_row_eval {
2605        CIF_REFINE_SUBINTERVALS
2606    } else {
2607        refined_times.len()
2608    };
2609
2610    let saved_timewiggle_by_cause = saved_cause_specific_timewiggles(model, &fit, cause_count)?;
2611    let cov_rows = (0..n)
2612        .map(|i| design_row_owned(&cov_design.design, i, "competing-risks covariate row"))
2613        .collect::<Result<Vec<_>, _>>()?;
2614
2615    let mut hazard = (0..cause_count)
2616        .map(|_| Array2::<f64>::zeros((n, t_cols)))
2617        .collect::<Vec<_>>();
2618    let mut survival = (0..cause_count)
2619        .map(|_| Array2::<f64>::zeros((n, t_cols)))
2620        .collect::<Vec<_>>();
2621    let mut cumulative_hazard = (0..cause_count)
2622        .map(|_| Array2::<f64>::zeros((n, t_cols)))
2623        .collect::<Vec<_>>();
2624    // Cause-specific cumulative hazards on the refined AJ grid (gam#1385);
2625    // unused (zero-width) on the per-row-eval path.
2626    let mut cumulative_hazard_refined = (0..cause_count)
2627        .map(|_| Array2::<f64>::zeros((n, refined_cols)))
2628        .collect::<Vec<_>>();
2629    let mut linear_predictor = (0..cause_count)
2630        .map(|_| Array1::<f64>::zeros(n))
2631        .collect::<Vec<_>>();
2632
2633    struct CauseRow {
2634        cause: usize,
2635        row: usize,
2636        hazard: Vec<f64>,
2637        survival: Vec<f64>,
2638        cumulative: Vec<f64>,
2639        /// Cumulative hazard on the refined AJ grid (gam#1385); empty on the
2640        /// per-row-eval path.
2641        cumulative_refined: Vec<f64>,
2642        eta_exit: f64,
2643    }
2644
2645    let rows: Result<Vec<CauseRow>, SurvivalPredictError> = (0..cause_count * n)
2646        .into_par_iter()
2647        .map(|flat| {
2648            let cause = flat / n;
2649            let i = flat % n;
2650            let block = &fit.blocks[cause];
2651            let timewiggle = saved_timewiggle_by_cause[cause].as_ref();
2652            let evaluate_at = |t_query: f64| -> Result<(f64, f64, f64), SurvivalPredictError> {
2653                let t_entry = age_entry[i].min(t_query);
2654                let single_entry = Array1::from_elem(1, t_entry);
2655                let single_exit = Array1::from_elem(1, t_query);
2656                let mut row_time =
2657                    build_survival_time_basis(&single_entry, &single_exit, time_cfg.clone(), None)?;
2658                if let Some(anchor_row) = cr_time_anchor_row.as_ref() {
2659                    center_survival_time_designs_at_anchor(
2660                        &mut row_time.x_entry_time,
2661                        &mut row_time.x_exit_time,
2662                        anchor_row,
2663                    )?;
2664                }
2665                let (r_eta_exit, r_deriv_exit) = if weibull_baseline_in_beta {
2666                    (0.0, 0.0)
2667                } else {
2668                    let (_, eta_exit, deriv_exit) = build_survival_time_offsets_for_likelihood(
2669                        &single_entry,
2670                        &single_exit,
2671                        &baseline_cfg,
2672                        saved_likelihood_mode,
2673                        None,
2674                    )?;
2675                    (eta_exit[0], deriv_exit[0])
2676                };
2677                evaluate_rp_row_with_beta(
2678                    &block.beta,
2679                    timewiggle,
2680                    &row_time,
2681                    &cov_rows[i],
2682                    r_eta_exit,
2683                    r_deriv_exit,
2684                    effective_primary_offset[i],
2685                )
2686            };
2687
2688            let mut out = CauseRow {
2689                cause,
2690                row: i,
2691                hazard: vec![0.0; t_cols],
2692                survival: vec![0.0; t_cols],
2693                cumulative: vec![0.0; t_cols],
2694                cumulative_refined: vec![0.0; refined_cols],
2695                eta_exit: 0.0,
2696            };
2697            if per_row_eval {
2698                let (eta_t, cum_t, haz_t) = evaluate_at(age_exit[i])?;
2699                out.eta_exit = eta_t;
2700                out.hazard[0] = haz_t;
2701                out.cumulative[0] = cum_t;
2702                out.survival[0] = (-cum_t).exp().clamp(0.0, 1.0);
2703                // Cause-specific cumulative hazards on this row's refined
2704                // [0, age_exit] subdivision for the time-ordered AJ assembly.
2705                // A single-interval assembly splits the CIF by ENDPOINT
2706                // cumulative-hazard proportions, which is exact only when the
2707                // cause-specific hazard ratio is constant in time; the CIF is
2708                // the time-ordered integral ∫ S(u−) dH_k(u) (gam#1385).
2709                for s in 1..=CIF_REFINE_SUBINTERVALS {
2710                    let frac = (s as f64) / (CIF_REFINE_SUBINTERVALS as f64);
2711                    let t_query = age_exit[i] * frac;
2712                    out.cumulative_refined[s - 1] = if t_query <= 0.0 {
2713                        0.0
2714                    } else if s == CIF_REFINE_SUBINTERVALS {
2715                        // frac == 1 exactly: reuse the exit evaluation so the
2716                        // assembled CIF and the reported cumulative hazard
2717                        // agree to the bit.
2718                        cum_t
2719                    } else {
2720                        evaluate_at(t_query)?.1
2721                    };
2722                }
2723            } else {
2724                for (j, &t_query) in eval_times.iter().enumerate() {
2725                    // Mirror the single-cause origin guard: every subject is
2726                    // alive at the time origin, so S(0)=1, H(0)=0, h(0)=0.
2727                    // Without this, the time basis floors t=0 to
2728                    // SURVIVAL_TIME_FLOOR and returns a nonzero hazard, which
2729                    // would anchor the Aalen-Johansen CIF assembly on a
2730                    // non-unit S(0) and bias every downstream value.
2731                    if t_query <= 0.0 {
2732                        out.hazard[j] = 0.0;
2733                        out.cumulative[j] = 0.0;
2734                        out.survival[j] = 1.0;
2735                    } else {
2736                        let (_eta_t, cum_t, haz_t) = evaluate_at(t_query)?;
2737                        out.hazard[j] = haz_t;
2738                        out.cumulative[j] = cum_t;
2739                        out.survival[j] = (-cum_t).exp().clamp(0.0, 1.0);
2740                    }
2741                }
2742                // Refined-grid cumulative hazards for the AJ CIF assembly
2743                // (gam#1385). Same closed-form evaluate_at; reuse the exact
2744                // user-grid values at the points that coincide so the returned
2745                // per-cause cumulative_hazard and the assembly agree at the user
2746                // times to the bit.
2747                for (jr, &t_query) in refined_times.iter().enumerate() {
2748                    out.cumulative_refined[jr] = if t_query <= 0.0 {
2749                        0.0
2750                    } else {
2751                        evaluate_at(t_query)?.1
2752                    };
2753                }
2754                let (eta_t, _, _) = evaluate_at(age_exit[i])?;
2755                out.eta_exit = eta_t;
2756            }
2757            Ok(out)
2758        })
2759        .collect();
2760
2761    for row in rows? {
2762        linear_predictor[row.cause][row.row] = row.eta_exit;
2763        for j in 0..t_cols {
2764            hazard[row.cause][[row.row, j]] = row.hazard[j];
2765            survival[row.cause][[row.row, j]] = row.survival[j];
2766            cumulative_hazard[row.cause][[row.row, j]] = row.cumulative[j];
2767        }
2768        for jr in 0..refined_cols {
2769            cumulative_hazard_refined[row.cause][[row.row, jr]] = row.cumulative_refined[jr];
2770        }
2771    }
2772
2773    // Assemble the Aalen-Johansen CIF on the refined grid (gam#1385), then read
2774    // the result back at the user-requested times so the CIF is grid-resolution
2775    // independent.
2776    let assembled = if per_row_eval {
2777        // Each row was integrated on its own normalized subdivision
2778        // t = age_exit·s/K. The AJ recurrence consumes only the time-ORDERED
2779        // cumulative-hazard values (the time stamps enter validation, never
2780        // the arithmetic), so a shared fraction grid s/K is an exact
2781        // parameterization of every row's [0, age_exit]; the row's CIF at its
2782        // exit time is the final column.
2783        let assembly_times = Array1::from_shape_fn(CIF_REFINE_SUBINTERVALS, |s| {
2784            ((s + 1) as f64) / (CIF_REFINE_SUBINTERVALS as f64)
2785        });
2786        let refined_assembled = assemble_competing_risks_cif_from_endpoints(
2787            assembly_times.view(),
2788            &cumulative_hazard_refined,
2789        )
2790        .map_err(|err| err.to_string())?;
2791        let last = CIF_REFINE_SUBINTERVALS - 1;
2792        let mut cif_user = (0..cause_count)
2793            .map(|_| Array2::<f64>::zeros((n, 1)))
2794            .collect::<Vec<_>>();
2795        let mut overall_user = Array2::<f64>::zeros((n, 1));
2796        for cause in 0..cause_count {
2797            for row in 0..n {
2798                cif_user[cause][[row, 0]] = refined_assembled.cif[cause][[row, last]];
2799            }
2800        }
2801        for row in 0..n {
2802            overall_user[[row, 0]] = refined_assembled.overall_survival[[row, last]];
2803        }
2804        CompetingRisksCifResult {
2805            cif: cif_user,
2806            overall_survival: overall_user,
2807        }
2808    } else {
2809        let assembly_times = Array1::from_vec(refined_times.clone());
2810        let refined_assembled = assemble_competing_risks_cif_from_endpoints(
2811            assembly_times.view(),
2812            &cumulative_hazard_refined,
2813        )
2814        .map_err(|err| err.to_string())?;
2815        // Project refined CIF / overall-survival columns onto the user grid.
2816        let mut cif_user = (0..cause_count)
2817            .map(|_| Array2::<f64>::zeros((n, t_cols)))
2818            .collect::<Vec<_>>();
2819        let mut overall_user = Array2::<f64>::zeros((n, t_cols));
2820        for (j_user, &jr) in user_time_to_refined_index.iter().enumerate() {
2821            for cause in 0..cause_count {
2822                for row in 0..n {
2823                    cif_user[cause][[row, j_user]] = refined_assembled.cif[cause][[row, jr]];
2824                }
2825            }
2826            for row in 0..n {
2827                overall_user[[row, j_user]] = refined_assembled.overall_survival[[row, jr]];
2828            }
2829        }
2830        CompetingRisksCifResult {
2831            cif: cif_user,
2832            overall_survival: overall_user,
2833        }
2834    };
2835    if assembled.cif.len() != cause_count {
2836        return Err(format!(
2837            "competing-risks CIF assembly produced {} endpoint matrices, expected {cause_count}",
2838            assembled.cif.len()
2839        )
2840        .into());
2841    }
2842    let cif = assembled.cif;
2843    let overall_survival = assembled.overall_survival;
2844    let times_out = if per_row_eval {
2845        age_exit.to_vec()
2846    } else {
2847        eval_times
2848    };
2849    Ok(CompetingRisksPredictResult {
2850        times: times_out,
2851        endpoint_names,
2852        hazard,
2853        survival,
2854        cumulative_hazard,
2855        cif,
2856        overall_survival,
2857        linear_predictor,
2858        likelihood_mode: saved_likelihood_mode,
2859        covariance_source: None,
2860        hazard_se: None,
2861        survival_se: None,
2862        cumulative_hazard_se: None,
2863        cif_se: None,
2864        overall_survival_se: None,
2865        eta_se: None,
2866    })
2867}
2868
2869fn saved_cause_specific_timewiggles(
2870    model: &SavedModel,
2871    fit: &UnifiedFitResult,
2872    cause_count: usize,
2873) -> Result<Vec<Option<SavedBaselineTimeWiggleRuntime>>, SurvivalPredictError> {
2874    let has_metadata = model.baseline_timewiggle_knots.is_some()
2875        || model.baseline_timewiggle_degree.is_some()
2876        || model.baseline_timewiggle_penalty_orders.is_some()
2877        || model.baseline_timewiggle_double_penalty.is_some()
2878        || model.beta_baseline_timewiggle_by_cause.is_some();
2879    if !has_metadata {
2880        return Ok(vec![None; cause_count]);
2881    }
2882    let knots = model.baseline_timewiggle_knots.clone().ok_or_else(|| {
2883        "joint cause-specific survival missing baseline_timewiggle_knots".to_string()
2884    })?;
2885    let degree = model.baseline_timewiggle_degree.ok_or_else(|| {
2886        "joint cause-specific survival missing baseline_timewiggle_degree".to_string()
2887    })?;
2888    let penalty_orders = model
2889        .baseline_timewiggle_penalty_orders
2890        .clone()
2891        .ok_or_else(|| {
2892            "joint cause-specific survival missing baseline_timewiggle_penalty_orders".to_string()
2893        })?;
2894    let double_penalty = model.baseline_timewiggle_double_penalty.ok_or_else(|| {
2895        "joint cause-specific survival missing baseline_timewiggle_double_penalty".to_string()
2896    })?;
2897    let by_cause = model
2898        .beta_baseline_timewiggle_by_cause
2899        .as_ref()
2900        .ok_or_else(|| {
2901            "joint cause-specific survival missing beta_baseline_timewiggle_by_cause".to_string()
2902        })?;
2903    if by_cause.len() != cause_count {
2904        return Err(SurvivalPredictError::IncompatibleSchema {
2905            reason: format!(
2906                "joint cause-specific survival has {} timewiggle coefficient blocks, expected {cause_count}",
2907                by_cause.len()
2908            ),
2909        });
2910    }
2911    for (cause, (block, beta_w)) in fit.blocks.iter().zip(by_cause).enumerate() {
2912        if beta_w.len() > block.beta.len() {
2913            return Err(SurvivalPredictError::IncompatibleSchema {
2914                reason: format!(
2915                    "joint cause-specific survival cause {} timewiggle beta has length {}, but endpoint beta has {} coefficients",
2916                    cause + 1,
2917                    beta_w.len(),
2918                    block.beta.len()
2919                ),
2920            });
2921        }
2922    }
2923    Ok(by_cause
2924        .iter()
2925        .map(|beta| {
2926            Some(SavedBaselineTimeWiggleRuntime {
2927                knots: knots.clone(),
2928                degree,
2929                penalty_orders: penalty_orders.clone(),
2930                double_penalty,
2931                beta: beta.clone(),
2932            })
2933        })
2934        .collect())
2935}
2936
2937// ---------------------------------------------------------------------------
2938// Per-mode single-row evaluators.
2939// ---------------------------------------------------------------------------
2940
2941/// Precomputed context for evaluating the saved survival marginal-slope
2942/// predictor row-by-row. Built once per call to `predict_survival` so the
2943/// per-(row, t) loop only assembles the per-time q-design slice.
2944struct MarginalSlopePredictContext {
2945    predictor: BernoulliMarginalSlopePredictor,
2946    /// Time-block coefficients (length `p_time_base + p_timewiggle`).
2947    beta_time: Array1<f64>,
2948    /// Covariate (marginal) coefficients.
2949    beta_marginal: Array1<f64>,
2950    saved_timewiggle: Option<SavedBaselineTimeWiggleRuntime>,
2951    /// Covariate design (n × p_marginal), kept operator-backed when possible.
2952    cov_design: DesignMatrix,
2953    /// Logslope design (n × p_logslope), kept operator-backed when possible.
2954    /// With a follow-up margin present this is the tensor product at each row's
2955    /// own exit time; the per-`(row, t)` curve replay rebuilds it at `t` from
2956    /// `logslope_cov_design` instead (gam#2765, gam#2767).
2957    logslope_design: DesignMatrix,
2958    /// The COVARIATE factor of the log-slope design (n × p_cov), which is what
2959    /// the saved term spec describes. Equal to `logslope_design` when the slope
2960    /// is time-constant.
2961    logslope_cov_design: DesignMatrix,
2962    /// The fit's resolved log-slope follow-up margin, when it has one. `None`
2963    /// is a slope that is constant within a person, and then the row's design
2964    /// does not depend on the evaluation time at all.
2965    logslope_time_basis: Option<SurvivalCovariateTimeBasis>,
2966    /// Per-row covariate eta = `cov_design[i] · beta_marginal`. Used to
2967    /// pre-compute `q_exit_base`.
2968    cov_eta: Array1<f64>,
2969    /// Per-row latent z (raw, un-normalized — the predictor's
2970    /// `latent_z_normalization` is applied internally).
2971    z_raw: Array1<f64>,
2972    /// Per-row noise offset, mirroring the `pred_input.offset_noise` slice
2973    /// used by the CLI.
2974    noise_offset: Array1<f64>,
2975}
2976
2977fn design_row_owned(
2978    design: &DesignMatrix,
2979    row: usize,
2980    context: &str,
2981) -> Result<Array1<f64>, SurvivalPredictError> {
2982    let chunk = design
2983        .try_row_chunk(row..row + 1)
2984        .map_err(|e| format!("{context}: {e}"))?;
2985    Ok(chunk.row(0).to_owned())
2986}
2987
2988fn build_marginal_slope_predict_context(
2989    model: &SavedModel,
2990    data: ArrayView2<'_, f64>,
2991    col_map: &HashMap<String, usize>,
2992    training_headers: Option<&Vec<String>>,
2993    cov_design: &DesignMatrix,
2994    primary_offset: &Array1<f64>,
2995    noise_offset: &Array1<f64>,
2996    time_build: &SurvivalTimeBuildOutput,
2997    eta_offset_entry: &Array1<f64>,
2998    eta_offset_exit: &Array1<f64>,
2999    derivative_offset_exit: &Array1<f64>,
3000    age_exit: &Array1<f64>,
3001) -> Result<MarginalSlopePredictContext, SurvivalPredictError> {
3002    let z_name = model
3003        .z_column
3004        .as_ref()
3005        .ok_or_else(|| "saved survival marginal-slope model missing z_column".to_string())?;
3006    let z_col = resolve_role_col(col_map, z_name, "z")?;
3007    let z_raw = data.column(z_col).to_owned();
3008
3009    let logslopespec = resolve_termspec_for_prediction(
3010        &model.resolved_termspec_logslope.as_ref().cloned(),
3011        training_headers,
3012        col_map,
3013        "resolved_termspec_logslope",
3014    )?;
3015    let logslope_clipped = model.axis_clip_to_training_ranges(data, col_map);
3016    let logslope_input = logslope_clipped.as_ref().map_or(data, |arr| arr.view());
3017    let logslope_design = build_term_collection_design(logslope_input, &logslopespec)
3018        .map_err(|e| format!("failed to build survival marginal-slope logslope design: {e}"))?;
3019    let effective_noise_offset = logslope_design
3020        .compose_offset(
3021            noise_offset.view(),
3022            "survival marginal-slope logslope block",
3023        )
3024        .map_err(|error| error.to_string())?;
3025    // gam#2765 / gam#2767: the term spec names the covariate factor only. With a
3026    // follow-up margin the fitted coefficients live against `X_cov ⊗ᵣ B(log t)`,
3027    // so keep BOTH — the factor, which the per-`(row, t)` replay re-tensors at
3028    // the time being predicted, and the product at each row's own exit time,
3029    // which is what the predictor's width contract is stated against.
3030    let logslope_time_basis = model.logslope_time_basis.clone();
3031    let logslope_cov_design = logslope_design.design.clone();
3032    let logslope_exit_design = match logslope_time_basis.as_ref() {
3033        None => logslope_cov_design.clone(),
3034        Some(time_basis) => crate::survival::construction::replay_logslope_time_margin_design(
3035            age_exit.view(),
3036            time_basis,
3037            &logslope_cov_design,
3038        )?,
3039    };
3040
3041    let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
3042    let (predictor, _pred_input, _predictor_fit) = build_saved_survival_marginal_slope_predictor(
3043        model,
3044        &fit_saved,
3045        z_name,
3046        &z_raw,
3047        cov_design,
3048        &logslope_exit_design,
3049        time_build,
3050        eta_offset_entry,
3051        eta_offset_exit,
3052        derivative_offset_exit,
3053        primary_offset,
3054        &effective_noise_offset,
3055    )?;
3056
3057    let blocks = &fit_saved.blocks;
3058    if blocks.len() < 3 {
3059        return Err(SurvivalPredictError::IncompatibleSchema {
3060            reason: format!(
3061                "saved survival marginal-slope model requires at least 3 blocks [time, marginal, slope], got {}",
3062                blocks.len()
3063            ),
3064        });
3065    }
3066    let beta_time = blocks[0].beta.clone();
3067    let beta_marginal = blocks[1].beta.clone();
3068    let saved_runtime = model.saved_prediction_runtime()?;
3069    let saved_timewiggle = saved_runtime.baseline_time_wiggle.clone();
3070
3071    // cov_eta is time-independent so doing it here avoids `O(n × T)`
3072    // re-multiplications inside the per-cell loop.
3073    let cov_eta = cov_design.dot(&beta_marginal);
3074
3075    Ok(MarginalSlopePredictContext {
3076        predictor,
3077        beta_time,
3078        beta_marginal,
3079        saved_timewiggle,
3080        cov_design: cov_design.clone(),
3081        logslope_design: logslope_exit_design,
3082        logslope_cov_design,
3083        logslope_time_basis,
3084        cov_eta,
3085        z_raw,
3086        noise_offset: effective_noise_offset,
3087    })
3088}
3089
3090/// Evaluate one (row, t) cell for the saved survival marginal-slope kernel.
3091///
3092/// Calls the saved [`BernoulliMarginalSlopePredictor`]
3093/// (`predict_eta_and_q_chain`) to obtain both the linear predictor `eta` and
3094/// the exact IFT-pullback factor `∂eta/∂q`. The survival-index time derivative
3095/// is then `(∂eta/∂q) · qd_with_wiggle`. In rigid mode this collapses to
3096/// `c · qd` (the closed-form probit-frailty composition); under score-warp /
3097/// link-deviation it picks up the exact implicit-function pull-back through the
3098/// per-row calibration intercept, mirroring `compute_survival_timepoint_exact`
3099/// in `survival_marginal_slope.rs`.
3100fn evaluate_marginal_slope_row(
3101    row_index: usize,
3102    ctx: &MarginalSlopePredictContext,
3103    row_time: &SurvivalTimeBuildOutput,
3104    r_eta_exit: &Array1<f64>,
3105    r_deriv_exit: &Array1<f64>,
3106    primary_offset_row: f64,
3107    evaluation_time: f64,
3108) -> Result<(f64, f64, f64), SurvivalPredictError> {
3109    let beta_time = &ctx.beta_time;
3110    let p_time_base = row_time.x_exit_time.ncols();
3111    let p_timewiggle = ctx
3112        .saved_timewiggle
3113        .as_ref()
3114        .map_or(0, |runtime| runtime.beta.len());
3115    if beta_time.len() != p_time_base + p_timewiggle {
3116        let hint = stale_weibull_time_basis_hint(
3117            &row_time.basisname,
3118            beta_time.len() == p_time_base + p_timewiggle + 1,
3119        );
3120        return Err(SurvivalPredictError::IncompatibleSchema {
3121            reason: format!(
3122                "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}{hint}",
3123                beta_time.len(),
3124                p_time_base,
3125                p_timewiggle
3126            ),
3127        });
3128    }
3129    let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
3130
3131    // Pre-wiggle q-eta for this (row, t) cell. Mirrors the CLI's `q_exit_base`
3132    // construction in `build_saved_survival_marginal_slope_predictor`:
3133    //   q = time_basis(t) · beta_time_base + cov[row] · beta_marginal
3134    //       + r_eta_exit + primary_offset_row.
3135    let q_exit_base = row_time.x_exit_time.dot(&beta_time_base)[0]
3136        + ctx.cov_eta[row_index]
3137        + r_eta_exit[0]
3138        + primary_offset_row;
3139    let qd_exit_base = row_time.x_derivative_time.dot(&beta_time_base)[0] + r_deriv_exit[0];
3140
3141    // For timewiggle the `exit_design` row enters the predictor's q-design;
3142    // the `derivative_design` row enters the time-derivative used to build the
3143    // hazard. Both are evaluated at the wiggle anchor `q_exit_base`.
3144    let (qd_with_wiggle, exit_wiggle_design) = if let Some(runtime) = ctx.saved_timewiggle.as_ref()
3145    {
3146        let knots = Array1::from_vec(runtime.knots.clone());
3147        let beta_w = beta_time.slice(s![p_time_base..]).to_owned();
3148        let eta_exit_row = Array1::from_elem(1, q_exit_base);
3149        let deriv_row = Array1::from_elem(1, qd_exit_base);
3150        // Only the VALUE basis is wanted here. Going through
3151        // `buildwiggle_block_input_from_knots` also assembled the penalty set —
3152        // discarded one line later — which since gam#2647 additionally costs a
3153        // function Gram and a generalized eigendecomposition per predicted row.
3154        let exit_design = monotone_wiggle_basis_with_derivative_order(
3155            eta_exit_row.view(),
3156            &knots,
3157            runtime.degree,
3158            0,
3159        )?;
3160        let derivative_design = build_survival_timewiggle_derivative_design(
3161            &eta_exit_row,
3162            &deriv_row,
3163            &knots,
3164            runtime.degree,
3165        )?;
3166        (
3167            qd_exit_base + derivative_design.dot(&beta_w)[0],
3168            Some(exit_design),
3169        )
3170    } else {
3171        (qd_exit_base, None)
3172    };
3173
3174    // Build a 1-row PredictInput for this (row, t) cell and call the saved
3175    // predictor. The predictor's `marginal_eta` formula is
3176    //   marginal_eta = q_design · combined_q_beta + baseline_marginal + offset
3177    // with `combined_q_beta = [beta_time | beta_marginal]` and the survival
3178    // predictor sets `baseline_marginal = 0`. We supply the full per-row
3179    // q_design = [time_basis(t) | timewiggle | cov_design[row]] so the
3180    // predictor reproduces `q_with_wiggle` exactly with `offset = r_eta_exit[0]
3181    // + primary_offset_row`.
3182    let cov_dim = ctx.beta_marginal.len();
3183    let q_design_ncols = p_time_base + p_timewiggle + cov_dim;
3184    let mut q_design_full = Array2::<f64>::zeros((1, q_design_ncols));
3185    q_design_full
3186        .slice_mut(s![.., ..p_time_base])
3187        .assign(&row_time.x_exit_time.to_dense());
3188    if let Some(exit_w) = exit_wiggle_design.as_ref() {
3189        q_design_full
3190            .slice_mut(s![.., p_time_base..p_time_base + p_timewiggle])
3191            .assign(exit_w);
3192    }
3193    if cov_dim > 0 {
3194        let cov_row = design_row_owned(
3195            &ctx.cov_design,
3196            row_index,
3197            "survival marginal covariate row",
3198        )?;
3199        q_design_full
3200            .slice_mut(s![.., p_time_base + p_timewiggle..])
3201            .row_mut(0)
3202            .assign(&cov_row);
3203    }
3204
3205    // Logslope design row + offset chosen so that the predictor's logslope_eta
3206    // equals our precomputed `slope_eta[row]`.  The predictor computes:
3207    //   logslope_eta = design_noise · beta_logslope + baseline_logslope
3208    //                  + offset_noise.
3209    // We feed the actual saved logslope row + the row's noise offset, matching
3210    // exactly the CLI's `pred_input.design_noise` / `offset_noise` slice.
3211    // gam#2765 / gam#2767: with a follow-up margin the slope is `b(t)`, so the
3212    // row's design has to be re-tensored at the time the curve is being
3213    // evaluated at rather than frozen at the row's own exit time. Reading the
3214    // exit-time row here would return `S(t)` computed with `b(t_exit)` — a
3215    // different model at every point of the curve except one.
3216    let logslope_row = match ctx.logslope_time_basis.as_ref() {
3217        None => design_row_owned(
3218            &ctx.logslope_design,
3219            row_index,
3220            "survival marginal logslope row",
3221        )?,
3222        Some(time_basis) => {
3223            let cov_row = design_row_owned(
3224                &ctx.logslope_cov_design,
3225                row_index,
3226                "survival marginal logslope covariate row",
3227            )?;
3228            let cov_row_design = DesignMatrix::from(
3229                cov_row
3230                    .into_shape_with_order((1, ctx.logslope_cov_design.ncols()))
3231                    .map_err(|e| format!("survival marginal logslope covariate row shape: {e}"))?,
3232            );
3233            let tensored = crate::survival::construction::replay_logslope_time_margin_design(
3234                Array1::from_elem(1, evaluation_time).view(),
3235                time_basis,
3236                &cov_row_design,
3237            )?;
3238            design_row_owned(&tensored, 0, "survival marginal logslope row at t")?
3239        }
3240    };
3241    let mut logslope_design_2d = Array2::<f64>::zeros((1, logslope_row.len()));
3242    logslope_design_2d.row_mut(0).assign(&logslope_row);
3243
3244    let pred_input = PredictInput {
3245        design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(q_design_full)),
3246        offset: Array1::from_elem(1, r_eta_exit[0] + primary_offset_row),
3247        design_noise: Some(DesignMatrix::Dense(
3248            gam_linalg::matrix::DenseDesignMatrix::from(logslope_design_2d),
3249        )),
3250        offset_noise: Some(Array1::from_elem(1, ctx.noise_offset[row_index])),
3251        auxiliary_scalar: Some(Array1::from_elem(1, ctx.z_raw[row_index])),
3252        auxiliary_matrix: None,
3253    };
3254
3255    // Exact IFT pull-back: the predictor returns both `eta` and the analytic
3256    // factor `∂eta/∂q` for this (row, t). This gives d eta(t) / dt; the hazard
3257    // conversion below divides the event density by S(t).
3258    let (eta_arr, deta_dq_arr) = ctx
3259        .predictor
3260        .predict_eta_and_q_chain(&pred_input)
3261        .map_err(|e| format!("saved survival marginal-slope predictor eta failed: {e}"))?;
3262    let eta = eta_arr[0];
3263    // `qd_with_wiggle` is the base survival-index time derivative q'(t), built
3264    // identically to fit-time `qd1 = dq_dq0·d_raw` (the wiggle chain and the
3265    // `+derivative_guard` offset are both already folded into `qd_exit_base`),
3266    // so there is no predict-vs-fit desync in the derivative reconstruction.
3267    //
3268    // Fit enforces the monotonicity floor `q'(t) >= derivative_guard` ONLY at
3269    // each training row's own exit time (one `t` per row), via the active-set
3270    // guard constraints. A prediction horizon is an arbitrary `t` — typically a
3271    // single CIF horizon evaluated for every row — which generally is NOT one of
3272    // the constrained training exit times. Where that horizon lands in a region
3273    // of sparse/no training exits, the penalized baseline spline can extrapolate
3274    // to a locally decreasing survival index, so `q'(t) < 0` is a legitimate
3275    // model statement ("no instantaneous hazard accrues here"), not a numerical
3276    // bug. The instantaneous hazard rate is physically non-negative, so the
3277    // truthful response is to clamp the index time-derivative at its floor 0
3278    // (flat hazard, survival locally constant) rather than reject the whole
3279    // prediction — clamping keeps the CIF well-posed and monotone. Only a
3280    // non-finite derivative (a real numerical failure) is surfaced to the strict
3281    // validator below.
3282    let eta_derivative = marginal_slope_index_derivative_at_horizon(deta_dq_arr[0], qd_with_wiggle);
3283    let (cum, haz) = probit_survival_hazard_components(eta, eta_derivative)?;
3284    Ok((eta, cum, haz))
3285}
3286
3287/// Reconstruct the marginal-slope survival index time-derivative `eta'(t)` at a
3288/// prediction horizon and clamp it to its physical floor.
3289///
3290/// `deta_dq = ∂eta/∂q ≥ 1` is the rigid probit-frailty chain factor and
3291/// `qd_with_wiggle = q'(t)` is the base survival-index time derivative built
3292/// identically to fit-time `qd1`. The instantaneous hazard rate `h(t) = mills ·
3293/// eta'(t)` is physically non-negative, so a finite negative `eta'(t)` — which a
3294/// penalized baseline spline can legitimately produce when the prediction
3295/// horizon lands outside the training exit times the monotonicity guard
3296/// constrains — is clamped to its floor 0 (flat hazard, locally constant
3297/// survival), keeping the CIF well-posed. Non-finite values pass through
3298/// unchanged so the strict validator rejects them as genuine numerical failures.
3299#[inline]
3300fn marginal_slope_index_derivative_at_horizon(deta_dq: f64, qd_with_wiggle: f64) -> f64 {
3301    let eta_derivative = deta_dq * qd_with_wiggle;
3302    if eta_derivative.is_finite() {
3303        eta_derivative.max(0.0)
3304    } else {
3305        eta_derivative
3306    }
3307}
3308
3309#[inline]
3310fn probit_survival_hazard_components(
3311    eta: f64,
3312    eta_derivative: f64,
3313) -> Result<(f64, f64), SurvivalPredictError> {
3314    if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
3315        return Err(SurvivalPredictError::NumericalFailure {
3316            reason: format!(
3317                "saved survival marginal-slope prediction produced invalid survival index derivative: eta={eta}, eta_t={eta_derivative}"
3318            ),
3319        });
3320    }
3321
3322    // Survival marginal-slope defines S(t) = Phi(-eta(t)). The event density
3323    // is f(t) = phi(eta(t)) * eta'(t), while the hazard rate exposed by the
3324    // prediction API is h(t) = f(t) / S(t). The signed-probit helper returns
3325    // both log Phi(-eta) and the stable Mills ratio phi(eta) / Phi(-eta).
3326    let (log_survival, mills_ratio) = signed_probit_logcdf_and_mills_ratio(-eta);
3327    let cumulative_hazard = -log_survival;
3328    let hazard = if eta_derivative == 0.0 {
3329        0.0
3330    } else {
3331        mills_ratio * eta_derivative
3332    };
3333    // `>= 0.0` rejects NaN (a programming-bug signal) and accepts the full
3334    // mathematical range [0, +∞]. Saturated probit fits where the model
3335    // genuinely says S(t)→0 produce a +∞ cumulative hazard — that is the
3336    // truthful answer, and the consumer's `survival = exp(-cum).clamp(0,1)`
3337    // handles it cleanly. Rejecting +∞ would force the predictor to fail on
3338    // models that the inner solver has already certified as a valid fit.
3339    if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
3340        return Err(SurvivalPredictError::NumericalFailure {
3341            reason: format!(
3342                "saved survival marginal-slope prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, log_survival={log_survival}, hazard={hazard}"
3343            ),
3344        });
3345    }
3346    Ok((cumulative_hazard, hazard))
3347}
3348
3349fn evaluate_rp_row(
3350    model: &SavedModel,
3351    row_time: &SurvivalTimeBuildOutput,
3352    cov_row: &Array1<f64>,
3353    eta_time_offset_row: f64,
3354    derivative_time_offset_row: f64,
3355    primary_offset_row: f64,
3356) -> Result<(f64, f64, f64), SurvivalPredictError> {
3357    let fit_saved = fit_result_from_saved_model_for_prediction(model)?;
3358    let saved_runtime = model.saved_prediction_runtime()?;
3359    evaluate_rp_row_with_beta(
3360        &fit_saved.beta,
3361        saved_runtime.baseline_time_wiggle.as_ref(),
3362        row_time,
3363        cov_row,
3364        eta_time_offset_row,
3365        derivative_time_offset_row,
3366        primary_offset_row,
3367    )
3368}
3369
3370fn evaluate_rp_row_with_beta(
3371    beta: &Array1<f64>,
3372    saved_timewiggle: Option<&SavedBaselineTimeWiggleRuntime>,
3373    row_time: &SurvivalTimeBuildOutput,
3374    cov_row: &Array1<f64>,
3375    eta_time_offset_row: f64,
3376    derivative_time_offset_row: f64,
3377    primary_offset_row: f64,
3378) -> Result<(f64, f64, f64), SurvivalPredictError> {
3379    let p_time = row_time.x_exit_time.ncols();
3380    let p_timewiggle = saved_timewiggle.map_or(0, |runtime| runtime.beta.len());
3381    let p_cov = cov_row.len();
3382    let p = p_time + p_timewiggle + p_cov;
3383    if beta.len() != p {
3384        let hint = stale_weibull_time_basis_hint(&row_time.basisname, beta.len() == p + 1);
3385        return Err(SurvivalPredictError::IncompatibleSchema {
3386            reason: format!(
3387                "survival RP coefficient mismatch: beta has {} entries but design has {} columns{hint}",
3388                beta.len(),
3389                p
3390            ),
3391        });
3392    }
3393    let mut x_exit = Array2::<f64>::zeros((1, p));
3394    if p_time > 0 {
3395        x_exit
3396            .slice_mut(s![.., ..p_time])
3397            .assign(&row_time.x_exit_time.to_dense());
3398    }
3399    let offset_derivative_component = derivative_time_offset_row;
3400    let mut eta_derivative = offset_derivative_component;
3401    let mut time_derivative_component = 0.0_f64;
3402    if p_time > 0 {
3403        time_derivative_component = row_time
3404            .x_derivative_time
3405            .dot(&beta.slice(s![..p_time]).to_owned())[0];
3406        eta_derivative += time_derivative_component;
3407    }
3408    let mut wiggle_derivative_component = 0.0_f64;
3409    if let Some(runtime) = saved_timewiggle {
3410        let knots = Array1::from_vec(runtime.knots.clone());
3411        let beta_w = beta.slice(s![p_time..p_time + p_timewiggle]).to_owned();
3412        let eta_exit_row = Array1::from_elem(1, eta_time_offset_row);
3413        let derivative_exit_row = Array1::from_elem(1, derivative_time_offset_row);
3414        let exit_design = monotone_wiggle_basis_with_derivative_order(
3415            eta_exit_row.view(),
3416            &knots,
3417            runtime.degree,
3418            0,
3419        )?;
3420        if exit_design.ncols() != p_timewiggle {
3421            return Err(SurvivalPredictError::IncompatibleSchema {
3422                reason: format!(
3423                    "survival RP timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
3424                    exit_design.ncols(),
3425                    p_timewiggle
3426                ),
3427            });
3428        }
3429        x_exit
3430            .slice_mut(s![.., p_time..p_time + p_timewiggle])
3431            .assign(&exit_design);
3432        let derivative_design = build_survival_timewiggle_derivative_design(
3433            &eta_exit_row,
3434            &derivative_exit_row,
3435            &knots,
3436            runtime.degree,
3437        )?;
3438        wiggle_derivative_component = derivative_design.dot(&beta_w)[0];
3439        eta_derivative += wiggle_derivative_component;
3440    }
3441    // Cold-path diagnostic (fires only when the assembled log-cumulative-hazard
3442    // derivative is about to be refused): decompose `eta_t` into its additive
3443    // components and report the time-coefficient / derivative-basis extrema so a
3444    // refused prediction is traceable to the specific negative term instead of
3445    // only surfacing the aggregate. Never fires on the accepted path.
3446    if !(eta_derivative.is_finite() && eta_derivative >= 0.0) {
3447        let time_beta = beta.slice(s![..p_time]);
3448        let beta_min = time_beta.iter().copied().fold(f64::INFINITY, f64::min);
3449        let beta_max = time_beta.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3450        let dtime = row_time.x_derivative_time.to_dense();
3451        let dmin = dtime.iter().copied().fold(f64::INFINITY, f64::min);
3452        let dmax = dtime.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3453        log::info!(
3454            "[rp-predict/eta_t-refusal] eta_t={eta_derivative:.12e} = offset({offset_derivative_component:.12e}) + time({time_derivative_component:.12e}) + wiggle({wiggle_derivative_component:.12e}); p_time={p_time} p_timewiggle={p_timewiggle} p_cov={p_cov} time_beta=[{beta_min:.6e},{beta_max:.6e}] x_derivative_time=[{dmin:.6e},{dmax:.6e}] has_wiggle={}",
3455            saved_timewiggle.is_some(),
3456        );
3457    }
3458    if p_cov > 0 {
3459        x_exit
3460            .slice_mut(s![
3461                ..,
3462                (p_time + p_timewiggle)..(p_time + p_timewiggle + p_cov)
3463            ])
3464            .row_mut(0)
3465            .assign(cov_row);
3466    }
3467    let offset_view = Array1::from_elem(1, eta_time_offset_row + primary_offset_row);
3468    let likelihood = LikelihoodSpec::new(
3469        ResponseFamily::RoystonParmar,
3470        InverseLink::Standard(StandardLink::Identity),
3471    );
3472    let eta =
3473        predict_royston_parmar_eta(x_exit.view(), beta.view(), offset_view.view(), &likelihood)?[0];
3474    let (cum, haz) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
3475    Ok((eta, cum, haz))
3476}
3477
3478fn predict_royston_parmar_eta<X>(
3479    x: X,
3480    beta: ndarray::ArrayView1<'_, f64>,
3481    offset: ndarray::ArrayView1<'_, f64>,
3482    likelihood: &LikelihoodSpec,
3483) -> Result<Array1<f64>, SurvivalPredictError>
3484where
3485    X: Into<DesignMatrix>,
3486{
3487    if !matches!(likelihood.response, ResponseFamily::RoystonParmar)
3488        || !matches!(
3489            likelihood.link,
3490            InverseLink::Standard(StandardLink::Identity)
3491        )
3492    {
3493        return Err(SurvivalPredictError::UnsupportedConfiguration {
3494            reason: "survival prediction requires RoystonParmar with identity link".to_string(),
3495        });
3496    }
3497    let x = x.into();
3498    if x.nrows() != offset.len() || x.ncols() != beta.len() {
3499        return Err(SurvivalPredictError::IncompatibleSchema {
3500            reason: format!(
3501                "survival prediction design dimensions disagree: design is {}x{}, beta has length {}, offset has length {}",
3502                x.nrows(),
3503                x.ncols(),
3504                beta.len(),
3505                offset.len()
3506            ),
3507        });
3508    }
3509    let mut eta = x.matrixvectormultiply(&beta.to_owned());
3510    eta += &offset;
3511    Ok(eta)
3512}
3513
3514#[inline]
3515fn royston_parmar_survival_hazard_components(
3516    eta: f64,
3517    eta_derivative: f64,
3518) -> Result<(f64, f64), SurvivalPredictError> {
3519    // `eta = log Λ(t)` and `eta_derivative = d(log Λ)/dt`, so the instantaneous
3520    // hazard is `h(t) = Λ(t) · eta_derivative = dΛ/dt`. Reject only the true bug
3521    // signals: a non-finite `eta`, and a derivative that is NaN or genuinely
3522    // negative.
3523    //
3524    // `eta_derivative == 0` is a VALID boundary value, not a failure. The RP
3525    // baseline `log Λ(t)` is an I-spline (monotone non-decreasing cumulative
3526    // hazard): beyond its last interior knot every I-spline basis is flat, so
3527    // its time-derivative is exactly 0 and the instantaneous hazard there is 0
3528    // (`S(t)` locally constant). Any RP model predicted on a grid that extends
3529    // past its training support hits this regime on the tail nodes. The earlier
3530    // strict `> 0.0` gate spuriously failed those predictions (#1564). The
3531    // probit / marginal-slope sibling guard
3532    // (`probit_survival_hazard_components`) already accepts the full `[0, ∞)`
3533    // range and maps a zero derivative to a zero hazard; the RP guard must match.
3534    if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative >= 0.0) {
3535        return Err(SurvivalPredictError::NumericalFailure {
3536            reason: format!(
3537                "saved Royston-Parmar survival prediction produced invalid log-cumulative-hazard derivative: eta={eta}, eta_t={eta_derivative}"
3538            ),
3539        });
3540    }
3541    let cumulative_hazard = eta.exp();
3542    // `h(t) = Λ(t) · d(log Λ)/dt`. Compute the zero-derivative boundary FIRST so
3543    // the `Λ = +∞` (saturated tail, `eta >~ 709.78`) × `0` (flat I-spline)
3544    // indeterminate form resolves to the mathematically correct `0`, not the
3545    // `NaN` that `f64::INFINITY * 0.0` produces. A flat cumulative hazard has
3546    // zero instantaneous hazard regardless of its (possibly saturated) level.
3547    let hazard = if eta_derivative == 0.0 {
3548        0.0
3549    } else {
3550        cumulative_hazard * eta_derivative
3551    };
3552    // Royston-Parmar parameterizes `eta = log Lambda(t)`, so `Lambda = exp(eta)`
3553    // is unbounded above and `exp(eta)` saturates to `+∞` in f64 once
3554    // `eta >~ 709.78` — exactly the regime a saturated RP fit produces in the
3555    // right tail. The math is well-defined (`S(t) → 0`, `h(t) → ∞`); rejecting
3556    // `+∞` here would crash predict on a fit the inner solver already accepted.
3557    // `>= 0.0` rejects NaN (the only true bug signal) while allowing the full
3558    // [0, +∞] range. The consumer materializes survival via
3559    // `survival = exp(-cum).clamp(0, 1)`, which collapses cleanly at saturation.
3560    if !(cumulative_hazard >= 0.0 && hazard >= 0.0) {
3561        return Err(SurvivalPredictError::NumericalFailure {
3562            reason: format!(
3563                "saved Royston-Parmar survival prediction produced invalid survival components: eta={eta}, eta_t={eta_derivative}, cumulative_hazard={cumulative_hazard}, hazard={hazard}"
3564            ),
3565        });
3566    }
3567    Ok((cumulative_hazard, hazard))
3568}
3569
3570/// Batch evaluator for the location-scale survival likelihood mode.
3571///
3572/// Mirrors the CLI's LocationScale predict path (main.rs::run_predict_survival
3573/// LocationScale arm) but stays library-only: builds the threshold/log_sigma
3574/// designs from the saved frozen specs and resolved time margins, applies the
3575/// survival time-derivative guard, and calls `predict_survival_location_scale`.
3576///
3577/// Plugin survival only — uncertainty paths still live in the CLI.
3578fn predict_survival_location_scale_batch(
3579    model: &SavedModel,
3580    age_entry: &Array1<f64>,
3581    age_exit: &Array1<f64>,
3582    cov_design: &gam_terms::smooth::TermCollectionDesign,
3583    primary_offset: &Array1<f64>,
3584    noise_offset: &Array1<f64>,
3585    training_headers: Option<&Vec<String>>,
3586    col_map: &HashMap<String, usize>,
3587    data: ArrayView2<'_, f64>,
3588    time_grid: Option<&[f64]>,
3589    with_uncertainty: bool,
3590    covariance_mode: SurvivalPredictionCovarianceMode,
3591) -> Result<SurvivalPredictResult, String> {
3592    use crate::survival::construction::evaluate_survival_time_basis_row;
3593    use crate::survival::location_scale::{
3594        SurvivalLocationScalePredictInput, predict_survival_location_scale,
3595        predict_survival_location_scalewith_uncertainty, replay_survival_covariate_channels,
3596    };
3597    use gam_linalg::matrix::DesignMatrix;
3598
3599    let n = age_entry.len();
3600    let per_row_eval = time_grid.is_none();
3601    let eval_times: Vec<f64> = match time_grid {
3602        Some(grid) => {
3603            if grid.is_empty() {
3604                return Err("survival time_grid must contain at least one time".to_string());
3605            }
3606            for (idx, &t) in grid.iter().enumerate() {
3607                if !t.is_finite() || t < 0.0 {
3608                    return Err(format!(
3609                        "survival time_grid requires finite non-negative times (index {idx})",
3610                    ));
3611                }
3612            }
3613            grid.to_vec()
3614        }
3615        None => Vec::new(),
3616    };
3617    let t_cols = if per_row_eval { 1 } else { eval_times.len() };
3618    let eval_width = if per_row_eval { 1 } else { t_cols + 1 };
3619    let saved_likelihood_mode = SurvivalLikelihoodMode::LocationScale;
3620    let baseline_cfg = saved_survival_runtime_baseline_config(model)?;
3621    let saved_fit = saved_survival_location_scale_fit_result(model)?;
3622    // Reduced AFT changes the likelihood program (`h ≡ 0` and `-log(t)` moves
3623    // to the location channel), so it is persisted as topology. Coefficient
3624    // values are never interpreted as a model-class discriminator.
3625    let saved_structure = model
3626        .survival_location_scale_structure
3627        .as_ref()
3628        .ok_or_else(|| {
3629            "saved location-scale survival model is missing exact replay structure".to_string()
3630        })?;
3631    let reduced_parametric_aft = matches!(
3632        saved_structure.time_parameterization,
3633        crate::survival::location_scale::SurvivalLocationScaleTimeParameterization::ReducedParametricAft
3634    );
3635    let time_cfg = load_survival_time_basis_config_from_model(model)?;
3636    let mut time_build = build_survival_time_basis(age_entry, age_exit, time_cfg.clone(), None)?;
3637    let resolved_time_cfg = resolved_survival_time_basis_config_from_build(
3638        &time_build.basisname,
3639        time_build.degree,
3640        time_build.knots.as_ref(),
3641        time_build.keep_cols.as_ref(),
3642        time_build.smooth_lambda,
3643    )?;
3644    let time_anchor = model
3645        .survival_time_anchor
3646        .ok_or_else(|| "saved survival model missing survival_time_anchor".to_string())?;
3647    let time_anchor_row = evaluate_survival_time_basis_row(time_anchor, &resolved_time_cfg)?;
3648    center_survival_time_designs_at_anchor(
3649        &mut time_build.x_entry_time,
3650        &mut time_build.x_exit_time,
3651        &time_anchor_row,
3652    )?;
3653    // The reduced-AFT regime has no structural time warp (the monotone baseline
3654    // rides the location channel), so the structural-basis requirement does not
3655    // apply to it.
3656    if !model.has_baseline_time_wiggle() && !reduced_parametric_aft {
3657        require_structural_survival_time_basis(&time_build.basisname, "saved survival sampling")?;
3658    }
3659    let saved_inverse_link = resolve_survival_inverse_link_from_saved(model)?;
3660    let (eval_entry, eval_exit) = if per_row_eval {
3661        (age_entry.clone(), age_exit.clone())
3662    } else {
3663        let total = n * eval_width;
3664        let mut entry = Array1::<f64>::zeros(total);
3665        let mut exit = Array1::<f64>::zeros(total);
3666        {
3667            use rayon::iter::{IntoParallelIterator, ParallelIterator};
3668            let pairs: Vec<(f64, f64)> = (0..total)
3669                .into_par_iter()
3670                .map(|k| {
3671                    let i = k / eval_width;
3672                    let col = k % eval_width;
3673                    let t = if col < t_cols {
3674                        eval_times[col]
3675                    } else {
3676                        age_exit[i]
3677                    };
3678                    (age_entry[i].min(t), t)
3679                })
3680                .collect();
3681            for (k, (t0, t1)) in pairs.into_iter().enumerate() {
3682                entry[k] = t0;
3683                exit[k] = t1;
3684            }
3685        }
3686        (entry, exit)
3687    };
3688    let mut time_build =
3689        build_survival_time_basis(&eval_entry, &eval_exit, time_cfg.clone(), None)?;
3690    center_survival_time_designs_at_anchor(
3691        &mut time_build.x_entry_time,
3692        &mut time_build.x_exit_time,
3693        &time_anchor_row,
3694    )?;
3695    let (mut eta_offset_entry, mut eta_offset_exit, mut derivative_offset_exit) =
3696        build_survival_time_offsets_for_likelihood(
3697            &eval_entry,
3698            &eval_exit,
3699            &baseline_cfg,
3700            saved_likelihood_mode,
3701            Some(&saved_inverse_link),
3702        )?;
3703    add_survival_time_derivative_guard_offset(
3704        &eval_entry,
3705        &eval_exit,
3706        time_anchor,
3707        survival_derivative_guard_for_likelihood(saved_likelihood_mode),
3708        &mut eta_offset_entry,
3709        &mut eta_offset_exit,
3710        &mut derivative_offset_exit,
3711    )?;
3712    if reduced_parametric_aft {
3713        // The warp is removed in this regime (`h ≡ 0`); the σ-scaled log-t baseline
3714        // rides the location channel via the `−log t` threshold shift applied
3715        // below. The saved `beta_time` is an all-zero length-`p` vector (the
3716        // reduced time block has zero free columns and a zero affine lift), so the
3717        // time-warp contribution `x_exit_time · beta_time` is identically zero for
3718        // ANY design — we therefore KEEP the full-width centered basis (so the
3719        // hazard's `beta.len() == x_exit_time.ncols()` check holds and the
3720        // scale-deviation primary keeps its full column count to match the saved
3721        // transform) and only zero the value OFFSET so `h_base = 0`. The derivative
3722        // is handled separately from `inv_sigma / t` in the hazard computation, so
3723        // the entry/derivative designs and offsets are left as built.
3724        eta_offset_exit = Array1::<f64>::zeros(eval_exit.len());
3725    }
3726
3727    let saved_timewiggle_runtime = model.saved_baseline_time_wiggle()?;
3728
3729    // Build threshold + log-sigma designs from the frozen saved specs. Re-using
3730    // resolve_termspec_for_prediction guarantees we honor the predict-data's
3731    // column layout via the model's training_headers.
3732    // The threshold design uses the same frozen spec as the covariate design
3733    // already built for predict_survival; reuse it instead of rebuilding.
3734    let threshold_design = cov_design;
3735    let log_sigmaspec = resolve_termspec_for_prediction(
3736        &model.resolved_termspec_noise,
3737        training_headers,
3738        col_map,
3739        "resolved_termspec_noise",
3740    )?;
3741    let sigma_clipped = model.axis_clip_to_training_ranges(data, col_map);
3742    let sigma_input = sigma_clipped.as_ref().map_or(data, |arr| arr.view());
3743    let raw_sigma_design =
3744        gam_terms::smooth::build_term_collection_design(sigma_input, &log_sigmaspec)
3745            .map_err(|err| format!("failed to build survival log-sigma design: {err}"))?;
3746    let effective_noise_offset = raw_sigma_design
3747        .compose_offset(
3748            noise_offset.view(),
3749            "survival location-scale log-sigma block",
3750        )
3751        .map_err(|error| error.to_string())?;
3752
3753    let x_time_exit_dense = time_build
3754        .x_exit_time
3755        .try_to_dense_by_chunks("survival location-scale prediction time-exit design")?;
3756    let total_rows = eval_exit.len();
3757    let x_time_exit = if let Some(runtime) = saved_timewiggle_runtime.as_ref() {
3758        let mut full =
3759            Array2::<f64>::zeros((total_rows, x_time_exit_dense.ncols() + runtime.beta.len()));
3760        full.slice_mut(s![.., 0..x_time_exit_dense.ncols()])
3761            .assign(&x_time_exit_dense);
3762        full
3763    } else {
3764        x_time_exit_dense
3765    };
3766
3767    let repeat_rows =
3768        |matrix: &DesignMatrix, label: &str| -> Result<DesignMatrix, SurvivalPredictError> {
3769            if per_row_eval {
3770                return Ok(matrix.clone());
3771            }
3772            let dense = matrix.try_to_dense_by_chunks(label)?;
3773            let mut repeated = Array2::<f64>::zeros((total_rows, dense.ncols()));
3774            use rayon::iter::{IntoParallelIterator, ParallelIterator};
3775            let rows: Vec<Vec<f64>> = (0..total_rows)
3776                .into_par_iter()
3777                .map(|k| dense.row(k / eval_width).to_vec())
3778                .collect();
3779            for (k, row) in rows.into_iter().enumerate() {
3780                for (j, value) in row.into_iter().enumerate() {
3781                    repeated[[k, j]] = value;
3782                }
3783            }
3784            Ok(DesignMatrix::from(repeated))
3785        };
3786    let expand_vector = |values: &Array1<f64>| -> Array1<f64> {
3787        if per_row_eval {
3788            values.clone()
3789        } else {
3790            Array1::from_shape_fn(total_rows, |k| values[k / eval_width])
3791        }
3792    };
3793    if saved_structure.threshold_time_basis.is_some()
3794        && threshold_design
3795            .affine_offset
3796            .iter()
3797            .any(|value| *value != 0.0)
3798    {
3799        return Err(
3800            "saved time-varying survival threshold cannot carry a non-zero smooth anchor"
3801                .to_string(),
3802        );
3803    }
3804    if saved_structure.log_sigma_time_basis.is_some()
3805        && raw_sigma_design
3806            .affine_offset
3807            .iter()
3808            .any(|value| *value != 0.0)
3809    {
3810        return Err(
3811            "saved time-varying survival log-sigma cannot carry a non-zero smooth anchor"
3812                .to_string(),
3813        );
3814    }
3815    let threshold_base_matrix = repeat_rows(
3816        &threshold_design.design,
3817        "survival location-scale prediction threshold design",
3818    )?;
3819    let raw_sigma_base_matrix = repeat_rows(
3820        &raw_sigma_design.design,
3821        "survival location-scale prediction log-sigma design",
3822    )?;
3823    let mut threshold_replay = replay_survival_covariate_channels(
3824        &threshold_base_matrix,
3825        &expand_vector(primary_offset),
3826        &eval_entry,
3827        &eval_exit,
3828        saved_structure.threshold_time_basis.as_ref(),
3829        "survival location-scale threshold",
3830    )?;
3831    let sigma_replay = replay_survival_covariate_channels(
3832        &raw_sigma_base_matrix,
3833        &expand_vector(&effective_noise_offset),
3834        &eval_entry,
3835        &eval_exit,
3836        saved_structure.log_sigma_time_basis.as_ref(),
3837        "survival location-scale log-sigma",
3838    )?;
3839    let link_wiggle_knots = model
3840        .linkwiggle_knots
3841        .as_ref()
3842        .map(|k| Array1::from_vec(k.clone()));
3843    let link_wiggle_degree = model.linkwiggle_degree;
3844    let time_wiggle_knots = saved_timewiggle_runtime
3845        .as_ref()
3846        .map(|w| Array1::from_vec(w.knots.clone()));
3847    let time_wiggle_degree = saved_timewiggle_runtime.as_ref().map(|w| w.degree);
3848    let time_wiggle_ncols = saved_timewiggle_runtime
3849        .as_ref()
3850        .map_or(0, |w| w.beta.len());
3851
3852    // Threshold (location) offset. In the reduced parametric-AFT regime the
3853    // σ-scaled `log t` baseline rides the location channel: shift the effective
3854    // location `η_t → η_t − log t` per query time so the predicted standardized
3855    // residual reproduces `u = inv_sigma·(log t − η_t) = (log t − μ)/σ`, exactly
3856    // as the fit's `LocationLogTimeOffset` does. `eval_exit` already carries the
3857    // per-(row, time) query exit times in the same flattened layout as the
3858    // expanded offsets; `−log t` uses the same `SURVIVAL_TIME_FLOOR` floor as the
3859    // fit's `checked_log_survival_times` (issue #892).
3860    if reduced_parametric_aft {
3861        for (slot, &t) in threshold_replay.offset.iter_mut().zip(eval_exit.iter()) {
3862            *slot -= t
3863                .max(crate::survival::construction::SURVIVAL_TIME_FLOOR)
3864                .ln();
3865        }
3866    }
3867    // Build the SurvivalLocationScalePredictInput once, with replicated /
3868    // expanded designs and offsets, regardless of `per_row_eval`.  This
3869    // unifies the mean-only and uncertainty paths and lets the
3870    // uncertainty branch reuse the same input.
3871    let pred_input = SurvivalLocationScalePredictInput {
3872        x_time_exit,
3873        eta_time_offset_exit: eta_offset_exit.clone(),
3874        time_wiggle_knots: time_wiggle_knots.clone(),
3875        time_wiggle_degree,
3876        time_wiggle_ncols,
3877        x_threshold: threshold_replay.design_exit.clone(),
3878        eta_threshold_offset: threshold_replay.offset.clone(),
3879        x_log_sigma: sigma_replay.design_exit.clone(),
3880        eta_log_sigma_offset: sigma_replay.offset.clone(),
3881        x_link_wiggle: None,
3882        link_wiggle_knots: link_wiggle_knots.clone(),
3883        link_wiggle_degree,
3884        inverse_link: saved_inverse_link.clone(),
3885    };
3886
3887    // Mean / SE computation.  The uncertainty path also computes the
3888    // survival mean and eta, so we use whichever output we have.
3889    let (eta_full, survival_prob_full, response_se_full, eta_se_full): (
3890        Array1<f64>,
3891        Array1<f64>,
3892        Option<Array1<f64>>,
3893        Option<Array1<f64>>,
3894    ) = if with_uncertainty {
3895        // #2296: resolve the requested covariance definition exactly. A
3896        // smoothing-corrected request must never be satisfied with the
3897        // conditional matrix; location-scale fits do not persist a corrected
3898        // covariance today, so that request is a typed refusal, not a
3899        // silently narrower band.
3900        let cov = match select_survival_prediction_covariance(
3901            saved_fit.beta_covariance(),
3902            saved_fit.beta_covariance_corrected(),
3903            covariance_mode,
3904        ) {
3905            Ok(cov) => cov,
3906            Err(SurvivalPredictError::PosteriorCovariance { reason })
3907                if covariance_mode == SurvivalPredictionCovarianceMode::Conditional =>
3908            {
3909                return Err(format!(
3910                    "survival location-scale uncertainty: {reason}; refit with the \
3911                     current CLI / library to populate beta_covariance"
3912                ));
3913            }
3914            Err(err) => return Err(String::from(err)),
3915        };
3916        let unc = predict_survival_location_scalewith_uncertainty(
3917            &pred_input,
3918            &saved_fit,
3919            cov,
3920            false,
3921            true,
3922        )
3923        .map_err(|err| format!("survival location-scale uncertainty predict failed: {err}"))?;
3924        let response_se = unc.response_standard_error.ok_or_else(|| {
3925            "survival location-scale uncertainty: response_standard_error \
3926             missing despite include_response_sd=true"
3927                .to_string()
3928        })?;
3929        (
3930            unc.eta,
3931            unc.survival_prob,
3932            Some(response_se),
3933            Some(unc.eta_standard_error),
3934        )
3935    } else {
3936        let pred = predict_survival_location_scale(&pred_input, &saved_fit)
3937            .map_err(|err| format!("survival location-scale predict failed: {err}"))?;
3938        (pred.eta, pred.survival_prob, None, None)
3939    };
3940
3941    let beta_threshold = saved_fit.beta_threshold();
3942    let beta_log_sigma = saved_fit.beta_log_sigma();
3943    let eta_threshold = threshold_replay
3944        .design_exit
3945        .matrixvectormultiply(&beta_threshold)
3946        + &threshold_replay.offset;
3947    let mut eta_threshold_derivative = threshold_replay
3948        .design_derivative_exit
3949        .as_ref()
3950        .map(|design| design.matrixvectormultiply(&beta_threshold))
3951        .unwrap_or_else(|| Array1::zeros(total_rows));
3952    if reduced_parametric_aft {
3953        for (slot, &time) in eta_threshold_derivative.iter_mut().zip(eval_exit.iter()) {
3954            *slot -= 1.0 / time.max(crate::survival::construction::SURVIVAL_TIME_FLOOR);
3955        }
3956    }
3957    let eta_log_sigma = sigma_replay
3958        .design_exit
3959        .matrixvectormultiply(&beta_log_sigma)
3960        + &sigma_replay.offset;
3961    let eta_log_sigma_derivative = sigma_replay
3962        .design_derivative_exit
3963        .as_ref()
3964        .map(|design| design.matrixvectormultiply(&beta_log_sigma))
3965        .unwrap_or_else(|| Array1::zeros(total_rows));
3966    let hdot = if reduced_parametric_aft {
3967        Array1::zeros(total_rows)
3968    } else {
3969        let x_time_derivative = time_build
3970            .x_derivative_time
3971            .try_to_dense_by_chunks("survival location-scale prediction time-derivative design")?;
3972        location_scale_eta_derivative_components(
3973            &x_time_derivative,
3974            &derivative_offset_exit,
3975            &pred_input.x_time_exit,
3976            &pred_input.eta_time_offset_exit,
3977            time_wiggle_knots.as_ref(),
3978            time_wiggle_degree,
3979            time_wiggle_ncols,
3980            &saved_fit,
3981        )?
3982    };
3983    let inv_sigma = eta_log_sigma.mapv(crate::sigma_link::exp_sigma_inverse_from_eta_scalar);
3984    let q_base = -&eta_threshold * &inv_sigma;
3985    let mut qdot =
3986        &inv_sigma * &(&eta_threshold * &eta_log_sigma_derivative - &eta_threshold_derivative);
3987    if let Some(beta_wiggle) = saved_fit.beta_link_wiggle() {
3988        let knots = link_wiggle_knots.as_ref().ok_or_else(|| {
3989            "saved location-scale link-wiggle coefficients are missing knots".to_string()
3990        })?;
3991        let degree = link_wiggle_degree.ok_or_else(|| {
3992            "saved location-scale link-wiggle coefficients are missing degree".to_string()
3993        })?;
3994        let derivative_basis = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
3995            q_base.view(),
3996            knots,
3997            degree,
3998            1,
3999        )?;
4000        if derivative_basis.ncols() != beta_wiggle.len() {
4001            return Err(format!(
4002                "saved location-scale link-wiggle derivative width mismatch: design={}, beta={}",
4003                derivative_basis.ncols(),
4004                beta_wiggle.len()
4005            ));
4006        }
4007        qdot *= &(derivative_basis.dot(&beta_wiggle) + 1.0);
4008    }
4009    let eta_derivative_full = hdot + qdot;
4010    if eta_derivative_full
4011        .iter()
4012        .any(|value| !(value.is_finite() && *value > 0.0))
4013    {
4014        return Err(
4015            "saved location-scale survival event-rate derivative must be finite and positive"
4016                .to_string(),
4017        );
4018    }
4019    let hazard_full = location_scale_hazard_from_eta_derivative(
4020        &eta_full,
4021        &eta_derivative_full,
4022        &saved_inverse_link,
4023    )?;
4024
4025    let mut survival = Array2::<f64>::zeros((n, t_cols));
4026    let mut cumulative_hazard = Array2::<f64>::zeros((n, t_cols));
4027    let mut hazard = Array2::<f64>::zeros((n, t_cols));
4028    ndarray::Zip::indexed(&mut survival)
4029        .and(&mut cumulative_hazard)
4030        .and(&mut hazard)
4031        .par_for_each(|(i, j), s, ch, h| {
4032            // Survival-curve origin: at t = 0 everyone is still at risk, so
4033            // S(0) = 1, H(0) = 0 and h(0) = 0 exactly, independent of the
4034            // fitted baseline. Anchor the origin column directly instead of
4035            // routing it through the (probit-survival) baseline, whose index is
4036            // -inf at S0(0) = 1. This matches the transformation / marginal-slope
4037            // predict path's `t <= 0` handling and keeps the default surface grid
4038            // — whose first node is the origin for the `Surv(time, event)`
4039            // right-censored shorthand — evaluable end to end (#1024).
4040            let query_time = if per_row_eval {
4041                age_exit[i]
4042            } else {
4043                eval_times[j]
4044            };
4045            if query_time <= 0.0 {
4046                *s = 1.0;
4047                *ch = 0.0;
4048                *h = 0.0;
4049                return;
4050            }
4051            let k = if per_row_eval { i } else { i * eval_width + j };
4052            let surv = survival_prob_full[k].clamp(SURVIVAL_PROB_MIN_FOR_LOG, 1.0);
4053            *s = surv;
4054            *ch = -surv.ln();
4055            *h = hazard_full[k];
4056        });
4057
4058    let linear_predictor = if per_row_eval {
4059        eta_full.clone()
4060    } else {
4061        Array1::from_shape_fn(n, |i| eta_full[i * eval_width + t_cols])
4062    };
4063    let times = if per_row_eval {
4064        age_exit.to_vec()
4065    } else {
4066        // Cloned (not moved) so the origin-column anchor below can still read the
4067        // per-column query times when assembling the survival standard errors.
4068        eval_times.clone()
4069    };
4070
4071    let survival_se = response_se_full.as_ref().map(|response_se| {
4072        let mut out = Array2::<f64>::zeros((n, t_cols));
4073        ndarray::Zip::indexed(&mut out).par_for_each(|(i, j), slot| {
4074            // S(0) = 1 is a deterministic identity, so its standard error is 0
4075            // at the origin column (consistent with the anchored survival above).
4076            let query_time = if per_row_eval {
4077                age_exit[i]
4078            } else {
4079                eval_times[j]
4080            };
4081            if query_time <= 0.0 {
4082                *slot = 0.0;
4083                return;
4084            }
4085            let k = if per_row_eval { i } else { i * eval_width + j };
4086            *slot = response_se[k].max(0.0);
4087        });
4088        out
4089    });
4090    let eta_se_per_row = eta_se_full.as_ref().map(|eta_se| {
4091        if per_row_eval {
4092            eta_se.clone()
4093        } else {
4094            Array1::from_shape_fn(n, |i| eta_se[i * eval_width + t_cols])
4095        }
4096    });
4097
4098    Ok(SurvivalPredictResult {
4099        times,
4100        hazard,
4101        survival,
4102        cumulative_hazard,
4103        linear_predictor,
4104        likelihood_mode: saved_likelihood_mode,
4105        survival_se,
4106        eta_se: eta_se_per_row,
4107        covariance_source: with_uncertainty.then_some(covariance_mode),
4108    })
4109}
4110
4111pub(crate) struct LocationScaleEtaComponents {
4112    pub h: Array1<f64>,
4113    pub time_jac: Array2<f64>,
4114    pub eta_t: Array1<f64>,
4115    pub eta_ls: Array1<f64>,
4116    pub inv_sigma: Array1<f64>,
4117}
4118
4119pub(crate) struct LocationScaleTimeWarpComponents {
4120    pub(crate) h: Array1<f64>,
4121    pub(crate) time_jac: Array2<f64>,
4122    pub(crate) time_wiggle_dq: Option<Array1<f64>>,
4123}
4124
4125pub(crate) fn location_scale_time_warp_components(
4126    x_time_exit: &Array2<f64>,
4127    eta_time_offset_exit: &Array1<f64>,
4128    time_wiggle_knots: Option<&Array1<f64>>,
4129    time_wiggle_degree: Option<usize>,
4130    time_wiggle_ncols: usize,
4131    fit: &UnifiedFitResult,
4132) -> Result<LocationScaleTimeWarpComponents, String> {
4133    let n = x_time_exit.nrows();
4134    if eta_time_offset_exit.len() != n {
4135        return Err("survival location-scale time-warp row mismatch across inputs".to_string());
4136    }
4137    let beta_time = fit.beta_time();
4138    if x_time_exit.ncols() != beta_time.len() {
4139        return Err(format!(
4140            "survival location-scale time-warp design mismatch: x_exit={} beta_time={}",
4141            x_time_exit.ncols(),
4142            beta_time.len()
4143        ));
4144    }
4145
4146    let p_time_total = beta_time.len();
4147    let p_wiggle = time_wiggle_ncols.min(p_time_total);
4148    let p_base = p_time_total - p_wiggle;
4149    let beta_base = beta_time.slice(s![..p_base]).to_owned();
4150    let h_base = if p_base > 0 {
4151        x_time_exit.slice(s![.., ..p_base]).dot(&beta_base) + eta_time_offset_exit
4152    } else {
4153        eta_time_offset_exit.clone()
4154    };
4155    let mut h = h_base.clone();
4156    let mut time_jac = x_time_exit.clone();
4157    let mut time_wiggle_dq = None;
4158    if p_wiggle > 0 {
4159        if x_time_exit
4160            .slice(s![.., p_base..p_time_total])
4161            .iter()
4162            .any(|&value| value != 0.0)
4163        {
4164            return Err(
4165                "survival location-scale timewiggle prediction requires zero placeholder tail columns"
4166                    .to_string(),
4167            );
4168        }
4169        let knots = time_wiggle_knots.ok_or_else(|| {
4170            "survival location-scale time-warp: timewiggle coefficients are missing knot metadata"
4171                .to_string()
4172        })?;
4173        let degree = time_wiggle_degree.ok_or_else(|| {
4174            "survival location-scale time-warp: timewiggle coefficients are missing degree metadata"
4175                .to_string()
4176        })?;
4177        let beta_w = beta_time.slice(s![p_base..p_time_total]).to_owned();
4178        let time_basis = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
4179            h_base.view(),
4180            knots,
4181            degree,
4182            0,
4183        )?;
4184        let time_basis_d1 = crate::wiggle::monotone_wiggle_basis_with_derivative_order(
4185            h_base.view(),
4186            knots,
4187            degree,
4188            1,
4189        )?;
4190        if time_basis.ncols() != p_wiggle || time_basis_d1.ncols() != p_wiggle {
4191            return Err(format!(
4192                "survival location-scale time-warp timewiggle mismatch: value basis has {} columns, derivative basis has {}, beta has {}",
4193                time_basis.ncols(),
4194                time_basis_d1.ncols(),
4195                p_wiggle
4196            ));
4197        }
4198        let dq = time_basis_d1.dot(&beta_w) + 1.0;
4199        h = &h_base + &time_basis.dot(&beta_w);
4200        time_jac = Array2::<f64>::zeros((n, p_time_total));
4201        if p_base > 0 {
4202            let scaled_base = crate::survival::location_scale::scale_dense_rows(
4203                &x_time_exit.slice(s![.., ..p_base]).to_owned(),
4204                &dq,
4205            )?;
4206            time_jac.slice_mut(s![.., ..p_base]).assign(&scaled_base);
4207        }
4208        time_jac
4209            .slice_mut(s![.., p_base..p_time_total])
4210            .assign(&time_basis);
4211        time_wiggle_dq = Some(dq);
4212    }
4213
4214    Ok(LocationScaleTimeWarpComponents {
4215        h,
4216        time_jac,
4217        time_wiggle_dq,
4218    })
4219}
4220
4221pub(crate) fn location_scale_eta_components(
4222    x_time_exit: &Array2<f64>,
4223    eta_time_offset_exit: &Array1<f64>,
4224    time_wiggle_knots: Option<&Array1<f64>>,
4225    time_wiggle_degree: Option<usize>,
4226    time_wiggle_ncols: usize,
4227    x_threshold: &gam_linalg::matrix::DesignMatrix,
4228    eta_threshold_offset: &Array1<f64>,
4229    x_log_sigma: &gam_linalg::matrix::DesignMatrix,
4230    eta_log_sigma_offset: &Array1<f64>,
4231    fit: &UnifiedFitResult,
4232) -> Result<LocationScaleEtaComponents, String> {
4233    let n = x_time_exit.nrows();
4234    if x_threshold.nrows() != n
4235        || eta_threshold_offset.len() != n
4236        || x_log_sigma.nrows() != n
4237        || eta_log_sigma_offset.len() != n
4238    {
4239        return Err("survival location-scale eta component row mismatch across inputs".to_string());
4240    }
4241    let time_components = location_scale_time_warp_components(
4242        x_time_exit,
4243        eta_time_offset_exit,
4244        time_wiggle_knots,
4245        time_wiggle_degree,
4246        time_wiggle_ncols,
4247        fit,
4248    )?;
4249    let beta_threshold = fit.beta_threshold();
4250    let beta_log_sigma = fit.beta_log_sigma();
4251    let eta_t = x_threshold.matrixvectormultiply(&beta_threshold) + eta_threshold_offset;
4252    let eta_ls = x_log_sigma.matrixvectormultiply(&beta_log_sigma) + eta_log_sigma_offset;
4253    let inv_sigma = eta_ls.mapv(crate::sigma_link::exp_sigma_inverse_from_eta_scalar);
4254    Ok(LocationScaleEtaComponents {
4255        h: time_components.h,
4256        time_jac: time_components.time_jac,
4257        eta_t,
4258        eta_ls,
4259        inv_sigma,
4260    })
4261}
4262
4263fn location_scale_eta_derivative_components(
4264    x_time_derivative: &Array2<f64>,
4265    derivative_offset_exit: &Array1<f64>,
4266    x_time_exit: &Array2<f64>,
4267    eta_time_offset_exit: &Array1<f64>,
4268    time_wiggle_knots: Option<&Array1<f64>>,
4269    time_wiggle_degree: Option<usize>,
4270    time_wiggle_ncols: usize,
4271    fit: &UnifiedFitResult,
4272) -> Result<Array1<f64>, String> {
4273    let n = x_time_exit.nrows();
4274    if x_time_derivative.nrows() != n
4275        || derivative_offset_exit.len() != n
4276        || eta_time_offset_exit.len() != n
4277    {
4278        return Err(
4279            "survival location-scale hazard derivative row mismatch across inputs".to_string(),
4280        );
4281    }
4282    let beta_time = fit.beta_time();
4283    let p_time_total = beta_time.len();
4284    let p_wiggle = time_wiggle_ncols.min(p_time_total);
4285    let p_base = p_time_total - p_wiggle;
4286    if x_time_exit.ncols() != p_time_total || x_time_derivative.ncols() != p_base {
4287        return Err(format!(
4288            "survival location-scale hazard derivative design mismatch: x_exit={} beta_time={} x_derivative={} base={}",
4289            x_time_exit.ncols(),
4290            p_time_total,
4291            x_time_derivative.ncols(),
4292            p_base
4293        ));
4294    }
4295
4296    let time_components = location_scale_time_warp_components(
4297        x_time_exit,
4298        eta_time_offset_exit,
4299        time_wiggle_knots,
4300        time_wiggle_degree,
4301        time_wiggle_ncols,
4302        fit,
4303    )?;
4304    let beta_base = beta_time.slice(s![..p_base]).to_owned();
4305    let mut eta_derivative = if p_base > 0 {
4306        x_time_derivative.dot(&beta_base) + derivative_offset_exit
4307    } else {
4308        derivative_offset_exit.clone()
4309    };
4310    if let Some(dq) = time_components.time_wiggle_dq.as_ref() {
4311        eta_derivative *= dq;
4312    }
4313    if eta_derivative
4314        .iter()
4315        .any(|value| !(value.is_finite() && *value > 0.0))
4316    {
4317        return Err(
4318            "survival location-scale hazard derivative must be finite and positive".to_string(),
4319        );
4320    }
4321    Ok(eta_derivative)
4322}
4323
4324fn location_scale_hazard_from_eta_derivative(
4325    eta: &Array1<f64>,
4326    eta_derivative: &Array1<f64>,
4327    inverse_link: &InverseLink,
4328) -> Result<Array1<f64>, String> {
4329    if eta.len() != eta_derivative.len() {
4330        return Err(format!(
4331            "survival location-scale hazard row mismatch: eta={} eta_derivative={}",
4332            eta.len(),
4333            eta_derivative.len()
4334        ));
4335    }
4336    let values = eta
4337        .iter()
4338        .zip(eta_derivative.iter())
4339        .map(|(&q, &q_t)| location_scale_hazard_component(q, q_t, inverse_link))
4340        .collect::<Result<Vec<_>, _>>()?;
4341    Ok(Array1::from_vec(values))
4342}
4343
4344fn location_scale_hazard_component(
4345    eta: f64,
4346    eta_derivative: f64,
4347    inverse_link: &InverseLink,
4348) -> Result<f64, String> {
4349    if !(eta.is_finite() && eta_derivative.is_finite() && eta_derivative > 0.0) {
4350        return Err(format!(
4351            "survival location-scale hazard requires finite eta and positive eta_t, got eta={eta}, eta_t={eta_derivative}"
4352        ));
4353    }
4354    match inverse_link {
4355        InverseLink::Standard(StandardLink::Probit) => {
4356            let (_, hazard) = probit_survival_hazard_components(eta, eta_derivative)?;
4357            Ok(hazard)
4358        }
4359        InverseLink::Standard(StandardLink::CLogLog) => {
4360            let (_, hazard) = royston_parmar_survival_hazard_components(eta, eta_derivative)?;
4361            Ok(hazard)
4362        }
4363        InverseLink::Standard(StandardLink::Logit) => {
4364            let failure = if eta >= 0.0 {
4365                1.0 / (1.0 + (-eta).exp())
4366            } else {
4367                let exp_eta = eta.exp();
4368                exp_eta / (1.0 + exp_eta)
4369            };
4370            Ok(failure * eta_derivative)
4371        }
4372        InverseLink::Standard(StandardLink::Identity) => {
4373            let survival = 1.0 - eta;
4374            if !(survival.is_finite() && survival > 0.0) {
4375                return Err(format!(
4376                    "survival location-scale identity link produced invalid survival={survival} at eta={eta}"
4377                ));
4378            }
4379            Ok(eta_derivative / survival)
4380        }
4381        _ => {
4382            let jet = inverse_link_jet_for_inverse_link(inverse_link, eta)
4383                .map_err(|err| format!("survival location-scale inverse-link jet failed: {err}"))?;
4384            let survival = 1.0 - jet.mu;
4385            let hazard = jet.d1 * eta_derivative / survival;
4386            if !(survival.is_finite() && survival > 0.0 && hazard.is_finite() && hazard >= 0.0) {
4387                return Err(format!(
4388                    "survival location-scale inverse link produced invalid hazard components: eta={eta}, eta_t={eta_derivative}, failure={}, d_failure={}, survival={survival}, hazard={hazard}",
4389                    jet.mu, jet.d1
4390                ));
4391            }
4392            Ok(hazard)
4393        }
4394    }
4395}
4396
4397// ---------------------------------------------------------------------------
4398// Shared library helpers (used by the CLI wrapper too).
4399// ---------------------------------------------------------------------------
4400
4401/// Extract the saved survival likelihood mode from the model payload.
4402pub fn require_saved_survival_likelihood_mode(
4403    model: &SavedModel,
4404) -> Result<SurvivalLikelihoodMode, SurvivalPredictError> {
4405    if matches!(&model.family_state, FittedFamily::LatentSurvival { .. }) {
4406        return match model.survival_likelihood.as_deref() {
4407            Some("latent") => Ok(SurvivalLikelihoodMode::Latent),
4408            Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
4409                "saved latent survival model has contradictory survival_likelihood metadata: expected 'latent', got '{other}'"
4410            ) }),
4411            None => Err(SurvivalPredictError::MissingFitMetadata {
4412                reason:
4413                    "saved latent survival model is missing survival_likelihood=latent metadata; refit"
4414                        .to_string(),
4415            }),
4416        };
4417    }
4418    if matches!(&model.family_state, FittedFamily::LatentBinary { .. }) {
4419        return match model.survival_likelihood.as_deref() {
4420            Some("latent-binary") => Ok(SurvivalLikelihoodMode::LatentBinary),
4421            Some(other) => Err(SurvivalPredictError::MissingFitMetadata { reason: format!(
4422                "saved latent binary model has contradictory survival_likelihood metadata: expected 'latent-binary', got '{other}'"
4423            ) }),
4424            None => Err(SurvivalPredictError::MissingFitMetadata {
4425                reason:
4426                    "saved latent binary model is missing survival_likelihood=latent-binary metadata; refit"
4427                        .to_string(),
4428            }),
4429        };
4430    }
4431    let raw = model.survival_likelihood.as_deref().ok_or_else(|| {
4432        "saved survival model is missing survival_likelihood metadata; refit".to_string()
4433    })?;
4434    parse_survival_likelihood_mode(raw).map_err(SurvivalPredictError::from)
4435}
4436
4437/// Baseline config persisted by the saved survival model.
4438pub fn saved_survival_runtime_baseline_config(
4439    model: &SavedModel,
4440) -> Result<SurvivalBaselineConfig, SurvivalPredictError> {
4441    survival_baseline_config_from_model(model).map_err(SurvivalPredictError::from)
4442}
4443
4444/// Resolve the covariate `TermCollectionSpec` for prediction, remapping
4445/// saved training-column indices onto the runtime dataset's layout.
4446pub fn resolve_termspec_for_prediction(
4447    modelspec: &Option<TermCollectionSpec>,
4448    training_headers: Option<&Vec<String>>,
4449    col_map: &HashMap<String, usize>,
4450    spec_label: &str,
4451) -> Result<TermCollectionSpec, SurvivalPredictError> {
4452    let saved = modelspec.as_ref().ok_or_else(|| {
4453        format!(
4454            "model is missing {spec_label}; refit to guarantee train/predict design consistency"
4455        )
4456    })?;
4457    saved.validate_frozen(spec_label)?;
4458    let headers = training_headers.ok_or_else(|| {
4459        "model is missing training_headers; refit to guarantee stable feature mapping at prediction time"
4460            .to_string()
4461    })?;
4462    let remapped = remap_term_collectionspec_columns(saved, headers, col_map)?;
4463    remapped.validate_frozen(spec_label)?;
4464    Ok(remapped)
4465}
4466
4467fn remap_term_collectionspec_columns(
4468    spec: &TermCollectionSpec,
4469    training_headers: &[String],
4470    prediction_column_map: &HashMap<String, usize>,
4471) -> Result<TermCollectionSpec, SurvivalPredictError> {
4472    // Delegate the (variant-exhaustive, easy-to-miss-a-field) walk to the
4473    // single shared authority on TermCollectionSpec; supply the survival
4474    // train→predict resolution as the per-index remap closure.
4475    spec.remap_feature_columns(|index| -> Result<usize, SurvivalPredictError> {
4476        let name = training_headers
4477            .get(index)
4478            .ok_or_else(|| format!("saved training column index {index} is out of bounds"))?;
4479        resolve_role_col(prediction_column_map, name, "prediction")
4480            .map_err(SurvivalPredictError::from)
4481    })
4482}
4483
4484/// Canonical saved fit result for prediction.
4485pub fn fit_result_from_saved_model_for_prediction(
4486    model: &SavedModel,
4487) -> Result<UnifiedFitResult, String> {
4488    model
4489        .fit_result
4490        .clone()
4491        .ok_or_else(|| "model is missing canonical fit_result payload; refit".to_string())
4492}
4493
4494/// Resolve the saved survival location-scale fit result.
4495///
4496/// Returns a `UnifiedFitResult` with the fitted inverse-link state
4497/// re-applied -- matching the CLI's behaviour in
4498/// `main.rs::saved_survival_location_scale_fit_result`.
4499pub fn saved_survival_location_scale_fit_result(
4500    model: &SavedModel,
4501) -> Result<UnifiedFitResult, SurvivalPredictError> {
4502    model.saved_prediction_runtime()?;
4503    let mut fit = model.fit_result.clone().ok_or_else(|| {
4504        "saved location-scale survival model missing canonical fit_result; refit".to_string()
4505    })?;
4506    let inverse_link = resolve_survival_inverse_link_from_saved(model)?;
4507    apply_inverse_link_state_to_fit_result(&mut fit, &inverse_link);
4508    Ok(fit)
4509}
4510
4511pub fn apply_inverse_link_state_to_fit_result(
4512    fit_result: &mut UnifiedFitResult,
4513    inverse_link: &InverseLink,
4514) {
4515    fit_result.fitted_link = match inverse_link {
4516        InverseLink::LatentCLogLog(state) => FittedLinkState::LatentCLogLog { state: *state },
4517        InverseLink::Sas(state) => FittedLinkState::Sas {
4518            state: *state,
4519            covariance: None,
4520        },
4521        InverseLink::BetaLogistic(state) => FittedLinkState::BetaLogistic {
4522            state: *state,
4523            covariance: None,
4524        },
4525        InverseLink::Mixture(state) => FittedLinkState::Mixture {
4526            state: state.clone(),
4527            covariance: None,
4528        },
4529        InverseLink::Standard(_) => FittedLinkState::Standard(None),
4530    };
4531}
4532
4533/// Resolve the saved survival inverse-link from saved link metadata and fitted
4534/// state.
4535pub fn resolve_survival_inverse_link_from_saved(
4536    model: &SavedModel,
4537) -> Result<InverseLink, SurvivalPredictError> {
4538    if let Some(link) = model.link.as_ref() {
4539        return Ok(link.clone());
4540    }
4541    Err(SurvivalPredictError::MissingFitMetadata {
4542        reason: "saved survival model is missing link metadata; refit".to_string(),
4543    })
4544}
4545
4546/// Concatenate referenced 1-D arrays into a single owned `Array1<f64>`.
4547pub fn concat_array1_refs(parts: &[&Array1<f64>]) -> Array1<f64> {
4548    let total: usize = parts.iter().map(|part| part.len()).sum();
4549    let mut out = Array1::<f64>::zeros(total);
4550    let mut offset = 0usize;
4551    for part in parts {
4552        let width = part.len();
4553        out.slice_mut(s![offset..offset + width]).assign(part);
4554        offset += width;
4555    }
4556    out
4557}
4558
4559/// Rebuild the saved baseline-timewiggle entry/exit/derivative design blocks
4560/// from the saved runtime metadata. Returns `None` when the saved model has no
4561/// baseline-timewiggle.
4562pub fn saved_baseline_timewiggle_components(
4563    eta_entry: &Array1<f64>,
4564    eta_exit: &Array1<f64>,
4565    derivative_exit: &Array1<f64>,
4566    model: &SavedModel,
4567) -> Result<Option<(Array2<f64>, Array2<f64>, Array2<f64>)>, SurvivalPredictError> {
4568    match model.saved_baseline_time_wiggle()? {
4569        None => Ok(None),
4570        Some(runtime) => {
4571            runtime.validate_global_monotonicity()?;
4572            let SavedBaselineTimeWiggleRuntime {
4573                knots,
4574                degree,
4575                beta,
4576                ..
4577            } = runtime;
4578            let knots = Array1::from_vec(knots);
4579            let entry =
4580                monotone_wiggle_basis_with_derivative_order(eta_entry.view(), &knots, degree, 0)?;
4581            let exit =
4582                monotone_wiggle_basis_with_derivative_order(eta_exit.view(), &knots, degree, 0)?;
4583            let betaw = beta;
4584            if entry.ncols() != betaw.len() || exit.ncols() != betaw.len() {
4585                return Err(SurvivalPredictError::IncompatibleSchema {
4586                    reason: format!(
4587                        "saved baseline-timewiggle dimension mismatch: coefficients have {} entries but basis has entry={} exit={}",
4588                        betaw.len(),
4589                        entry.ncols(),
4590                        exit.ncols()
4591                    ),
4592                });
4593            }
4594            let derivative = build_survival_timewiggle_derivative_design(
4595                eta_exit,
4596                derivative_exit,
4597                &knots,
4598                degree,
4599            )
4600            .map_err(|e| {
4601                e.replace(
4602                    "build baseline-timewiggle",
4603                    "evaluate saved baseline-timewiggle",
4604                )
4605            })?;
4606            if derivative.ncols() != betaw.len() {
4607                return Err(SurvivalPredictError::IncompatibleSchema {
4608                    reason: format!(
4609                        "saved baseline-timewiggle derivative dimension mismatch: coefficients have {} entries but derivative basis has {} columns",
4610                        betaw.len(),
4611                        derivative.ncols()
4612                    ),
4613                });
4614            }
4615            Ok(Some((entry, exit, derivative)))
4616        }
4617    }
4618}
4619
4620/// Build the saved survival marginal-slope predictor along with the matching
4621/// `PredictInput` and a `UnifiedFitResult` repackaged into the layout
4622/// `BernoulliMarginalSlopePredictor::from_unified` expects.
4623///
4624/// This is the single source of truth for assembling the marginal-slope
4625/// predictor at predict time. The CLI's `gam predict` flow and the
4626/// library-side `predict_survival` both call into this helper so they share
4627/// bit-identical eta math (link-deviation + score-warp replay included).
4628pub fn build_saved_survival_marginal_slope_predictor(
4629    model: &SavedModel,
4630    fit_saved: &UnifiedFitResult,
4631    z_name: &str,
4632    z: &Array1<f64>,
4633    cov_design: &DesignMatrix,
4634    logslope_design: &DesignMatrix,
4635    time_build: &SurvivalTimeBuildOutput,
4636    eta_offset_entry: &Array1<f64>,
4637    eta_offset_exit: &Array1<f64>,
4638    derivative_offset_exit: &Array1<f64>,
4639    primary_offset: &Array1<f64>,
4640    noise_offset: &Array1<f64>,
4641) -> Result<
4642    (
4643        BernoulliMarginalSlopePredictor,
4644        PredictInput,
4645        UnifiedFitResult,
4646    ),
4647    SurvivalPredictError,
4648> {
4649    let saved_runtime = model.saved_prediction_runtime()?;
4650    if saved_runtime.link_wiggle.is_some() {
4651        return Err(SurvivalPredictError::MissingFitMetadata {
4652            reason:
4653                "saved survival marginal-slope model contains legacy linkwiggle metadata; refit with the anchored link-deviation runtime"
4654                    .to_string(),
4655        });
4656    }
4657
4658    let saved_score_runtime = saved_runtime.score_warp;
4659    let saved_link_runtime = saved_runtime.link_deviation;
4660    // #461: the absorbed Stage-1 influence block (when present) is the trailing
4661    // block. Its `γ` is DROPPED at predict (the orthogonalized β̂ is a
4662    // training-fit property), so it is NOT read below — but it IS persisted, so
4663    // the saved block count includes it.
4664    let influence_absorber_width = saved_runtime.influence_absorber_width;
4665    let blocks = &fit_saved.blocks;
4666    let expected_blocks = 3
4667        + usize::from(saved_score_runtime.is_some())
4668        + usize::from(saved_link_runtime.is_some())
4669        + usize::from(influence_absorber_width.is_some());
4670    if blocks.len() != expected_blocks {
4671        return Err(SurvivalPredictError::IncompatibleSchema {
4672            reason: format!(
4673                "saved survival marginal-slope model requires {} blocks [time, marginal, slope{}{}{}], got {}",
4674                expected_blocks,
4675                if saved_score_runtime.is_some() {
4676                    ", score-warp"
4677                } else {
4678                    ""
4679                },
4680                if saved_link_runtime.is_some() {
4681                    ", link-deviation"
4682                } else {
4683                    ""
4684                },
4685                if influence_absorber_width.is_some() {
4686                    ", influence-absorber(dropped)"
4687                } else {
4688                    ""
4689                },
4690                blocks.len(),
4691            ),
4692        });
4693    }
4694
4695    let beta_time = &blocks[0].beta;
4696    let beta_marginal = &blocks[1].beta;
4697    let beta_logslope = &blocks[2].beta;
4698    if let Some(runtime) = saved_score_runtime.as_ref() {
4699        let beta = &blocks[3].beta;
4700        if beta.len() != runtime.basis_dim {
4701            return Err(SurvivalPredictError::IncompatibleSchema {
4702                reason: format!(
4703                    "saved survival marginal-slope score-warp coefficient mismatch: beta has {} entries but runtime expects {}",
4704                    beta.len(),
4705                    runtime.basis_dim
4706                ),
4707            });
4708        }
4709    }
4710    if let Some(runtime) = saved_link_runtime.as_ref() {
4711        let idx = 3 + usize::from(saved_score_runtime.is_some());
4712        let beta = &blocks[idx].beta;
4713        if beta.len() != runtime.basis_dim {
4714            return Err(SurvivalPredictError::IncompatibleSchema {
4715                reason: format!(
4716                    "saved survival marginal-slope link-deviation coefficient mismatch: beta has {} entries but runtime expects {}",
4717                    beta.len(),
4718                    runtime.basis_dim
4719                ),
4720            });
4721        }
4722    }
4723
4724    if beta_marginal.len() != cov_design.ncols() {
4725        return Err(SurvivalPredictError::IncompatibleSchema {
4726            reason: format!(
4727                "saved survival marginal-slope marginal coefficient mismatch: beta has {} entries but baseline design has {} columns",
4728                beta_marginal.len(),
4729                cov_design.ncols()
4730            ),
4731        });
4732    }
4733    if beta_logslope.len() != logslope_design.ncols() {
4734        return Err(SurvivalPredictError::IncompatibleSchema {
4735            reason: format!(
4736                "saved survival marginal-slope slope coefficient mismatch: beta has {} entries but slope design has {} columns",
4737                beta_logslope.len(),
4738                logslope_design.ncols()
4739            ),
4740        });
4741    }
4742
4743    let p_time_base = time_build.x_exit_time.ncols();
4744    let saved_timewiggle = saved_runtime.baseline_time_wiggle;
4745    let p_timewiggle = saved_timewiggle
4746        .as_ref()
4747        .map_or(0, |runtime| runtime.beta.len());
4748    if beta_time.len() != p_time_base + p_timewiggle {
4749        let hint = stale_weibull_time_basis_hint(
4750            &time_build.basisname,
4751            beta_time.len() == p_time_base + p_timewiggle + 1,
4752        );
4753        return Err(SurvivalPredictError::IncompatibleSchema {
4754            reason: format!(
4755                "saved survival marginal-slope time coefficient mismatch: beta has {} entries but expected base={} plus timewiggle={}{hint}",
4756                beta_time.len(),
4757                p_time_base,
4758                p_timewiggle
4759            ),
4760        });
4761    }
4762
4763    let beta_time_base = beta_time.slice(s![..p_time_base]).to_owned();
4764    // `cov_design · beta_marginal` is row-only (no time dependence); hoist it
4765    // once so both the entry- and exit-time baselines share the single
4766    // matrix-vector multiply instead of recomputing it.
4767    let cov_eta_marginal = cov_design.dot(beta_marginal);
4768    let q_entry_base = time_build.x_entry_time.dot(&beta_time_base)
4769        + &cov_eta_marginal
4770        + eta_offset_entry
4771        + primary_offset;
4772    let q_exit_base = time_build.x_exit_time.dot(&beta_time_base)
4773        + &cov_eta_marginal
4774        + eta_offset_exit
4775        + primary_offset;
4776    let qd_exit_base = time_build.x_derivative_time.dot(&beta_time_base) + derivative_offset_exit;
4777
4778    let mut q_design_parts = vec![time_build.x_exit_time.clone()];
4779    if saved_timewiggle.is_some() {
4780        let (_, exit_w, _) = saved_baseline_timewiggle_components(
4781            &q_entry_base,
4782            &q_exit_base,
4783            &qd_exit_base,
4784            model,
4785        )?
4786        .ok_or_else(|| {
4787            "saved survival marginal-slope model is missing baseline-timewiggle runtime metadata"
4788                .to_string()
4789        })?;
4790        if exit_w.ncols() != p_timewiggle {
4791            return Err(SurvivalPredictError::IncompatibleSchema {
4792                reason: format!(
4793                    "saved survival marginal-slope timewiggle design mismatch: rebuilt {} columns but runtime expects {}",
4794                    exit_w.ncols(),
4795                    p_timewiggle
4796                ),
4797            });
4798        }
4799        q_design_parts.push(DesignMatrix::from(exit_w));
4800    }
4801    q_design_parts.push(cov_design.clone());
4802    let q_design = DesignMatrix::hstack(q_design_parts)?;
4803
4804    let combined_q_beta = concat_array1_refs(&[beta_time, beta_marginal]);
4805    let combined_q_lambdas = concat_array1_refs(&[&blocks[0].lambdas, &blocks[1].lambdas]);
4806    let mut predictor_blocks = Vec::with_capacity(
4807        2 + usize::from(saved_score_runtime.is_some()) + usize::from(saved_link_runtime.is_some()),
4808    );
4809    predictor_blocks.push(FittedBlock {
4810        beta: combined_q_beta.clone(),
4811        role: BlockRole::Mean,
4812        edf: blocks[0].edf + blocks[1].edf,
4813        lambdas: combined_q_lambdas,
4814    });
4815    predictor_blocks.push(FittedBlock {
4816        beta: beta_logslope.clone(),
4817        role: BlockRole::Scale,
4818        edf: blocks[2].edf,
4819        lambdas: blocks[2].lambdas.clone(),
4820    });
4821    if saved_score_runtime.is_some() {
4822        let mut block = blocks[3].clone();
4823        block.role = BlockRole::Mean;
4824        predictor_blocks.push(block);
4825    }
4826    if saved_link_runtime.is_some() {
4827        let idx = 3 + usize::from(saved_score_runtime.is_some());
4828        let mut block = blocks[idx].clone();
4829        block.role = BlockRole::LinkWiggle;
4830        predictor_blocks.push(block);
4831    }
4832
4833    let mut predictor_fit = fit_saved.clone();
4834    predictor_fit.blocks = predictor_blocks;
4835    predictor_fit.beta = concat_array1_refs(
4836        &predictor_fit
4837            .blocks
4838            .iter()
4839            .map(|block| &block.beta)
4840            .collect::<Vec<_>>(),
4841    );
4842    predictor_fit.block_states.clear();
4843
4844    let predictor = BernoulliMarginalSlopePredictor::from_unified(
4845        &predictor_fit,
4846        z_name.to_string(),
4847        model.latent_z_normalization.ok_or_else(|| {
4848            "saved survival marginal-slope model missing latent_z_normalization".to_string()
4849        })?,
4850        model.latent_measure.clone().ok_or_else(|| {
4851            "saved survival marginal-slope model missing latent_measure".to_string()
4852        })?,
4853        0.0,
4854        model.logslope_baseline.ok_or_else(|| {
4855            "saved survival marginal-slope model missing logslope_baseline".to_string()
4856        })?,
4857        model
4858            .resolved_inverse_link()?
4859            .unwrap_or(InverseLink::Standard(StandardLink::Probit)),
4860        model
4861            .family_state
4862            .frailty()
4863            .cloned()
4864            .unwrap_or(FrailtySpec::None),
4865        saved_score_runtime,
4866        saved_link_runtime,
4867        model.latent_z_rank_int_calibration.clone(),
4868        model.latent_z_conditional_calibration.clone(),
4869        // gam#2768: the survival marginal-slope now runs the same automatic
4870        // conditional gate BMS does, so this field is no longer always `None` —
4871        // and the survival predictor's primary design is the q-design
4872        // `[time | timewiggle | marginal]`, NOT the marginal design. The span the
4873        // fit conditioned on is its trailing covariate block; naming anything
4874        // else here would rebuild `a(C)` from time columns and apply a different
4875        // map from the one the fit applied.
4876        LatentConditioningSpan::PrimaryDesignTail {
4877            ncols: cov_design.ncols(),
4878        },
4879    )?;
4880
4881    let pred_input = PredictInput {
4882        design: q_design,
4883        offset: eta_offset_exit + primary_offset,
4884        design_noise: Some(logslope_design.clone()),
4885        offset_noise: Some(noise_offset.clone()),
4886        auxiliary_scalar: Some(z.clone()),
4887        auxiliary_matrix: None,
4888    };
4889
4890    Ok((predictor, pred_input, predictor_fit))
4891}
4892
4893/// Typed hint appended to a survival time-coefficient / design mismatch when the
4894/// saved model looks like a pre-#2301 linear Weibull fit. The built-in Weibull
4895/// linear time basis dropped its redundant constant column (2 → 1 columns), so a
4896/// model saved before that change carries exactly one extra time coefficient
4897/// against the rebuilt 1-column basis. Naming it keeps the load path from
4898/// silently misindexing the stale constant coefficient as the shape.
4899fn stale_weibull_time_basis_hint(basisname: &str, extra_time_coefficient: bool) -> &'static str {
4900    if basisname == "linear" && extra_time_coefficient {
4901        " (this looks like a model saved before the #2301 Weibull time-basis \
4902         change, which removed the redundant constant column; refit the model)"
4903    } else {
4904        ""
4905    }
4906}
4907
4908#[cfg(test)]
4909mod tests {
4910    use super::*;
4911    use crate::probability::{normal_cdf, normal_pdf};
4912
4913    #[test]
4914    fn competing_risks_covariance_mode_selects_exact_requested_matrix() {
4915        let conditional = ndarray::array![[1.0, 0.2], [0.2, 2.0]];
4916        let corrected = ndarray::array![[1.5, 0.4], [0.4, 3.0]];
4917
4918        let selected_conditional = select_survival_prediction_covariance(
4919            Some(&conditional),
4920            Some(&corrected),
4921            SurvivalPredictionCovarianceMode::Conditional,
4922        )
4923        .expect("conditional covariance");
4924        let selected_corrected = select_survival_prediction_covariance(
4925            Some(&conditional),
4926            Some(&corrected),
4927            SurvivalPredictionCovarianceMode::SmoothingCorrected,
4928        )
4929        .expect("smoothing-corrected covariance");
4930
4931        assert_eq!(selected_conditional, &conditional);
4932        assert_eq!(selected_corrected, &corrected);
4933        assert_eq!(
4934            SurvivalPredictionCovarianceMode::Conditional.as_str(),
4935            "conditional"
4936        );
4937        assert_eq!(
4938            SurvivalPredictionCovarianceMode::SmoothingCorrected.as_str(),
4939            "smoothing-corrected"
4940        );
4941    }
4942
4943    #[test]
4944    fn competing_risks_smoothing_covariance_never_falls_back() {
4945        let conditional = ndarray::array![[1.0]];
4946        let error = select_survival_prediction_covariance(
4947            Some(&conditional),
4948            None,
4949            SurvivalPredictionCovarianceMode::SmoothingCorrected,
4950        )
4951        .expect_err("a corrected request must not substitute conditional covariance");
4952        assert_eq!(
4953            error.to_string(),
4954            "fit result does not contain smoothing-corrected covariance"
4955        );
4956    }
4957
4958    #[test]
4959    fn posterior_quadrature_second_moment_honors_cross_coordinate_covariance() {
4960        let posterior_mean = ndarray::array![0.4, -0.2];
4961        let covariance = ndarray::array![[0.9, 0.35], [0.35, 0.6]];
4962        let mut functional_mean = 0.0_f64;
4963        let mut functional_second = 0.0_f64;
4964        let mut recovered_cross_covariance = 0.0_f64;
4965
4966        for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
4967            let functional = node[0] + 2.0 * node[1];
4968            functional_mean += weight * functional;
4969            functional_second += weight * functional * functional;
4970            recovered_cross_covariance +=
4971                weight * (node[0] - posterior_mean[0]) * (node[1] - posterior_mean[1]);
4972            Ok(())
4973        })
4974        .expect("joint posterior quadrature");
4975
4976        let expected_mean = posterior_mean[0] + 2.0 * posterior_mean[1];
4977        let expected_variance =
4978            covariance[[0, 0]] + 4.0 * covariance[[1, 1]] + 4.0 * covariance[[0, 1]];
4979        assert!((functional_mean - expected_mean).abs() <= 1e-12);
4980        assert!((recovered_cross_covariance - covariance[[0, 1]]).abs() <= 1e-12);
4981
4982        let mean_surface = Array2::from_elem((1, 1), functional_mean);
4983        let second_surface = Array2::from_elem((1, 1), functional_second);
4984        let standard_error = posterior_standard_error_matrix(
4985            &mean_surface,
4986            &second_surface,
4987            "joint-covariance witness",
4988        )
4989        .expect("posterior standard error");
4990        assert!((standard_error[[0, 0]].powi(2) - expected_variance).abs() <= 1e-11);
4991    }
4992
4993    #[test]
4994    fn posterior_quadrature_zero_covariance_has_zero_standard_error() {
4995        let posterior_mean = ndarray::array![0.25, -0.75];
4996        let covariance = Array2::<f64>::zeros((2, 2));
4997        let mut functional_mean = 0.0_f64;
4998        let mut functional_second = 0.0_f64;
4999        let mut node_count = 0usize;
5000
5001        for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
5002            let functional = node[0].exp() + node[1].sin();
5003            functional_mean += weight * functional;
5004            functional_second += weight * functional * functional;
5005            node_count += 1;
5006            Ok(())
5007        })
5008        .expect("rank-zero posterior quadrature");
5009
5010        assert_eq!(node_count, 1, "rank-zero covariance has one exact node");
5011        let standard_error = posterior_standard_error_matrix(
5012            &Array2::from_elem((1, 1), functional_mean),
5013            &Array2::from_elem((1, 1), functional_second),
5014            "rank-zero witness",
5015        )
5016        .expect("rank-zero posterior standard error");
5017        assert_eq!(standard_error[[0, 0]], 0.0);
5018    }
5019
5020    #[test]
5021    fn posterior_quadrature_keeps_cone_coordinates_feasible_and_unbiased() {
5022        // Coordinate 0 is a structural monotone-I-spline baseline time
5023        // coefficient the fit constrained to β_0 ≥ 0; coordinate 1 is an
5024        // unconstrained covariate intercept. The covariance loads coordinate 0
5025        // with a spread wider than β̂_0, so the untruncated √rank·σ sigma point
5026        // steps β_0 below zero — the exact #2375 infeasible-node signature that
5027        // manufactures a non-monotone RP baseline the plugin evaluator refuses.
5028        let posterior_mean = ndarray::array![0.354, -8.30];
5029        let covariance = ndarray::array![[0.2304, 0.30], [0.30, 0.9604]];
5030
5031        // Without the cone, the untruncated rule produces an infeasible node.
5032        let mut min_cone0_unconstrained = f64::INFINITY;
5033        for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, _| {
5034            min_cone0_unconstrained = min_cone0_unconstrained.min(node[0]);
5035            Ok(())
5036        })
5037        .expect("unconstrained quadrature");
5038        assert!(
5039            min_cone0_unconstrained < 0.0,
5040            "fixture must reproduce the infeasible-node bug (min β_0 = {min_cone0_unconstrained})"
5041        );
5042
5043        // With the cone, every node stays feasible AND the rule stays unbiased
5044        // (symmetric ± steps, unchanged weights → the posterior mean is exact).
5045        let mut mean0 = 0.0_f64;
5046        let mut mean1 = 0.0_f64;
5047        let mut weight_sum = 0.0_f64;
5048        let mut min_cone0 = f64::INFINITY;
5049        for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5050            assert!(
5051                node[0] >= -1e-12,
5052                "cone coordinate stepped below its β_0 ≥ 0 wall: {}",
5053                node[0]
5054            );
5055            min_cone0 = min_cone0.min(node[0]);
5056            mean0 += weight * node[0];
5057            mean1 += weight * node[1];
5058            weight_sum += weight;
5059            Ok(())
5060        })
5061        .expect("cone-truncated quadrature");
5062        assert!((weight_sum - 1.0).abs() <= 1e-12, "weights must sum to one");
5063        assert!(
5064            (mean0 - posterior_mean[0]).abs() <= 1e-12
5065                && (mean1 - posterior_mean[1]).abs() <= 1e-12,
5066            "cone truncation must leave the posterior mean unbiased (got [{mean0}, {mean1}])"
5067        );
5068
5069        // Truncation shrinks — never inflates — the represented spread along the
5070        // constrained coordinate (a truncated Gaussian has smaller variance).
5071        let mut var0_unconstrained = 0.0_f64;
5072        for_each_survival_posterior_node(&posterior_mean, &covariance, &[], |node, weight| {
5073            var0_unconstrained += weight * (node[0] - posterior_mean[0]).powi(2);
5074            Ok(())
5075        })
5076        .expect("unconstrained spread");
5077        let mut var0_cone = 0.0_f64;
5078        for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5079            var0_cone += weight * (node[0] - posterior_mean[0]).powi(2);
5080            Ok(())
5081        })
5082        .expect("cone spread");
5083        assert!(
5084            var0_cone <= var0_unconstrained + 1e-12 && var0_cone < var0_unconstrained,
5085            "cone spread {var0_cone} must be strictly smaller than the untruncated {var0_unconstrained}"
5086        );
5087    }
5088
5089    #[test]
5090    fn posterior_quadrature_cone_is_a_noop_far_from_the_wall() {
5091        // When β̂ sits comfortably inside the cone (every √rank·σ node stays
5092        // feasible), the fraction-to-boundary step never binds, so the cone rule
5093        // must reproduce the untruncated covariance to full precision — the fix
5094        // is inert on the healthy fits that dominate production.
5095        let posterior_mean = ndarray::array![40.0, -0.2];
5096        let covariance = ndarray::array![[0.9, 0.35], [0.35, 0.6]];
5097        let mut recovered_var0 = 0.0_f64;
5098        let mut recovered_cross = 0.0_f64;
5099        for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5100            recovered_var0 += weight * (node[0] - posterior_mean[0]).powi(2);
5101            recovered_cross +=
5102                weight * (node[0] - posterior_mean[0]) * (node[1] - posterior_mean[1]);
5103            Ok(())
5104        })
5105        .expect("cone quadrature far from the wall");
5106        assert!((recovered_var0 - covariance[[0, 0]]).abs() <= 1e-11);
5107        assert!((recovered_cross - covariance[[0, 1]]).abs() <= 1e-11);
5108    }
5109
5110    /// A cone coefficient sitting EXACTLY on its wall (`β̂_j = 0`) is the
5111    /// ordinary state of an active box face, not an edge case: the fit's
5112    /// `coefficient_lower_bounds` pins increments there routinely. The
5113    /// fraction-to-boundary limit is then `0 / |f_j| = 0`, so every direction
5114    /// that loads that coordinate collapses to the mean and contributes no
5115    /// spread, while directions that do not load it keep the full `√rank` step.
5116    ///
5117    /// This is the one place the rule cannot represent the posterior it is
5118    /// approximating: a truncated Gaussian at an active bound is ONE-SIDED and
5119    /// carries real mass, but no symmetric `±` pair can express that. Reporting
5120    /// zero spread there is the conservative feasible answer rather than a
5121    /// fabricated one, and pinning it here means a future switch to an
5122    /// asymmetric rule has to change this test deliberately instead of silently.
5123    #[test]
5124    fn posterior_quadrature_radius_collapses_on_an_active_bound() {
5125        // Distinct eigenvalues so the PSD factor is axis-aligned and "the
5126        // direction that loads the pinned coordinate" is unambiguous.
5127        let posterior_mean = ndarray::array![0.0, 0.75];
5128        let covariance = ndarray::array![[0.5, 0.0], [0.0, 0.2]];
5129
5130        let mut min_pinned = f64::INFINITY;
5131        let mut max_pinned = f64::NEG_INFINITY;
5132        let mut spread_unpinned = 0.0_f64;
5133        for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, weight| {
5134            min_pinned = min_pinned.min(node[0]);
5135            max_pinned = max_pinned.max(node[0]);
5136            spread_unpinned += weight * (node[1] - posterior_mean[1]).powi(2);
5137            Ok(())
5138        })
5139        .expect("active-bound quadrature");
5140
5141        assert!(
5142            min_pinned >= 0.0,
5143            "an active bound must never be crossed, got {min_pinned}"
5144        );
5145        assert!(
5146            max_pinned.abs() <= 1e-12,
5147            "a direction loading an active-bound coordinate carries zero symmetric spread, \
5148             but the coordinate reached {max_pinned}"
5149        );
5150        // The unconstrained coordinate is untouched: collapsing one direction
5151        // must not collapse the whole rule.
5152        assert!(
5153            (spread_unpinned - covariance[[1, 1]]).abs() <= 1e-11,
5154            "a coordinate outside the cone keeps its full spread, got {spread_unpinned} want {}",
5155            covariance[[1, 1]]
5156        );
5157    }
5158
5159    /// Round-off guard for the `β̂_j.max(0.0)` clamp in the fraction-to-boundary
5160    /// limit. A converged active-set coefficient can land a few ulps BELOW its
5161    /// wall, and the unclamped ratio `β̂_j / |f_j|` would then be NEGATIVE — a
5162    /// negative step that silently inverts the `±` geometry of that direction
5163    /// instead of shrinking it. The clamp sends the limit to zero, so the pair
5164    /// collapses onto `β̂` and truncation never drives a coordinate further
5165    /// outside the cone than the fit already left it.
5166    #[test]
5167    fn posterior_quadrature_clamps_a_roundoff_negative_cone_coordinate() {
5168        let roundoff_below_wall = -1e-15_f64;
5169        let posterior_mean = ndarray::array![roundoff_below_wall, 0.75];
5170        let covariance = ndarray::array![[0.5, 0.0], [0.0, 0.2]];
5171
5172        let mut nodes = Vec::new();
5173        for_each_survival_posterior_node(&posterior_mean, &covariance, &[0], |node, _| {
5174            nodes.push(node[0]);
5175            Ok(())
5176        })
5177        .expect("round-off-negative cone quadrature");
5178
5179        for value in &nodes {
5180            assert!(
5181                *value >= roundoff_below_wall,
5182                "truncation must never push a cone coordinate further below the wall than the \
5183                 fit left it: node {value} < β̂ {roundoff_below_wall}"
5184            );
5185            assert!(
5186                (*value - roundoff_below_wall).abs() <= 1e-12,
5187                "a coordinate at the wall carries no spread, got {value}"
5188            );
5189        }
5190    }
5191
5192    #[test]
5193    fn probit_survival_hazard_uses_density_over_survival() {
5194        let eta = 2.0;
5195        let eta_t = 0.3;
5196
5197        let (cum, hazard) =
5198            probit_survival_hazard_components(eta, eta_t).expect("valid components");
5199
5200        let survival = normal_cdf(-eta);
5201        let expected_cum = -survival.ln();
5202        let expected_hazard = normal_pdf(eta) * eta_t / survival;
5203        assert!((cum - expected_cum).abs() <= 1e-14);
5204        assert!((hazard - expected_hazard).abs() <= 1e-14);
5205    }
5206
5207    #[test]
5208    fn probit_survival_hazard_stays_finite_in_right_tail() {
5209        let eta = 40.0;
5210        let eta_t = 9.694_340_360_912_401e-5;
5211
5212        let event_density =
5213            (-0.5_f64 * eta * eta).exp() / (2.0 * std::f64::consts::PI).sqrt() * eta_t;
5214        assert_eq!(event_density, 0.0);
5215
5216        let (cum, hazard) =
5217            probit_survival_hazard_components(eta, eta_t).expect("valid tail components");
5218        assert!(cum > 800.0, "right-tail cumulative hazard was {cum}");
5219        assert!(
5220            (3.87e-3..3.89e-3).contains(&hazard),
5221            "right-tail hazard was {hazard}"
5222        );
5223    }
5224
5225    #[test]
5226    fn probit_survival_hazard_accepts_zero_time_derivative_as_flat_hazard() {
5227        let (cum, hazard) =
5228            probit_survival_hazard_components(1.0, 0.0).expect("zero derivative is flat hazard");
5229        assert!(cum > 0.0);
5230        assert_eq!(hazard, 0.0);
5231    }
5232
5233    #[test]
5234    fn marginal_slope_index_derivative_clamps_extrapolation_negative_to_flat_hazard() {
5235        // The #1040 end-to-end blocker: at a prediction horizon outside the
5236        // training exit times, the penalized baseline derivative q'(t) can dip
5237        // negative (e.g. the reported eta_t=-0.00135), producing a negative
5238        // index time-derivative the strict validator used to reject. The
5239        // physical hazard floor is 0, so the clamp must turn it into a flat
5240        // hazard the validator accepts — keeping predict/CIF runnable.
5241        let deta_dq = (1.0_f64 + 0.4 * 0.4).sqrt(); // rigid c = sqrt(1+sb^2) >= 1
5242        let qd_with_wiggle = -1.35e-3;
5243        let eta_t = marginal_slope_index_derivative_at_horizon(deta_dq, qd_with_wiggle);
5244        assert_eq!(
5245            eta_t, 0.0,
5246            "negative extrapolation derivative must clamp to 0"
5247        );
5248        // Downstream validator now accepts it as a flat-hazard point.
5249        let (cum, hazard) = probit_survival_hazard_components(-0.563, eta_t)
5250            .expect("clamped flat-hazard prediction must validate");
5251        assert!(
5252            cum >= 0.0,
5253            "cumulative hazard must be well-posed, got {cum}"
5254        );
5255        assert_eq!(
5256            hazard, 0.0,
5257            "clamped derivative gives zero instantaneous hazard"
5258        );
5259    }
5260
5261    #[test]
5262    fn marginal_slope_index_derivative_preserves_positive_and_nonfinite() {
5263        // A genuinely positive derivative passes through unchanged (scaled by
5264        // the chain factor), and a non-finite value is left for the strict
5265        // validator to reject as a real numerical failure rather than masked.
5266        let positive = marginal_slope_index_derivative_at_horizon(1.25, 0.8);
5267        assert!(
5268            (positive - 1.0).abs() <= 1e-15,
5269            "positive derivative scaled by chain factor"
5270        );
5271        let nonfinite = marginal_slope_index_derivative_at_horizon(1.25, f64::NAN);
5272        assert!(
5273            nonfinite.is_nan(),
5274            "non-finite derivative passes through unclamped"
5275        );
5276        assert!(
5277            probit_survival_hazard_components(0.5, nonfinite).is_err(),
5278            "non-finite derivative must still be rejected by the validator"
5279        );
5280    }
5281
5282    #[test]
5283    fn probit_survival_hazard_rejects_infinite_time_derivative() {
5284        let err = probit_survival_hazard_components(1.0, f64::INFINITY)
5285            .expect_err("infinite derivative should be invalid");
5286        assert!(
5287            err.to_string()
5288                .contains("invalid survival index derivative")
5289        );
5290    }
5291
5292    #[test]
5293    fn probit_survival_hazard_rejects_nan_inputs() {
5294        // The upstream input gate is the only line that rejects NaN — the
5295        // output gate (`>= 0.0`) is dead-code for finite input because
5296        // `signed_probit_logcdf_and_mills_ratio` is provably NaN-free on the
5297        // finite domain (every internal branch clamps `erfcx`/`cdf` away from
5298        // zero). Pin both NaN slots so the input gate cannot regress.
5299        let err_eta =
5300            probit_survival_hazard_components(f64::NAN, 0.5).expect_err("NaN eta must be rejected");
5301        assert!(
5302            err_eta
5303                .to_string()
5304                .contains("invalid survival index derivative")
5305        );
5306        let err_dt = probit_survival_hazard_components(1.0, f64::NAN)
5307            .expect_err("NaN eta_derivative must be rejected");
5308        assert!(
5309            err_dt
5310                .to_string()
5311                .contains("invalid survival index derivative")
5312        );
5313    }
5314
5315    #[test]
5316    fn probit_survival_hazard_rejects_negative_time_derivative() {
5317        // The CDF S(t) = Phi(-eta(t)) is monotone in t iff eta'(t) > 0. A
5318        // negative slope would give a non-monotone survival curve, which is
5319        // not a valid survival function.
5320        let err = probit_survival_hazard_components(1.0, -0.5)
5321            .expect_err("negative derivative should be invalid");
5322        assert!(
5323            err.to_string()
5324                .contains("invalid survival index derivative")
5325        );
5326    }
5327
5328    #[test]
5329    fn royston_parmar_hazard_is_cumulative_hazard_derivative() {
5330        let eta = 2.0_f64.ln();
5331        let eta_t = 0.25;
5332
5333        let (cum, hazard) =
5334            royston_parmar_survival_hazard_components(eta, eta_t).expect("valid components");
5335
5336        assert!((cum - 2.0).abs() <= 1e-14);
5337        assert!((hazard - 0.5).abs() <= 1e-14);
5338        assert_ne!(hazard, cum);
5339    }
5340
5341    #[test]
5342    fn royston_parmar_hazard_rejects_negative_log_hazard_derivative() {
5343        // A negative time-derivative of log Λ(t) means a *decreasing* cumulative
5344        // hazard — not a valid survival model. Only the genuinely-negative slope
5345        // is rejected; the zero boundary is valid (see the sibling test below).
5346        let err = royston_parmar_survival_hazard_components(0.0, -0.5)
5347            .expect_err("negative derivative should be invalid");
5348        assert!(
5349            err.to_string()
5350                .contains("invalid log-cumulative-hazard derivative")
5351        );
5352    }
5353
5354    #[test]
5355    fn royston_parmar_hazard_accepts_zero_derivative_as_flat_boundary() {
5356        // #1564: a monotone I-spline cumulative hazard is flat beyond its last
5357        // interior knot, so `d(log Λ)/dt == 0` exactly on any grid node past the
5358        // training support. That is a *valid* prediction (zero instantaneous
5359        // hazard, locally constant survival), not a numerical failure. The old
5360        // strict `> 0.0` gate rejected it and crashed saved-model RP predict.
5361        let eta = 1.9909019457445971_f64; // the exact η from the #1564 report
5362        let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
5363            .expect("zero derivative is a valid flat boundary, not an error");
5364        assert!((cum - eta.exp()).abs() <= 1e-12, "cum = Λ(t) = exp(η)");
5365        assert_eq!(
5366            hazard, 0.0,
5367            "flat cumulative hazard ⇒ zero instantaneous hazard"
5368        );
5369        // Survival is finite and well-defined at the boundary.
5370        let survival = (-cum).exp().clamp(0.0, 1.0);
5371        assert!(survival.is_finite() && (0.0..=1.0).contains(&survival));
5372    }
5373
5374    #[test]
5375    fn royston_parmar_hazard_zero_derivative_in_saturated_tail_is_zero_not_nan() {
5376        // The dangerous corner: a saturated tail (η large ⇒ Λ = exp(η) = +∞) that
5377        // also lands past the I-spline support (derivative == 0). The naive
5378        // product `+∞ * 0.0` is `NaN`, which would (a) trip the components guard
5379        // and (b) serialize to JSON `null` and break the Python parse (#1564,
5380        // bug 1). The hazard must resolve to the mathematically correct `0`.
5381        let eta = 1000.0_f64;
5382        assert!(
5383            eta.exp().is_infinite(),
5384            "test premise: exp(1000) overflows to +∞"
5385        );
5386        assert!(
5387            (f64::INFINITY * 0.0).is_nan(),
5388            "test premise: the naive product is NaN"
5389        );
5390        let (cum, hazard) = royston_parmar_survival_hazard_components(eta, 0.0)
5391            .expect("saturated + flat boundary must be valid");
5392        assert!(cum.is_infinite() && cum > 0.0, "cum saturates to +∞");
5393        assert_eq!(hazard, 0.0, "hazard at a flat boundary is 0, never NaN");
5394    }
5395
5396    #[test]
5397    fn royston_parmar_hazard_propagates_saturation_as_infinity() {
5398        // η = log Λ(t); a saturated RP fit can drive η well past the
5399        // exp(709.78)≈f64::MAX boundary in the right tail. The math is
5400        // S(t)→0, h(t)→∞; the helper must not reject this regime, because the
5401        // inner solver has already accepted the underlying fit.
5402        let eta = 1000.0_f64;
5403        let eta_t = 0.5_f64;
5404        assert!(eta.exp().is_infinite(), "test premise: exp(1000) overflows");
5405
5406        let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
5407            .expect("saturated RP fit must yield a result, not an error");
5408        assert!(cum.is_infinite() && cum > 0.0, "expected +∞ cum, got {cum}");
5409        assert!(
5410            hazard.is_infinite() && hazard > 0.0,
5411            "expected +∞ hazard, got {hazard}"
5412        );
5413
5414        // Consumer materializes survival via exp(-cum).clamp(0,1).
5415        let survival = (-cum).exp().clamp(0.0, 1.0);
5416        assert_eq!(survival, 0.0, "saturated cum_hazard must give survival 0");
5417    }
5418
5419    #[test]
5420    fn royston_parmar_hazard_rejects_nan_eta() {
5421        let err = royston_parmar_survival_hazard_components(f64::NAN, 0.5)
5422            .expect_err("NaN eta should be invalid");
5423        assert!(
5424            err.to_string()
5425                .contains("invalid log-cumulative-hazard derivative")
5426        );
5427    }
5428
5429    #[test]
5430    fn royston_parmar_hazard_left_tail_collapses_to_zero() {
5431        // η = log Λ(t); η → -∞ means Λ(t) → 0, so cum_hazard underflows to 0
5432        // and hazard rate underflows to 0. Survival → 1. No error.
5433        let eta = -1000.0_f64;
5434        let eta_t = 2.0_f64;
5435        assert_eq!(eta.exp(), 0.0, "test premise: exp(-1000) underflows to 0");
5436
5437        let (cum, hazard) = royston_parmar_survival_hazard_components(eta, eta_t)
5438            .expect("RP left tail must remain valid");
5439        assert_eq!(
5440            cum, 0.0,
5441            "left-tail cum_hazard should underflow to 0, got {cum}"
5442        );
5443        assert_eq!(
5444            hazard, 0.0,
5445            "left-tail hazard should underflow to 0, got {hazard}"
5446        );
5447
5448        // Consumer: survival = exp(-0) = 1.
5449        let survival = (-cum).exp().clamp(0.0, 1.0);
5450        assert_eq!(survival, 1.0);
5451    }
5452
5453    #[test]
5454    fn probit_survival_hazard_left_tail_collapses_to_zero() {
5455        // η→-∞ mirror of the right-tail test: survival → 1, hazard → 0.
5456        // Asymptote: Mills(η) = φ(η)/Φ(-η) → 0 as η → -∞ (φ underflows,
5457        // Φ(-η) → 1).  No error, no NaN, no spurious negativity.
5458        let eta = -40.0_f64;
5459        let eta_t = 1.5_f64;
5460
5461        let (cum, hazard) =
5462            probit_survival_hazard_components(eta, eta_t).expect("left tail must remain valid");
5463        assert!(
5464            (0.0..1e-300).contains(&cum),
5465            "left-tail cum should be ~0, got {cum}"
5466        );
5467        assert_eq!(
5468            hazard, 0.0,
5469            "left-tail hazard should underflow to 0, got {hazard}"
5470        );
5471    }
5472
5473    #[test]
5474    fn location_scale_logit_hazard_is_failure_slope_over_survival() {
5475        let eta = 0.7;
5476        let eta_t = 0.4;
5477
5478        let hazard = location_scale_hazard_component(
5479            eta,
5480            eta_t,
5481            &InverseLink::Standard(StandardLink::Logit),
5482        )
5483        .expect("valid logit hazard");
5484
5485        let failure = 1.0 / (1.0 + (-eta).exp());
5486        assert!((hazard - failure * eta_t).abs() <= 1e-14);
5487    }
5488
5489    #[test]
5490    fn location_scale_cloglog_hazard_matches_log_cumulative_hazard_derivative() {
5491        let eta = 1.5;
5492        let eta_t = 0.2;
5493
5494        let hazard = location_scale_hazard_component(
5495            eta,
5496            eta_t,
5497            &InverseLink::Standard(StandardLink::CLogLog),
5498        )
5499        .expect("valid cloglog hazard");
5500
5501        assert!((hazard - eta.exp() * eta_t).abs() <= 1e-14);
5502    }
5503
5504    // ---- IPCW Brier score (Graf et al. 1999) -------------------------------
5505
5506    #[test]
5507    fn kaplan_meier_censoring_is_right_continuous_step() {
5508        // Two censorings (events flipped) at t=4 and t=8; deaths at t=2,6.
5509        let time = [2.0, 4.0, 6.0, 8.0];
5510        let event = [1.0, 0.0, 1.0, 0.0];
5511        let g = KaplanMeier::fit_censoring(&time, &event);
5512        // Before the first censoring the censoring-survival is 1.
5513        assert!((g.at(0.0) - 1.0).abs() <= 1e-15);
5514        assert!((g.at(2.0) - 1.0).abs() <= 1e-15);
5515        assert!((g.at(3.999) - 1.0).abs() <= 1e-15);
5516        // At t=4 the at-risk set {4,6,8} loses one to censoring: G = 2/3.
5517        assert!((g.at(4.0) - 2.0 / 3.0).abs() <= 1e-12);
5518        assert!((g.at(5.0) - 2.0 / 3.0).abs() <= 1e-12);
5519        // A death at t=6 does not move the censoring KM.
5520        assert!((g.at(6.0) - 2.0 / 3.0).abs() <= 1e-12);
5521        // At t=8 the last (sole) at-risk subject is censored: G collapses to 0.
5522        assert!(g.at(8.0).abs() <= 1e-15);
5523    }
5524
5525    #[test]
5526    fn ipcw_brier_no_censoring_reduces_to_plain_brier() {
5527        // With no censoring G(t) ≡ 1, so the IPCW Brier is the ordinary Brier of
5528        // the predicted survival against the alive-indicator I(T_i > tau).
5529        let s_pred = [0.3, 0.7, 0.6, 0.2];
5530        let time = [2.0, 8.0, 10.0, 3.0];
5531        let event = [1.0, 1.0, 0.0, 1.0];
5532        let tau = 5.0;
5533        let g = KaplanMeier::fit_censoring(&time, &event);
5534        let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
5535        // targets: dead→0 (subj1,4), alive→1 (subj2,3).
5536        let expected =
5537            (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2) + (1.0 - 0.6f64).powi(2) + 0.2f64.powi(2))
5538                / 4.0;
5539        assert!(
5540            (bs - expected).abs() <= 1e-12,
5541            "bs={bs} expected={expected}"
5542        );
5543    }
5544
5545    #[test]
5546    fn ipcw_brier_reweights_by_inverse_censoring_probability() {
5547        // Hand-computed Graf estimator with real censoring weights.
5548        // times/events: death@2, cens@4, death@6, cens@8; tau=5.
5549        // Censoring KM: G(5)=2/3 (one censoring at t=4 among {4,6,8}); G(2)=1.
5550        let s_pred = [0.4, 0.5, 0.7, 0.8];
5551        let time = [2.0, 4.0, 6.0, 8.0];
5552        let event = [1.0, 0.0, 1.0, 0.0];
5553        let tau = 5.0;
5554        let g = KaplanMeier::fit_censoring(&time, &event);
5555        let bs = ipcw_brier_score(&s_pred, &time, &event, tau, |t| g.at(t)).unwrap();
5556        // subj1 dead by 5: weight 1/G(2)=1, contrib 0.4²=0.16.
5557        // subj2 censored before 5: contributes 0.
5558        // subj3 alive: weight 1/G(5)=1.5, contrib 1.5·0.3²=0.135.
5559        // subj4 alive: weight 1/G(5)=1.5, contrib 1.5·0.2²=0.06.
5560        let expected = (0.16 + 0.0 + 0.135 + 0.06) / 4.0;
5561        assert!(
5562            (bs - expected).abs() <= 1e-12,
5563            "bs={bs} expected={expected}"
5564        );
5565    }
5566
5567    #[test]
5568    fn ipcw_brier_drops_invalid_rows_from_both_numerator_and_denominator() {
5569        // A NaN-time row and a non-positive-time row must not be counted at all.
5570        let s_pred = [0.3, 0.7, 0.5, 0.5];
5571        let time = [2.0, 8.0, f64::NAN, -1.0];
5572        let event = [1.0, 1.0, 1.0, 0.0];
5573        let g = KaplanMeier::fit_censoring(&time, &event);
5574        let bs = ipcw_brier_score(&s_pred, &time, &event, 5.0, |t| g.at(t)).unwrap();
5575        // Only subj1 (dead, contrib 0.3²) and subj2 (alive, contrib 0.3²) count;
5576        // censoring KM has no censorings so G≡1.
5577        let expected = (0.3f64.powi(2) + (1.0 - 0.7f64).powi(2)) / 2.0;
5578        assert!(
5579            (bs - expected).abs() <= 1e-12,
5580            "bs={bs} expected={expected}"
5581        );
5582    }
5583
5584    #[test]
5585    fn integrated_ipcw_brier_of_constant_brier_is_that_constant() {
5586        // A survival matrix whose every column equals a perfect classifier yields
5587        // BS(t)=0 at every grid point, so the integral is 0.
5588        let time = [2.0, 8.0, 10.0, 3.0];
5589        let event = [1.0, 1.0, 0.0, 1.0];
5590        let grid = [0.0, 1.0, 2.5, 4.0, 6.0];
5591        // Perfect prediction at every grid time given the (no-censoring) data is
5592        // not generally achievable, so instead test the integral of a literally
5593        // constant-in-time Brier: replicate one column across the grid.
5594        let col = [0.3, 0.7, 0.6, 0.2];
5595        let mut surv = Array2::<f64>::zeros((4, grid.len()));
5596        for k in 0..grid.len() {
5597            for i in 0..4 {
5598                surv[[i, k]] = col[i];
5599            }
5600        }
5601        let g = KaplanMeier::fit_censoring(&time, &event);
5602        let per_time = ipcw_brier_score(&col, &time, &event, grid[2], |t| g.at(t)).unwrap();
5603        // Because the predicted survival is identical at every grid time, BS(t)
5604        // is *not* constant (tau changes which subjects are "alive"), so use a
5605        // direct trapezoid as the oracle.
5606        let mut oracle_pts = Vec::new();
5607        for k in 0..grid.len() {
5608            oracle_pts.push((
5609                grid[k],
5610                ipcw_brier_score(&col, &time, &event, grid[k], |t| g.at(t)).unwrap(),
5611            ));
5612        }
5613        let mut integral = 0.0;
5614        for w in oracle_pts.windows(2) {
5615            integral += 0.5 * (w[0].1 + w[1].1) * (w[1].0 - w[0].0);
5616        }
5617        let oracle = integral / (grid[grid.len() - 1] - grid[0]);
5618        let ibs =
5619            integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
5620                g.at(t)
5621            })
5622            .unwrap();
5623        assert!((ibs - oracle).abs() <= 1e-12, "ibs={ibs} oracle={oracle}");
5624        // Sanity: per-time value is in a sensible [0,1]-ish range.
5625        assert!(per_time >= 0.0);
5626    }
5627
5628    #[test]
5629    fn integrated_ipcw_brier_respects_the_horizon_cutoff() {
5630        let time = [2.0, 8.0, 10.0, 3.0];
5631        let event = [1.0, 1.0, 0.0, 1.0];
5632        let grid = [0.0, 2.0, 4.0, 100.0];
5633        let col = [0.3, 0.7, 0.6, 0.2];
5634        let mut surv = Array2::<f64>::zeros((4, grid.len()));
5635        for k in 0..grid.len() {
5636            for i in 0..4 {
5637                surv[[i, k]] = col[i];
5638            }
5639        }
5640        let g = KaplanMeier::fit_censoring(&time, &event);
5641        // Horizon 5 drops the extrapolation point at t=100: integral runs [0,4].
5642        let restricted =
5643            integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, 5.0, |t| g.at(t))
5644                .unwrap();
5645        let full =
5646            integrated_ipcw_brier_score(surv.view(), &time, &event, &grid, f64::INFINITY, |t| {
5647                g.at(t)
5648            })
5649            .unwrap();
5650        // The huge [4,100] tail interval dominates the full integral, so the two
5651        // must differ substantially — the horizon guard is doing real work.
5652        assert!(
5653            (restricted - full).abs() > 1e-3,
5654            "horizon cutoff had no effect: restricted={restricted} full={full}"
5655        );
5656    }
5657
5658    #[test]
5659    fn integrated_ipcw_brier_rejects_malformed_grids() {
5660        let time = [2.0, 8.0];
5661        let event = [1.0, 0.0];
5662        let surv = Array2::<f64>::from_elem((2, 3), 0.5);
5663        let g = KaplanMeier::fit_censoring(&time, &event);
5664        // Non-increasing grid.
5665        let bad = [0.0, 2.0, 1.0];
5666        assert!(
5667            integrated_ipcw_brier_score(surv.view(), &time, &event, &bad, f64::INFINITY, |t| g
5668                .at(t))
5669            .is_none()
5670        );
5671        // Grid width mismatched to the survival matrix.
5672        let short = [0.0, 1.0];
5673        assert!(
5674            integrated_ipcw_brier_score(surv.view(), &time, &event, &short, f64::INFINITY, |t| g
5675                .at(t))
5676            .is_none()
5677        );
5678    }
5679
5680    /// `S(t) = exp(-rate*t)` sampled on `times`, one identical row per subject.
5681    fn exponential_survival_result(times: Vec<f64>, rate: f64, rows: usize) -> SurvivalPredictResult {
5682        let t = times.len();
5683        let mut survival = Array2::<f64>::zeros((rows, t));
5684        let mut hazard = Array2::<f64>::zeros((rows, t));
5685        let mut cumulative_hazard = Array2::<f64>::zeros((rows, t));
5686        for i in 0..rows {
5687            for (j, &time) in times.iter().enumerate() {
5688                survival[[i, j]] = (-rate * time).exp();
5689                hazard[[i, j]] = rate;
5690                cumulative_hazard[[i, j]] = rate * time;
5691            }
5692        }
5693        SurvivalPredictResult {
5694            times,
5695            hazard,
5696            survival,
5697            cumulative_hazard,
5698            linear_predictor: Array1::zeros(rows),
5699            likelihood_mode: SurvivalLikelihoodMode::MarginalSlope,
5700            survival_se: None,
5701            eta_se: None,
5702            covariance_source: None,
5703        }
5704    }
5705
5706    #[test]
5707    fn rmst_over_prediction_horizon_matches_the_exponential_closed_form() {
5708        // For S(t) = exp(-rate*t) the restricted mean has a closed form,
5709        // RMST(tau) = (1 - exp(-rate*tau)) / rate, so the trapezoid sum is
5710        // checked against an exact value rather than against itself.
5711        let rate = 0.35_f64;
5712        let tau = 4.0_f64;
5713        let steps = 4000_usize;
5714        let times: Vec<f64> = (1..=steps)
5715            .map(|k| tau * (k as f64) / (steps as f64))
5716            .collect();
5717        let result = exponential_survival_result(times, rate, 3);
5718
5719        let rmst = result
5720            .rmst_over_prediction_horizon()
5721            .expect("a positive finite grid yields an RMST column");
5722        let expected = (1.0 - (-rate * tau).exp()) / rate;
5723
5724        assert!((rmst.tau - tau).abs() < 1e-12, "tau = {}", rmst.tau);
5725        assert_eq!(rmst.values.len(), 3);
5726        for value in rmst.values.iter() {
5727            // Trapezoid error on a convex curve is O(h^2); h = tau/steps here,
5728            // so 1e-6 is several orders above the discretization floor and
5729            // still far below any difference that would matter clinically.
5730            assert!(
5731                (value - expected).abs() < 1e-6,
5732                "rmst {value} vs closed form {expected}"
5733            );
5734        }
5735    }
5736
5737    #[test]
5738    fn rmst_over_prediction_horizon_integrates_to_the_last_grid_time() {
5739        let times = vec![0.5, 1.0, 2.5, 6.0];
5740        let result = exponential_survival_result(times.clone(), 0.2, 2);
5741
5742        let horizon = result
5743            .rmst_over_prediction_horizon()
5744            .expect("non-empty grid");
5745        let explicit = result
5746            .restricted_mean_survival_time(6.0)
5747            .expect("explicit tau at the same horizon");
5748
5749        assert_eq!(horizon.tau, 6.0, "tau is the last grid point");
5750        assert_eq!(horizon.values, explicit, "no second integration policy");
5751    }
5752
5753    #[test]
5754    fn rmst_over_prediction_horizon_declines_an_empty_or_degenerate_grid() {
5755        let empty = exponential_survival_result(Vec::new(), 0.2, 2);
5756        assert!(empty.rmst_over_prediction_horizon().is_none(), "empty grid");
5757
5758        // A grid whose only point is the time origin encloses no area.
5759        let origin_only = exponential_survival_result(vec![0.0], 0.2, 2);
5760        assert!(
5761            origin_only.rmst_over_prediction_horizon().is_none(),
5762            "tau = 0 encloses no area"
5763        );
5764    }
5765
5766    #[test]
5767    fn rmst_over_prediction_horizon_declines_a_non_finite_curve() {
5768        let mut result = exponential_survival_result(vec![1.0, 2.0], 0.2, 2);
5769        result.survival[[1, 0]] = f64::NAN;
5770        assert!(
5771            result.rmst_over_prediction_horizon().is_none(),
5772            "a NaN anywhere on the integrated span refuses the whole column"
5773        );
5774    }
5775
5776    #[test]
5777    fn overall_rmst_over_prediction_horizon_reads_the_all_cause_surface() {
5778        // Two endpoints, each with constant hazard `rate`, so the all-cause
5779        // survival is exp(-2*rate*t) and the closed form applies to it.
5780        let rate = 0.25_f64;
5781        let tau = 3.0_f64;
5782        let steps = 3000_usize;
5783        let times: Vec<f64> = (1..=steps)
5784            .map(|k| tau * (k as f64) / (steps as f64))
5785            .collect();
5786        let rows = 2_usize;
5787        let t = times.len();
5788        let mut overall_survival = Array2::<f64>::zeros((rows, t));
5789        for i in 0..rows {
5790            for (j, &time) in times.iter().enumerate() {
5791                overall_survival[[i, j]] = (-2.0 * rate * time).exp();
5792            }
5793        }
5794        let per_cause = Array2::<f64>::zeros((rows, t));
5795        let result = CompetingRisksPredictResult {
5796            times,
5797            endpoint_names: vec!["a".to_string(), "b".to_string()],
5798            hazard: vec![per_cause.clone(), per_cause.clone()],
5799            survival: vec![per_cause.clone(), per_cause.clone()],
5800            cumulative_hazard: vec![per_cause.clone(), per_cause.clone()],
5801            cif: vec![per_cause.clone(), per_cause],
5802            overall_survival,
5803            linear_predictor: vec![Array1::zeros(rows), Array1::zeros(rows)],
5804            likelihood_mode: SurvivalLikelihoodMode::MarginalSlope,
5805            covariance_source: None,
5806            hazard_se: None,
5807            survival_se: None,
5808            cumulative_hazard_se: None,
5809            cif_se: None,
5810            overall_survival_se: None,
5811            eta_se: None,
5812        };
5813
5814        let rmst = result
5815            .overall_rmst_over_prediction_horizon()
5816            .expect("all-cause RMST over a positive grid");
5817        let expected = (1.0 - (-2.0 * rate * tau).exp()) / (2.0 * rate);
5818
5819        assert!((rmst.tau - tau).abs() < 1e-12);
5820        for value in rmst.values.iter() {
5821            assert!(
5822                (value - expected).abs() < 1e-6,
5823                "all-cause rmst {value} vs closed form {expected}"
5824            );
5825        }
5826    }
5827
5828}
5829
5830/// Multiplier applied to the Weibull baseline scale when no time-basis knots are
5831/// available, so the default surface grid reaches into the right tail of the
5832/// fitted distribution rather than stopping at the characteristic time.
5833const SURVIVAL_DEFAULT_GRID_SCALE_MARGIN: f64 = 5.0;
5834
5835/// Training-time upper bound for the default survival surface grid, read from
5836/// the saved model rather than the prediction frame.
5837///
5838/// The default surface grid must be a property of the FITTED model, not of the
5839/// `exit` placeholder a caller happens to put in the prediction frame. A small
5840/// placeholder `exit` previously shrank the grid to `[entry, exit]` and silently
5841/// truncated the surface (#896); a large one stretched the fixed grid past the
5842/// fitted range and coarsened every in-range cell (#1717).
5843///
5844/// Returns `None` when the model carries neither a time-basis knot vector nor a
5845/// usable training range / baseline scale (the caller then falls back to the
5846/// prediction-frame range alone, preserving the prior behavior).
5847pub fn survival_training_time_upper_bound(payload: &FittedModelPayload) -> Option<f64> {
5848    if let Some(knots) = payload.survival_time_knots.as_ref() {
5849        let max_log_knot = knots
5850            .iter()
5851            .copied()
5852            .filter(|value| value.is_finite())
5853            .fold(f64::NEG_INFINITY, f64::max);
5854        if max_log_knot.is_finite() {
5855            let hi = max_log_knot.exp();
5856            if hi.is_finite() && hi > 0.0 {
5857                return Some(hi);
5858            }
5859        }
5860    }
5861
5862    // No time-basis knots (the linear Weibull baseline). Two candidate anchors:
5863    //
5864    //   * the model's recorded TRAINING time support — the upper end of the
5865    //     survival exit column's training range — which is the true observed
5866    //     upper bound of the fitted distribution; and
5867    //   * a margin over the parametric Weibull `survival_baseline_scale`, to
5868    //     reach into the right tail.
5869    //
5870    // Take the LARGER so the surface grid always covers the observed time range.
5871    // The scale field alone is unreliable here: on the covariate-driven Weibull
5872    // parameterization the baseline scale is absorbed into the linear predictor
5873    // and `survival_baseline_scale` is left as a degenerate floor sentinel
5874    // (≈ SURVIVAL_TIME_FLOOR ≈ 1e-9). Anchoring on it alone collapsed the grid
5875    // to ~0 and truncated every `survival_at` query past the prediction frame's
5876    // `exit` placeholder, even for times well inside the fitted range (#896).
5877    let mut upper = f64::NEG_INFINITY;
5878    if let Some(training_hi) = survival_training_exit_upper_bound(payload) {
5879        upper = upper.max(training_hi);
5880    }
5881    if let Some(scale) = payload.survival_baseline_scale
5882        && scale.is_finite()
5883        && scale > 0.0
5884    {
5885        upper = upper.max(scale * SURVIVAL_DEFAULT_GRID_SCALE_MARGIN);
5886    }
5887    (upper.is_finite() && upper > 0.0).then_some(upper)
5888}
5889
5890/// Upper end of the survival exit column's recorded training range.
5891///
5892/// Used to anchor the default survival-surface grid to the fitted model's
5893/// observed time support when the parametric baseline carries no usable time
5894/// signal (the linear Weibull basis: no knots, and a degenerate
5895/// `survival_baseline_scale` sentinel — #896). Returns `None` when the model
5896/// carries no training-range metadata or the exit column cannot be located.
5897fn survival_training_exit_upper_bound(payload: &FittedModelPayload) -> Option<f64> {
5898    let exit_name = payload.survival_exit.as_deref()?;
5899    let headers = payload.training_headers.as_ref()?;
5900    let ranges = payload.training_feature_ranges.as_ref()?;
5901    let idx = headers.iter().position(|h| h == exit_name)?;
5902    let (_, hi) = *ranges.get(idx)?;
5903    (hi.is_finite() && hi > 0.0).then_some(hi)
5904}
5905
5906/// The default survival-surface time grid: 64 uniform points spanning the
5907/// prediction frame's `[min entry, max exit]`, with the upper edge anchored to
5908/// the fitted model's training time support when one is supplied.
5909///
5910/// This is the SINGLE owner of the default-grid policy for every frontend
5911/// (#2470); it used to live only in the Python bindings, so `gam predict` and
5912/// `model.predict()` produced different survival surfaces by default.
5913///
5914/// The `entry_name == None` case is the right-censored shorthand
5915/// `Surv(time, event)`: every subject enters at time zero, so the grid lower
5916/// bound is zero and there is no entry column to read per row. The anchor CAPS
5917/// (and floors) the frame's `hi` — the frame's `exit` placeholder is a
5918/// semantically meaningless response value that `survival_at` ignores, so it
5919/// must neither truncate the surface below the fitted range (#896) nor stretch
5920/// the fixed 64-point grid past it (#1717); query times legitimately beyond
5921/// the support are handled by `survival_at`'s extrapolation (#1595).
5922pub fn default_survival_time_grid(
5923    formula: &str,
5924    dataset: &EncodedDataset,
5925    training_time_upper: Option<f64>,
5926) -> Result<Option<Vec<f64>>, String> {
5927    let parsed = gam_terms::inference::formula_dsl::parse_formula(formula)
5928        .map_err(|err| format!("failed to parse survival formula: {err}"))?;
5929    let Some((entry_name, exit_name, _event_name)) =
5930        gam_terms::inference::formula_dsl::parse_surv_response(&parsed.response)
5931            .map_err(|err| format!("failed to parse Surv(...) response: {err}"))?
5932    else {
5933        return Ok(None);
5934    };
5935
5936    let header_to_index: HashMap<&str, usize> = dataset
5937        .headers
5938        .iter()
5939        .enumerate()
5940        .map(|(index, name)| (name.as_str(), index))
5941        .collect();
5942    let entry_idx = match entry_name.as_deref() {
5943        Some(name) => match header_to_index.get(name).copied() {
5944            Some(idx) => Some(idx),
5945            None => {
5946                return Err(format!(
5947                    "survival prediction data is missing required time column(s): {name}"
5948                ));
5949            }
5950        },
5951        None => None,
5952    };
5953    let exit_idx = match header_to_index.get(exit_name.as_str()).copied() {
5954        Some(idx) => idx,
5955        None => {
5956            return Err(format!(
5957                "survival prediction data is missing required time column(s): {exit_name}"
5958            ));
5959        }
5960    };
5961
5962    if let Some(index) = entry_idx
5963        && matches!(
5964            dataset.schema.columns[index].kind,
5965            gam_data::ColumnKindTag::Categorical
5966        )
5967    {
5968        return Err(format!(
5969            "survival entry column '{}' is categorical, expected numeric times",
5970            entry_name.as_deref().unwrap_or_default()
5971        ));
5972    }
5973    if matches!(
5974        dataset.schema.columns[exit_idx].kind,
5975        gam_data::ColumnKindTag::Categorical
5976    ) {
5977        return Err(format!(
5978            "survival exit column '{exit_name}' is categorical, expected numeric times"
5979        ));
5980    }
5981    let mut lo = f64::INFINITY;
5982    let mut hi = f64::NEG_INFINITY;
5983    for row_index in 0..dataset.values.nrows() {
5984        let entry_value = match entry_idx {
5985            None => 0.0,
5986            Some(index) => dataset.values[[row_index, index]],
5987        };
5988        let exit_value = dataset.values[[row_index, exit_idx]];
5989        if !entry_value.is_finite() || !exit_value.is_finite() {
5990            return Err("survival time columns must contain only finite values".to_string());
5991        }
5992        lo = lo.min(entry_value);
5993        hi = hi.max(exit_value);
5994    }
5995    if dataset.values.nrows() == 0 {
5996        return Ok(None);
5997    }
5998    if let Some(training_hi) = training_time_upper
5999        && training_hi.is_finite()
6000    {
6001        hi = training_hi;
6002    }
6003    if hi <= lo {
6004        return Err(format!(
6005            "survival exit times must extend beyond entry times; got min entry {lo:?} and max exit {hi:?}"
6006        ));
6007    }
6008    let span = hi - lo;
6009    let hi_padded = hi + (span * 1.0e-6).max(1.0e-9);
6010    let step = (hi_padded - lo) / 63.0;
6011    Ok(Some(
6012        (0..64).map(|index| lo + step * (index as f64)).collect(),
6013    ))
6014}