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