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