Skip to main content

gam_models/survival/location_scale/
spec.rs

1use super::*;
2use serde::{Deserialize, Serialize};
3
4/// Exact time-axis parameterization selected by the fitted location-scale
5/// model. Saved replay must dispatch on this structural fit result, never on
6/// coefficient values (an all-zero fitted warp is data, not a type tag).
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum SurvivalLocationScaleTimeParameterization {
10    /// The ordinary monotone time-warp channel `h(t)` is present.
11    MonotoneWarp,
12    /// The warp was removed and `-log(t)` is carried by the location channel.
13    ReducedParametricAft,
14}
15
16/// Resolved B-spline authority for a time-varying threshold or log-scale
17/// covariate margin. Knots are fit-time values, not prediction-time estimates.
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19pub struct SurvivalCovariateTimeBasis {
20    pub degree: usize,
21    pub knots: Vec<f64>,
22}
23
24/// Exact prediction-row designs for one saved threshold/log-scale block.
25/// Time-varying blocks carry all three likelihood channels; static blocks
26/// carry only the exit design and use the same value at entry with zero time
27/// derivative inside the row program.
28#[derive(Clone)]
29pub struct SurvivalCovariateReplayDesign {
30    pub design_exit: DesignMatrix,
31    pub design_entry: Option<DesignMatrix>,
32    pub design_derivative_exit: Option<DesignMatrix>,
33    pub offset: Array1<f64>,
34}
35
36/// How a time block's parameterization enforces the derivative-guard
37/// monotonicity `q'(t) ≥ guard`.
38///
39/// The constraint set fed to the inner active-set / KKT machinery depends on
40/// the variant; consuming families dispatch on this to choose the right
41/// constraint shape and to refuse a mismatched parameterization (e.g.
42/// `survival_marginal_slope` cannot ride a coordinate-cone-only basis
43/// without re-introducing the phantom-multiplier bug it solved with the
44/// row-wise representation; `survival_location_scale` cannot ride a
45/// row-wise representation without making its reduced KKT system
46/// rank-deficient on the cone basis).
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum TimeBlockMonotonicity {
49    /// The time block's coefficients are constrained by a per-coordinate
50    /// cone `β_j ≥ 0` (with appropriate offsets handled by the family).
51    /// Used by location-scale / latent paths whose bases produce a
52    /// non-negative derivative whenever the cone holds.
53    EnforcedByCoordinateCone,
54    /// The time block's coefficients are constrained by row-wise
55    /// `D β + o ≥ guard` over every observation row; needed when the
56    /// basis admits negative-derivative directions that no coordinate
57    /// cone can encode without leaving phantom KKT multipliers when a
58    /// row binds. Used by `survival_marginal_slope` under the additive
59    /// base.
60    EnforcedByRowConstraint,
61    /// The base is a structurally-monotone parameterization (e.g.
62    /// `q'(t) = guard + I(t)·γ` with `γ ≥ 0`). Monotonicity holds
63    /// pointwise from the cone; the family treats this exactly as a
64    /// coordinate cone for constraint generation but the geometric
65    /// claim is stronger and is recorded here for diagnostics and for
66    /// future fast paths (e.g. skipping per-row validation).
67    StructuralISpline,
68}
69
70impl TimeBlockMonotonicity {
71    /// True when the variant can be enforced by a coordinate cone alone
72    /// (no row-wise constraints required). Both `EnforcedByCoordinateCone`
73    /// and `StructuralISpline` satisfy this; only `EnforcedByRowConstraint`
74    /// requires the row-wise `D β ≥ b` constraint matrix.
75    #[inline]
76    pub fn is_coordinate_cone(self) -> bool {
77        matches!(
78            self,
79            Self::EnforcedByCoordinateCone | Self::StructuralISpline
80        )
81    }
82
83    /// True when row-wise `D β + o ≥ guard` constraints must be emitted
84    /// for the inner active-set/KKT machinery to capture binding
85    /// multipliers correctly.
86    #[inline]
87    pub fn requires_row_constraints(self) -> bool {
88        matches!(self, Self::EnforcedByRowConstraint)
89    }
90}
91
92#[derive(Clone)]
93pub struct TimeBlockInput {
94    pub design_entry: DesignMatrix,
95    pub design_exit: DesignMatrix,
96    pub design_derivative_exit: DesignMatrix,
97    pub offset_entry: Array1<f64>,
98    pub offset_exit: Array1<f64>,
99    pub derivative_offset_exit: Array1<f64>,
100    /// How the time block enforces `q'(t) ≥ guard`. The consuming family
101    /// dispatches the constraint shape on this and refuses a mismatch
102    /// rather than silently producing a degenerate KKT system.
103    pub time_monotonicity: TimeBlockMonotonicity,
104    pub penalties: Vec<Array2<f64>>,
105    /// Structural nullspace dimension of each penalty matrix.
106    pub nullspace_dims: Vec<usize>,
107    pub initial_log_lambdas: Option<Array1<f64>>,
108    pub initial_beta: Option<Array1<f64>>,
109}
110
111/// A covariate block whose linear predictor depends on the survival time axis
112/// via a tensor product: covariate design (n x p_cov) ⊗ B-spline on log(time).
113///
114/// At row i the linear predictor evaluated at time t is
115///
116///   eta(t) = [ x_cov(i,:) ⊗ B_time(t) ] @ beta
117///
118/// where B_time(t) is a B-spline basis row evaluated at log(t).
119/// The entry and exit tensor designs are precomputed:
120///   X_entry\[i,:\] = x_cov(i,:) ⊗ B_time(t_entry_i)
121///   X_exit\[i,:\]  = x_cov(i,:) ⊗ B_time(t_exit_i)
122#[derive(Clone)]
123pub struct TimeDependentCovariateBlockInput {
124    /// Covariate design matrix (n x p_cov), same for all time points.
125    pub design_covariates: DesignMatrix,
126    /// B-spline time basis at entry times (n x p_time).
127    pub time_basis_entry: Array2<f64>,
128    /// B-spline time basis at exit times (n x p_time).
129    pub time_basis_exit: Array2<f64>,
130    /// Derivative of the time basis with respect to clock time at exit.
131    pub time_basis_derivative_exit: Array2<f64>,
132    /// Combined Kronecker penalties for the tensor product.
133    pub penalties: Vec<PenaltyMatrix>,
134    pub initial_log_lambdas: Option<Array1<f64>>,
135    pub initial_beta: Option<Array1<f64>>,
136    pub offset: Array1<f64>,
137}
138
139/// Whether a covariate block (threshold or log-sigma) is time-invariant or
140/// depends on the survival time axis via a tensor product.
141#[derive(Clone)]
142pub enum CovariateBlockKind {
143    Static(ParameterBlockInput),
144    TimeVarying(TimeDependentCovariateBlockInput),
145}
146
147#[derive(Clone)]
148pub struct LinkWiggleBlockInput {
149    pub design: DesignMatrix,
150    pub knots: Array1<f64>,
151    pub degree: usize,
152    pub penalties: Vec<gam_terms::penalty_spec::PenaltySpec>,
153    /// Structural nullspace dimension of each penalty matrix.
154    pub nullspace_dims: Vec<usize>,
155    pub initial_log_lambdas: Option<Array1<f64>>,
156    pub initial_beta: Option<Array1<f64>>,
157}
158
159#[derive(Clone)]
160pub struct TimeWiggleBlockInput {
161    pub knots: Array1<f64>,
162    pub degree: usize,
163    pub ncols: usize,
164}
165
166#[derive(Clone)]
167pub(crate) struct SurvivalLocationScaleSpec {
168    pub age_entry: Array1<f64>,
169    pub age_exit: Array1<f64>,
170    pub event_target: Array1<f64>,
171    pub weights: Array1<f64>,
172    pub inverse_link: InverseLink,
173    pub derivative_guard: f64,
174    pub max_iter: usize,
175    pub tol: f64,
176    pub time_block: TimeBlockInput,
177    pub threshold_block: CovariateBlockKind,
178    pub log_sigma_block: CovariateBlockKind,
179    pub timewiggle_block: Option<TimeWiggleBlockInput>,
180    pub linkwiggle_block: Option<LinkWiggleBlockInput>,
181    /// Explicit persistent warm-start cache session. See
182    /// [`BlockwiseFitOptions::cache_session`].
183    pub cache_session: Option<std::sync::Arc<gam_runtime::warm_start::Session>>,
184    pub persistent_warm_start_store: Option<gam_runtime::warm_start::ConfiguredWarmStartStore>,
185    /// Persistent warm-start mirror sessions; see
186    /// [`BlockwiseFitOptions::cache_mirror_sessions`].
187    pub cache_mirror_sessions: Vec<std::sync::Arc<gam_runtime::warm_start::Session>>,
188}
189
190#[derive(Clone)]
191pub enum SurvivalCovariateTermBlockTemplate {
192    Static,
193    TimeVarying {
194        time_basis: SurvivalCovariateTimeBasis,
195        time_basis_entry: Array2<f64>,
196        time_basis_exit: Array2<f64>,
197        time_basis_derivative_exit: Array2<f64>,
198        time_penalties: Vec<Array2<f64>>,
199    },
200}
201
202impl SurvivalCovariateTermBlockTemplate {
203    pub fn resolved_time_basis(&self) -> Option<&SurvivalCovariateTimeBasis> {
204        match self {
205            Self::Static => None,
206            Self::TimeVarying { time_basis, .. } => Some(time_basis),
207        }
208    }
209}
210
211#[derive(Clone)]
212pub struct SurvivalLocationScaleTermSpec {
213    pub age_entry: Array1<f64>,
214    pub age_exit: Array1<f64>,
215    pub event_target: Array1<f64>,
216    pub weights: Array1<f64>,
217    pub inverse_link: InverseLink,
218    /// Strict lower bound on d_eta/dt used by both the event Jacobian term
219    /// and the time monotonicity constraints.
220    pub derivative_guard: f64,
221    pub max_iter: usize,
222    pub tol: f64,
223    pub time_block: TimeBlockInput,
224    pub thresholdspec: TermCollectionSpec,
225    pub log_sigmaspec: TermCollectionSpec,
226    pub threshold_offset: Array1<f64>,
227    pub log_sigma_offset: Array1<f64>,
228    pub threshold_template: SurvivalCovariateTermBlockTemplate,
229    pub log_sigma_template: SurvivalCovariateTermBlockTemplate,
230    pub timewiggle_block: Option<TimeWiggleBlockInput>,
231    pub linkwiggle_block: Option<LinkWiggleBlockInput>,
232    /// Optional warm-start seed for the threshold-block log-smoothing parameters (ρ).
233    /// When `Some`, its length must equal the number of threshold penalties; values are
234    /// clamped to the outer-loop ρ bounds before being injected into `rho0`.
235    /// Used by the outer baseline-config optimizer to thread converged smoothing
236    /// from one probe into the next.
237    pub initial_threshold_log_lambdas: Option<Array1<f64>>,
238    /// Optional warm-start seed for the log-sigma-block log-smoothing parameters (ρ).
239    /// Same semantics as `initial_threshold_log_lambdas`.
240    pub initial_log_sigma_log_lambdas: Option<Array1<f64>>,
241    /// Explicit persistent warm-start cache session. See
242    /// [`crate::custom_family::BlockwiseFitOptions::cache_session`].
243    pub cache_session: Option<std::sync::Arc<gam_runtime::warm_start::Session>>,
244    pub persistent_warm_start_store: Option<gam_runtime::warm_start::ConfiguredWarmStartStore>,
245    /// Explicit persistent warm-start mirror sessions. See
246    /// [`crate::custom_family::BlockwiseFitOptions::cache_mirror_sessions`].
247    pub cache_mirror_sessions: Vec<std::sync::Arc<gam_runtime::warm_start::Session>>,
248}
249
250pub const DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD: f64 = 1e-6;
251
252pub struct SurvivalLocationScaleTermFitResult {
253    pub fit: UnifiedFitResult,
254    pub time_parameterization: SurvivalLocationScaleTimeParameterization,
255    pub threshold_time_basis: Option<SurvivalCovariateTimeBasis>,
256    pub log_sigma_time_basis: Option<SurvivalCovariateTimeBasis>,
257    pub resolved_thresholdspec: TermCollectionSpec,
258    pub resolved_log_sigmaspec: TermCollectionSpec,
259    pub threshold_design: TermCollectionDesign,
260    pub log_sigma_design: TermCollectionDesign,
261    /// Per-row gradient of unpenalized NLL w.r.t. the three additive time-block
262    /// offset channels (entry / exit / derivative-at-exit) at the converged β.
263    /// Contracted with `∂o/∂θ_baseline` this yields the analytic θ-gradient
264    /// used by the with-gradient baseline optimizer.
265    pub baseline_offset_residuals: OffsetChannelResiduals,
266    /// 3×3 NLL Hessian per row on the offset channels, in
267    /// `(entry, exit, derivative)` order. Diagonal under location-scale —
268    /// the row likelihood is separable in `(u0, u1, g)`. Used by the analytic
269    /// θ-Hessian builder (chain rule second derivative).
270    pub baseline_offset_curvatures: OffsetChannelCurvatures,
271    /// Exact data-fit gradient `∂(−ℓ)/∂θ_link` of the unpenalized
272    /// log-likelihood w.r.t. the inverse-link parameters at the converged β̂
273    /// (`None` when the inverse link carries no free parameters). Equals the
274    /// envelope-theorem θ_link-gradient of the profile penalized NLL, consumed
275    /// by the inverse-link BFGS optimizer.
276    pub link_param_data_fit_gradient: Option<Array1<f64>>,
277}
278
279/// Helper struct so callers can build a `UnifiedFitResult` from
280/// survival-specific fields without knowing about the unified layout.
281pub struct SurvivalLocationScaleFitResultParts {
282    /// Number of original survival records used by every parameter block.
283    pub training_sample_size: usize,
284    pub beta_time: Array1<f64>,
285    pub beta_threshold: Array1<f64>,
286    pub beta_log_sigma: Array1<f64>,
287    pub beta_link_wiggle: Option<Array1<f64>>,
288    pub link_wiggle_knots: Option<Array1<f64>>,
289    pub link_wiggle_degree: Option<usize>,
290    pub lambdas_time: Array1<f64>,
291    pub lambdas_threshold: Array1<f64>,
292    pub lambdas_log_sigma: Array1<f64>,
293    pub lambdas_linkwiggle: Option<Array1<f64>>,
294    pub log_likelihood: f64,
295    /// The fit's REML/LAML criterion, or `None` when the fit has none at all
296    /// (the exact-fit boundary; see `UnifiedFitResult::reml_score`). Carried
297    /// through rather than defaulted so a survival assembly cannot mint a
298    /// criterion its inner fit declined to state (#2595).
299    pub reml_score: Option<f64>,
300    pub stable_penalty_term: f64,
301    /// Absent exactly when [`Self::reml_score`] is absent.
302    pub penalized_objective: Option<f64>,
303    /// Whether any GPU device executed part of this fit (GPU-flag propagation).
304    /// Survival location-scale runs on the CPU path, so this is `false`; it is
305    /// carried so the assembled `UnifiedFitResultParts` reports a real value.
306    pub used_device: bool,
307    pub outer_iterations: usize,
308    /// `None` = no gradient measured at termination; `Some(g)` = measured.
309    /// `outer_converged` is the authoritative convergence signal.
310    pub outer_gradient_norm: Option<f64>,
311    /// Exact analytic stationarity certificate owned by the nested smoothing /
312    /// spatial solve. `None` is valid only when `outer_iterations == 0`.
313    ///
314    /// Finalization changes coefficient coordinates, not the optimized
315    /// criterion, so it must carry this proof through instead of replacing it
316    /// with a convergence boolean.
317    pub criterion_certificate:
318        Option<gam_solve::rho_optimizer::OuterCriterionCertificate>,
319    pub outer_converged: bool,
320    pub covariance_conditional: Option<Array2<f64>>,
321    /// Smoothing-corrected coefficient covariance `V_c = V_cond + C` (#2346),
322    /// already lifted into the SAME raw coefficient frame as
323    /// [`Self::covariance_conditional`].
324    ///
325    /// Finalization used to hard-code this to `None`, which silently discarded
326    /// the first-order ρ-uncertainty correction the inner custom-family fit had
327    /// already computed: every penalized survival location-scale fit therefore
328    /// saved a model with no corrected covariance, and `gam predict`'s DEFAULT
329    /// `--covariance-mode corrected` refused it with "saved model does not
330    /// contain smoothing-corrected covariance; refit" — an instruction no refit
331    /// could satisfy, because the correction was produced and then dropped
332    /// rather than never computed (#2677).
333    pub covariance_corrected: Option<Array2<f64>>,
334    /// The correction term `C` alone (raw frame) with the typed provenance that
335    /// produced it. `V_c = V_cond + C`, so this is the same lift as the two
336    /// covariances above. `None` is a typed absence, never an error.
337    pub smoothing_correction:
338        Option<(Array2<f64>, gam_solve::model_types::SmoothingCorrectionMethod)>,
339    pub geometry: Option<FitGeometry>,
340    /// Raw per-penalty trace `tr_kk = λ_kk·tr(H⁻¹ S_kk)` at the converged fit,
341    /// aligned 1:1 with the concatenated block lambdas in block order
342    /// `[time, threshold, log_sigma, wiggle]`. Empty when the inner solver did
343    /// not record traces (e.g. the reduced parametric-AFT path with no
344    /// penalties). Used to assemble the effective per-block / total EDF
345    /// `tr(F) = p − Σ tr_kk` instead of the nominal coefficient count.
346    pub penalty_block_trace: Vec<f64>,
347    /// Per-penalty effective d.f. `rank_kk − tr_kk`, aligned 1:1 with the same
348    /// concatenated block lambdas. Carried through from the inner blockwise fit
349    /// (basis-invariant, so valid on the lifted raw fit) for `edf_by_block`.
350    pub edf_by_block: Vec<f64>,
351}
352
353#[derive(Clone, Copy)]
354pub(crate) struct SurvivalLambdaLayout {
355    pub(crate) k_time: usize,
356    pub(crate) k_threshold: usize,
357    pub(crate) k_log_sigma: usize,
358    pub(crate) k_wiggle: usize,
359}
360
361impl SurvivalLambdaLayout {
362    pub(crate) fn new(
363        k_time: usize,
364        k_threshold: usize,
365        k_log_sigma: usize,
366        k_wiggle: usize,
367    ) -> Self {
368        Self {
369            k_time,
370            k_threshold,
371            k_log_sigma,
372            k_wiggle,
373        }
374    }
375
376    pub(crate) fn total(&self) -> usize {
377        self.k_time + self.k_threshold + self.k_log_sigma + self.k_wiggle
378    }
379
380    pub(crate) fn time_range(&self) -> std::ops::Range<usize> {
381        0..self.k_time
382    }
383
384    pub(crate) fn threshold_range(&self) -> std::ops::Range<usize> {
385        self.k_time..self.k_time + self.k_threshold
386    }
387
388    pub(crate) fn log_sigma_range(&self) -> std::ops::Range<usize> {
389        self.k_time + self.k_threshold..self.k_time + self.k_threshold + self.k_log_sigma
390    }
391
392    pub(crate) fn wiggle_range(&self) -> std::ops::Range<usize> {
393        self.k_time + self.k_threshold + self.k_log_sigma..self.total()
394    }
395
396    pub(crate) fn validate_rho(&self, rho: &Array1<f64>, label: &str) -> Result<(), String> {
397        if rho.len() != self.total() {
398            return Err(SurvivalLocationScaleError::DimensionMismatch {
399                reason: format!(
400                    "{label} rho length mismatch: got {}, expected {}",
401                    rho.len(),
402                    self.total()
403                ),
404            }
405            .into());
406        }
407        Ok::<(), _>(())
408    }
409
410    pub(crate) fn time_from(&self, rho: &Array1<f64>) -> Array1<f64> {
411        let range = self.time_range();
412        rho.slice(s![range.start..range.end]).to_owned()
413    }
414
415    pub(crate) fn threshold_from(&self, rho: &Array1<f64>) -> Array1<f64> {
416        let range = self.threshold_range();
417        rho.slice(s![range.start..range.end]).to_owned()
418    }
419
420    pub(crate) fn log_sigma_from(&self, rho: &Array1<f64>) -> Array1<f64> {
421        let range = self.log_sigma_range();
422        rho.slice(s![range.start..range.end]).to_owned()
423    }
424
425    pub(crate) fn wiggle_from(&self, rho: &Array1<f64>) -> Option<Array1<f64>> {
426        if self.k_wiggle == 0 {
427            None
428        } else {
429            let range = self.wiggle_range();
430            Some(rho.slice(s![range.start..range.end]).to_owned())
431        }
432    }
433}
434
435/// Build a `UnifiedFitResult` from survival-specific fields.
436pub fn survival_fit_from_parts(
437    parts: SurvivalLocationScaleFitResultParts,
438) -> Result<UnifiedFitResult, String> {
439    let SurvivalLocationScaleFitResultParts {
440        training_sample_size,
441        beta_time,
442        beta_threshold,
443        beta_log_sigma,
444        beta_link_wiggle,
445        link_wiggle_knots,
446        link_wiggle_degree,
447        lambdas_time,
448        lambdas_threshold,
449        lambdas_log_sigma,
450        lambdas_linkwiggle,
451        log_likelihood,
452        reml_score,
453        stable_penalty_term,
454        penalized_objective,
455        used_device,
456        outer_iterations,
457        outer_gradient_norm,
458        criterion_certificate,
459        outer_converged,
460        covariance_conditional,
461        covariance_corrected,
462        smoothing_correction,
463        geometry,
464        penalty_block_trace,
465        edf_by_block,
466    } = parts;
467
468    // Validation (preserved from the old impl).
469    validate_all_finite_estimation("survival_fit.beta_time", beta_time.iter().copied())
470        .map_err(|e| e.to_string())?;
471    validate_all_finite_estimation(
472        "survival_fit.beta_threshold",
473        beta_threshold.iter().copied(),
474    )
475    .map_err(|e| e.to_string())?;
476    validate_all_finite_estimation(
477        "survival_fit.beta_log_sigma",
478        beta_log_sigma.iter().copied(),
479    )
480    .map_err(|e| e.to_string())?;
481    if let Some(beta_wiggle) = beta_link_wiggle.as_ref() {
482        validate_all_finite_estimation(
483            "survival_fit.beta_link_wiggle",
484            beta_wiggle.iter().copied(),
485        )
486        .map_err(|e| e.to_string())?;
487        let knots = link_wiggle_knots.as_ref().ok_or_else(|| {
488            "survival_fit.beta_link_wiggle requires link_wiggle_knots".to_string()
489        })?;
490        validate_all_finite_estimation("survival_fit.link_wiggle_knots", knots.iter().copied())
491            .map_err(|e| e.to_string())?;
492        if link_wiggle_degree.is_none() {
493            return Err(SurvivalLocationScaleError::InvalidConfiguration {
494                reason: "survival_fit.beta_link_wiggle requires link_wiggle_degree".to_string(),
495            }
496            .into());
497        }
498    } else if link_wiggle_knots.is_some() || link_wiggle_degree.is_some() {
499        return Err(SurvivalLocationScaleError::InvalidConfiguration {
500            reason: "survival_fit link-wiggle metadata requires beta_link_wiggle coefficients"
501                .to_string(),
502        }
503        .into());
504    }
505    validate_all_finite_estimation("survival_fit.lambdas_time", lambdas_time.iter().copied())
506        .map_err(|e| e.to_string())?;
507    validate_all_finite_estimation(
508        "survival_fit.lambdas_threshold",
509        lambdas_threshold.iter().copied(),
510    )
511    .map_err(|e| e.to_string())?;
512    validate_all_finite_estimation(
513        "survival_fit.lambdas_log_sigma",
514        lambdas_log_sigma.iter().copied(),
515    )
516    .map_err(|e| e.to_string())?;
517    // Each block's smoothing-parameter count counts the number of distinct
518    // penalty terms acting on that block's coefficients. A penalty term cannot
519    // outnumber the coefficients it penalizes, so reject `lambdas_<block>`
520    // vectors longer than the corresponding `beta_<block>`. This catches stale
521    // / misaligned lambda slices that would otherwise propagate silently into
522    // downstream inference where the per-block penalty bookkeeping is
523    // unrecoverable.
524    if lambdas_time.len() > beta_time.len() {
525        return Err(SurvivalLocationScaleError::DimensionMismatch {
526            reason: format!(
527                "survival_fit.lambdas_time has {} entries but beta_time has only {} \
528                 coefficients; each lambda corresponds to a penalty term on this block",
529                lambdas_time.len(),
530                beta_time.len()
531            ),
532        }
533        .into());
534    }
535    if lambdas_threshold.len() > beta_threshold.len() {
536        return Err(SurvivalLocationScaleError::DimensionMismatch {
537            reason: format!(
538                "survival_fit.lambdas_threshold has {} entries but beta_threshold has only {} \
539                 coefficients; each lambda corresponds to a penalty term on this block",
540                lambdas_threshold.len(),
541                beta_threshold.len()
542            ),
543        }
544        .into());
545    }
546    if lambdas_log_sigma.len() > beta_log_sigma.len() {
547        return Err(SurvivalLocationScaleError::DimensionMismatch {
548            reason: format!(
549                "survival_fit.lambdas_log_sigma has {} entries but beta_log_sigma has only {} \
550                 coefficients; each lambda corresponds to a penalty term on this block",
551                lambdas_log_sigma.len(),
552                beta_log_sigma.len()
553            ),
554        }
555        .into());
556    }
557    if let Some(lambdas_wiggle) = lambdas_linkwiggle.as_ref() {
558        if beta_link_wiggle.is_none() {
559            return Err(SurvivalLocationScaleError::InvalidConfiguration {
560                reason: "survival_fit.lambdas_linkwiggle requires beta_link_wiggle".to_string(),
561            }
562            .into());
563        }
564        validate_all_finite_estimation(
565            "survival_fit.lambdas_linkwiggle",
566            lambdas_wiggle.iter().copied(),
567        )
568        .map_err(|e| e.to_string())?;
569        let wiggle_len = beta_link_wiggle.as_ref().map_or(0, |beta| beta.len());
570        if lambdas_wiggle.len() > wiggle_len {
571            return Err(SurvivalLocationScaleError::DimensionMismatch {
572                reason: format!(
573                    "survival_fit.lambdas_linkwiggle has {} entries but beta_link_wiggle has \
574                     only {} coefficients; each lambda corresponds to a penalty term on this block",
575                    lambdas_wiggle.len(),
576                    wiggle_len
577                ),
578            }
579            .into());
580        }
581    }
582    ensure_finite_scalar_estimation("survival_fit.log_likelihood", log_likelihood)
583        .map_err(|e| e.to_string())?;
584    if let Some(reml_score) = reml_score {
585        ensure_finite_scalar_estimation("survival_fit.reml_score", reml_score)
586            .map_err(|e| e.to_string())?;
587    }
588    ensure_finite_scalar_estimation("survival_fit.stable_penalty_term", stable_penalty_term)
589        .map_err(|e| e.to_string())?;
590    if let Some(penalized_objective) = penalized_objective {
591        ensure_finite_scalar_estimation("survival_fit.penalized_objective", penalized_objective)
592            .map_err(|e| e.to_string())?;
593    }
594    if let Some(g) = outer_gradient_norm {
595        ensure_finite_scalar_estimation("survival_fit.outer_gradient_norm", g)
596            .map_err(|e| e.to_string())?;
597    }
598
599    let total_p = beta_time.len()
600        + beta_threshold.len()
601        + beta_log_sigma.len()
602        + beta_link_wiggle.as_ref().map_or(0, |beta| beta.len());
603    if let Some(cov) = covariance_conditional.as_ref() {
604        validate_all_finite_estimation("survival_fit.covariance_conditional", cov.iter().copied())
605            .map_err(|e| e.to_string())?;
606        let (rows, cols) = cov.dim();
607        if rows != total_p || cols != total_p {
608            return Err(SurvivalLocationScaleError::InvalidConfiguration {
609                reason: format!(
610                    "survival_fit.covariance_conditional must be {}x{}, got {}x{}",
611                    total_p, total_p, rows, cols
612                ),
613            }
614            .into());
615        }
616    }
617    if let Some(geom) = geometry.as_ref() {
618        geom.validate_numeric_finiteness()
619            .map_err(|e| e.to_string())?;
620        let mut saved_block_widths =
621            vec![beta_time.len(), beta_threshold.len(), beta_log_sigma.len()];
622        if let Some(beta) = beta_link_wiggle.as_ref() {
623            saved_block_widths.push(beta.len());
624        }
625        if geom.coefficient_gauge.raw_widths() != saved_block_widths {
626            return Err(SurvivalLocationScaleError::InvalidConfiguration {
627                reason: format!(
628                    "survival_fit.geometry coefficient-gauge raw block widths {:?} do not match saved coefficient widths {:?}",
629                    geom.coefficient_gauge.raw_widths(),
630                    saved_block_widths,
631                ),
632            }
633            .into());
634        }
635        let active_p = geom.coefficient_gauge.reduced_total();
636        let (rows, cols) = geom.penalized_hessian.dim();
637        if rows != active_p || cols != active_p {
638            return Err(SurvivalLocationScaleError::InvalidConfiguration {
639                reason: format!(
640                    "survival_fit.geometry active-coordinate penalized_hessian must be {}x{}, got {}x{}",
641                    active_p, active_p, rows, cols
642                ),
643            }
644            .into());
645        }
646    }
647
648    // Effective degrees of freedom per block from the converged penalized
649    // information matrix (issue #2106). The inner blockwise solver already
650    // computes the mgcv-consistent per-penalty trace `tr_kk = λ_kk·tr(H⁻¹ S_kk)`
651    // (see `custom_family_blockwise_edf`); the finalize path threads those
652    // traces through `penalty_block_trace`, aligned 1:1 with the concatenated
653    // block lambdas in block order `[time, threshold, log_sigma, wiggle]`. The
654    // effective d.f. of a block is then `tr(F) = |coeff| − Σ tr_kk`, which
655    // strictly drops below the coefficient count when a positive-rank penalty is
656    // active and shrinks as λ grows. An unpenalized/parametric block (no λ,
657    // hence no trace) keeps its full column count. The traces are basis-invariant
658    // under the finalize gauge lift, so they apply directly to the raw block
659    // coefficient counts here; any raw column added by the lift is an unpenalized
660    // parametric direction that carries its full unit of d.f.
661    let n_time = lambdas_time.len();
662    let n_threshold = lambdas_threshold.len();
663    let n_log_sigma = lambdas_log_sigma.len();
664    let n_wiggle = lambdas_linkwiggle.as_ref().map_or(0, |l| l.len());
665    let total_penalties = n_time + n_threshold + n_log_sigma + n_wiggle;
666    // Only trust the plumbed traces when they align 1:1 with the block lambdas;
667    // otherwise (traces unavailable) fall back to the nominal column count.
668    let traces_available = penalty_block_trace.len() == total_penalties;
669    let block_trace_sum = |offset: usize, count: usize| -> f64 {
670        if traces_available && count > 0 {
671            penalty_block_trace[offset..offset + count].iter().sum()
672        } else {
673            0.0
674        }
675    };
676    let effective_edf = |ncoef: usize, trace_sum: f64| -> f64 {
677        (ncoef as f64 - trace_sum).clamp(0.0, ncoef as f64)
678    };
679    let edf_time = effective_edf(beta_time.len(), block_trace_sum(0, n_time));
680    let edf_threshold = effective_edf(beta_threshold.len(), block_trace_sum(n_time, n_threshold));
681    let edf_log_sigma = effective_edf(
682        beta_log_sigma.len(),
683        block_trace_sum(n_time + n_threshold, n_log_sigma),
684    );
685    let wiggle_len = beta_link_wiggle.as_ref().map_or(0, |beta| beta.len());
686    let edf_link_wiggle = effective_edf(
687        wiggle_len,
688        block_trace_sum(n_time + n_threshold + n_log_sigma, n_wiggle),
689    );
690    let edf_total = edf_time + edf_threshold + edf_log_sigma + edf_link_wiggle;
691
692    use crate::model_types::{BlockRole, FittedBlock, FittedLinkState, UnifiedFitResultParts};
693    let mut blocks = vec![
694        FittedBlock {
695            beta: beta_time.clone(),
696            role: BlockRole::Time,
697            edf: edf_time,
698            lambdas: lambdas_time.clone(),
699        },
700        FittedBlock {
701            beta: beta_threshold.clone(),
702            role: BlockRole::Threshold,
703            edf: edf_threshold,
704            lambdas: lambdas_threshold.clone(),
705        },
706        FittedBlock {
707            beta: beta_log_sigma.clone(),
708            role: BlockRole::Scale,
709            edf: edf_log_sigma,
710            lambdas: lambdas_log_sigma.clone(),
711        },
712    ];
713    if let Some(ref bw) = beta_link_wiggle {
714        blocks.push(FittedBlock {
715            beta: bw.clone(),
716            role: BlockRole::LinkWiggle,
717            edf: edf_link_wiggle,
718            lambdas: lambdas_linkwiggle
719                .clone()
720                .unwrap_or_else(|| Array1::zeros(0)),
721        });
722    }
723    let all_lambdas: Vec<f64> = blocks
724        .iter()
725        .flat_map(|b| b.lambdas.iter().copied())
726        .collect();
727    let log_lambdas = Array1::from_vec(
728        all_lambdas
729            .iter()
730            .map(|&v| if v > 0.0 { v.ln() } else { f64::NEG_INFINITY })
731            .collect(),
732    );
733    // Report the genuine per-penalty trace / effective-d.f. channels when the
734    // inner solver supplied them (aligned 1:1 with `all_lambdas`); otherwise
735    // leave them empty so downstream consumers treat them as unavailable rather
736    // than reading a fabricated uniform split (issue #2106).
737    let inference_penalty_block_trace = if penalty_block_trace.len() == all_lambdas.len() {
738        penalty_block_trace.clone()
739    } else {
740        Vec::new()
741    };
742    let inference_edf_by_block = if edf_by_block.len() == all_lambdas.len() {
743        edf_by_block.clone()
744    } else {
745        Vec::new()
746    };
747    // One gate owns the negative-diagonal judgement for every lane's
748    // `sqrt(diag(V))` (`gam_problem::se_from_covariance`). The location-scale
749    // conditional covariance is only conditionally SPD, and a local
750    // `max(0, ·)` would publish a materially negative variance as `SE = 0` —
751    // an infinitely precise coefficient — instead of refusing it.
752    let beta_standard_errors = covariance_conditional
753        .as_ref()
754        .map(gam_problem::se_from_covariance)
755        .transpose()
756        .map_err(|reason| {
757            format!("survival location-scale conditional standard errors are invalid: {reason}")
758        })?;
759    // #2677 — when lambda was NOT selected, the smoothing correction
760    // `J V_rho Jᵀ` integrates over a zero-dimensional rho, so it is the zero
761    // matrix and `Vp = Vb` EXACTLY. Publishing the conditional covariance as
762    // the corrected one is then an identity of the definition, not a fallback
763    // to a narrower uncertainty object.
764    //
765    // The predicate is the sibling survival-transformation route's, verbatim
766    // (`fit_orchestration/fit.rs`): no outer iterations and no criterion
767    // certificate is exactly "no rho was selected" -- the field doc on
768    // `criterion_certificate` states `None` is valid only when
769    // `outer_iterations == 0`.
770    //
771    // This is the FALLBACK, not the primary source. A fit whose lambda WAS
772    // selected (the bench's penalized survival scenarios run 16 outer
773    // iterations) carries a real, non-zero correction computed by the
774    // custom-family assembler; finalization now hands it in through
775    // `parts.covariance_corrected` and it wins here. Only when no such term
776    // exists does the zero-rho identity apply, and a fit that selected rho
777    // WITHOUT producing a correction still keeps the typed absence rather than
778    // silently under-reporting every interval with `Vb`.
779    let lambda_is_fixed = outer_iterations == 0 && criterion_certificate.is_none();
780    let covariance_corrected = covariance_corrected.or_else(|| {
781        lambda_is_fixed
782            .then(|| covariance_conditional.clone())
783            .flatten()
784    });
785    // One gate for the CORRECTED marginal SEs too: `V_c = V_cond + C` is only
786    // conditionally SPD, so a materially negative variance must be refused
787    // here rather than published as `SE = 0`.
788    let beta_standard_errors_corrected = covariance_corrected
789        .as_ref()
790        .map(gam_problem::se_from_covariance)
791        .transpose()
792        .map_err(|reason| {
793            format!("survival location-scale corrected standard errors are invalid: {reason}")
794        })?;
795    let (smoothing_correction_matrix, smoothing_correction_method) = match smoothing_correction {
796        Some((correction, method)) => (Some(correction), Some(method)),
797        None => (None, None),
798    };
799    let inference = geometry
800        .as_ref()
801        .map(|geom| gam_solve::estimate::FitInference {
802            edf_by_block: inference_edf_by_block.clone(),
803            penalty_block_trace: inference_penalty_block_trace.clone(),
804            edf_total,
805            // This lane's correction is only ever the first-order IFT term
806            // (the custom-family fit never runs a cubature upgrade), so the
807            // retained "first-order" pair is exactly the primary pair and the
808            // #946 exact corrected-EDF/AIC channel reads a populated value.
809            smoothing_correction_first_order: smoothing_correction_matrix.clone(),
810            smoothing_correction_method_first_order: smoothing_correction_method,
811            smoothing_correction: smoothing_correction_matrix.clone(),
812            smoothing_correction_method,
813            penalized_hessian: geom.penalized_hessian.clone(),
814            reparam_qs: None,
815            dispersion: gam_solve::estimate::Dispersion::UNIT,
816            beta_covariance: covariance_conditional.clone().map(Into::into),
817            beta_standard_errors,
818            beta_covariance_corrected: covariance_corrected.clone(),
819            beta_standard_errors_corrected: beta_standard_errors_corrected.clone(),
820            beta_covariance_frequentist: None,
821            coefficient_influence: None,
822            weighted_gram: None,
823            bias_correction_beta: None,
824            bias_correction_jacobian: None,
825        });
826
827    let deviance = -2.0 * log_likelihood;
828    crate::model_types::UnifiedFitResult::try_from_parts(UnifiedFitResultParts {
829        blocks,
830        training_sample_size,
831        log_lambdas,
832        lambdas: Array1::from_vec(all_lambdas),
833        likelihood_family: None,
834        likelihood_scale: gam_problem::LikelihoodScaleMetadata::Unspecified,
835        log_likelihood_normalization: gam_problem::LogLikelihoodNormalization::UserProvided,
836        log_likelihood,
837        deviance,
838        reml_score,
839        stable_penalty_term,
840        penalized_objective,
841        used_device,
842        outer_iterations,
843        outer_converged,
844        outer_gradient_norm,
845        standard_deviation: 1.0,
846        covariance_conditional,
847        covariance_corrected,
848        inference,
849        fitted_link: FittedLinkState::Standard(None),
850        geometry,
851        block_states: Vec::new(),
852        pirls_status: gam_solve::pirls::PirlsStatus::Converged,
853        max_abs_eta: 0.0,
854        constraint_kkt: None,
855        artifacts: crate::model_types::FitArtifacts {
856            pirls: None,
857            null_space_logdet: None,
858            null_space_dim: None,
859            survival_link_wiggle_knots: link_wiggle_knots,
860            survival_link_wiggle_degree: link_wiggle_degree,
861            criterion_certificate,
862            rho_posterior_certificate: None,
863            rho_posterior_escalation: None,
864            rho_covariance: None,
865            joint_log_lambdas: None,
866            // Survival location-scale fits optimize the plain penalized
867            // likelihood; the Firth/Jeffreys adjustment is a binary-separation
868            // remedy and is never engaged here.
869            firth_bias_reduction: false,
870            // Survival location-scale never reaches the BMS generated-regressor
871            // seam, so it never withholds a covariance it could have published.
872            covariance_declined: None,
873        },
874        inner_cycles: 0,
875    })
876    .map_err(|e| e.to_string())
877}
878
879#[derive(Clone)]
880pub struct SurvivalLocationScalePredictInput {
881    pub x_time_exit: Array2<f64>,
882    pub eta_time_offset_exit: Array1<f64>,
883    pub time_wiggle_knots: Option<Array1<f64>>,
884    pub time_wiggle_degree: Option<usize>,
885    pub time_wiggle_ncols: usize,
886    pub x_threshold: DesignMatrix,
887    pub eta_threshold_offset: Array1<f64>,
888    pub x_log_sigma: DesignMatrix,
889    pub eta_log_sigma_offset: Array1<f64>,
890    pub x_link_wiggle: Option<DesignMatrix>,
891    pub link_wiggle_knots: Option<Array1<f64>>,
892    pub link_wiggle_degree: Option<usize>,
893    pub inverse_link: InverseLink,
894}
895
896#[derive(Clone, Debug)]
897pub struct SurvivalLocationScalePredictResult {
898    pub eta: Array1<f64>,
899    pub survival_prob: Array1<f64>,
900}
901
902#[derive(Clone)]
903pub struct SurvivalLocationScalePredictUncertaintyResult {
904    pub eta: Array1<f64>,
905    pub survival_prob: Array1<f64>,
906    pub eta_standard_error: Array1<f64>,
907    pub response_standard_error: Option<Array1<f64>>,
908}
909
910pub(crate) fn initial_log_lambdas<T>(
911    penalties: &[T],
912    rho0: Option<Array1<f64>>,
913) -> Result<Array1<f64>, String> {
914    let k = penalties.len();
915    let rho = rho0.unwrap_or_else(|| Array1::zeros(k));
916    if rho.len() != k {
917        return Err(SurvivalLocationScaleError::DimensionMismatch {
918            reason: format!(
919                "initial_log_lambdas mismatch: got {}, expected {k}",
920                rho.len()
921            ),
922        }
923        .into());
924    }
925    Ok(rho)
926}