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    /// Persistent warm-start mirror sessions; see
185    /// [`BlockwiseFitOptions::cache_mirror_sessions`].
186    pub cache_mirror_sessions: Vec<std::sync::Arc<gam_runtime::warm_start::Session>>,
187}
188
189#[derive(Clone)]
190pub enum SurvivalCovariateTermBlockTemplate {
191    Static,
192    TimeVarying {
193        time_basis: SurvivalCovariateTimeBasis,
194        time_basis_entry: Array2<f64>,
195        time_basis_exit: Array2<f64>,
196        time_basis_derivative_exit: Array2<f64>,
197        time_penalties: Vec<Array2<f64>>,
198    },
199}
200
201impl SurvivalCovariateTermBlockTemplate {
202    pub fn resolved_time_basis(&self) -> Option<&SurvivalCovariateTimeBasis> {
203        match self {
204            Self::Static => None,
205            Self::TimeVarying { time_basis, .. } => Some(time_basis),
206        }
207    }
208}
209
210#[derive(Clone)]
211pub struct SurvivalLocationScaleTermSpec {
212    pub age_entry: Array1<f64>,
213    pub age_exit: Array1<f64>,
214    pub event_target: Array1<f64>,
215    pub weights: Array1<f64>,
216    pub inverse_link: InverseLink,
217    /// Strict lower bound on d_eta/dt used by both the event Jacobian term
218    /// and the time monotonicity constraints.
219    pub derivative_guard: f64,
220    pub max_iter: usize,
221    pub tol: f64,
222    pub time_block: TimeBlockInput,
223    pub thresholdspec: TermCollectionSpec,
224    pub log_sigmaspec: TermCollectionSpec,
225    pub threshold_offset: Array1<f64>,
226    pub log_sigma_offset: Array1<f64>,
227    pub threshold_template: SurvivalCovariateTermBlockTemplate,
228    pub log_sigma_template: SurvivalCovariateTermBlockTemplate,
229    pub timewiggle_block: Option<TimeWiggleBlockInput>,
230    pub linkwiggle_block: Option<LinkWiggleBlockInput>,
231    /// Optional warm-start seed for the threshold-block log-smoothing parameters (ρ).
232    /// When `Some`, its length must equal the number of threshold penalties; values are
233    /// clamped to the outer-loop ρ bounds before being injected into `rho0`.
234    /// Used by the outer baseline-config optimizer to thread converged smoothing
235    /// from one probe into the next.
236    pub initial_threshold_log_lambdas: Option<Array1<f64>>,
237    /// Optional warm-start seed for the log-sigma-block log-smoothing parameters (ρ).
238    /// Same semantics as `initial_threshold_log_lambdas`.
239    pub initial_log_sigma_log_lambdas: Option<Array1<f64>>,
240    /// Explicit persistent warm-start cache session. See
241    /// [`crate::custom_family::BlockwiseFitOptions::cache_session`].
242    pub cache_session: Option<std::sync::Arc<gam_runtime::warm_start::Session>>,
243    /// Explicit persistent warm-start mirror sessions. See
244    /// [`crate::custom_family::BlockwiseFitOptions::cache_mirror_sessions`].
245    pub cache_mirror_sessions: Vec<std::sync::Arc<gam_runtime::warm_start::Session>>,
246}
247
248pub const DEFAULT_SURVIVAL_LOCATION_SCALE_DERIVATIVE_GUARD: f64 = 1e-6;
249
250pub struct SurvivalLocationScaleTermFitResult {
251    pub fit: UnifiedFitResult,
252    pub time_parameterization: SurvivalLocationScaleTimeParameterization,
253    pub threshold_time_basis: Option<SurvivalCovariateTimeBasis>,
254    pub log_sigma_time_basis: Option<SurvivalCovariateTimeBasis>,
255    pub resolved_thresholdspec: TermCollectionSpec,
256    pub resolved_log_sigmaspec: TermCollectionSpec,
257    pub threshold_design: TermCollectionDesign,
258    pub log_sigma_design: TermCollectionDesign,
259    /// Per-row gradient of unpenalized NLL w.r.t. the three additive time-block
260    /// offset channels (entry / exit / derivative-at-exit) at the converged β.
261    /// Contracted with `∂o/∂θ_baseline` this yields the analytic θ-gradient
262    /// used by the with-gradient baseline optimizer.
263    pub baseline_offset_residuals: OffsetChannelResiduals,
264    /// 3×3 NLL Hessian per row on the offset channels, in
265    /// `(entry, exit, derivative)` order. Diagonal under location-scale —
266    /// the row likelihood is separable in `(u0, u1, g)`. Used by the analytic
267    /// θ-Hessian builder (chain rule second derivative).
268    pub baseline_offset_curvatures: OffsetChannelCurvatures,
269    /// Exact data-fit gradient `∂(−ℓ)/∂θ_link` of the unpenalized
270    /// log-likelihood w.r.t. the inverse-link parameters at the converged β̂
271    /// (`None` when the inverse link carries no free parameters). Equals the
272    /// envelope-theorem θ_link-gradient of the profile penalized NLL, consumed
273    /// by the inverse-link BFGS optimizer.
274    pub link_param_data_fit_gradient: Option<Array1<f64>>,
275}
276
277/// Helper struct so callers can build a `UnifiedFitResult` from
278/// survival-specific fields without knowing about the unified layout.
279pub struct SurvivalLocationScaleFitResultParts {
280    pub beta_time: Array1<f64>,
281    pub beta_threshold: Array1<f64>,
282    pub beta_log_sigma: Array1<f64>,
283    pub beta_link_wiggle: Option<Array1<f64>>,
284    pub link_wiggle_knots: Option<Array1<f64>>,
285    pub link_wiggle_degree: Option<usize>,
286    pub lambdas_time: Array1<f64>,
287    pub lambdas_threshold: Array1<f64>,
288    pub lambdas_log_sigma: Array1<f64>,
289    pub lambdas_linkwiggle: Option<Array1<f64>>,
290    pub log_likelihood: f64,
291    pub reml_score: f64,
292    pub stable_penalty_term: f64,
293    pub penalized_objective: f64,
294    /// Whether any GPU device executed part of this fit (GPU-flag propagation).
295    /// Survival location-scale runs on the CPU path, so this is `false`; it is
296    /// carried so the assembled `UnifiedFitResultParts` reports a real value.
297    pub used_device: bool,
298    pub outer_iterations: usize,
299    /// `None` = no gradient measured at termination; `Some(g)` = measured.
300    /// `outer_converged` is the authoritative convergence signal.
301    pub outer_gradient_norm: Option<f64>,
302    /// Exact analytic stationarity certificate owned by the nested smoothing /
303    /// spatial solve. `None` is valid only when `outer_iterations == 0`.
304    ///
305    /// Finalization changes coefficient coordinates, not the optimized
306    /// criterion, so it must carry this proof through instead of replacing it
307    /// with a convergence boolean.
308    pub criterion_certificate:
309        Option<gam_solve::rho_optimizer::OuterCriterionCertificate>,
310    pub outer_converged: bool,
311    pub covariance_conditional: Option<Array2<f64>>,
312    pub geometry: Option<FitGeometry>,
313    /// Raw per-penalty trace `tr_kk = λ_kk·tr(H⁻¹ S_kk)` at the converged fit,
314    /// aligned 1:1 with the concatenated block lambdas in block order
315    /// `[time, threshold, log_sigma, wiggle]`. Empty when the inner solver did
316    /// not record traces (e.g. the reduced parametric-AFT path with no
317    /// penalties). Used to assemble the effective per-block / total EDF
318    /// `tr(F) = p − Σ tr_kk` instead of the nominal coefficient count.
319    pub penalty_block_trace: Vec<f64>,
320    /// Per-penalty effective d.f. `rank_kk − tr_kk`, aligned 1:1 with the same
321    /// concatenated block lambdas. Carried through from the inner blockwise fit
322    /// (basis-invariant, so valid on the lifted raw fit) for `edf_by_block`.
323    pub edf_by_block: Vec<f64>,
324}
325
326#[derive(Clone, Copy)]
327pub(crate) struct SurvivalLambdaLayout {
328    pub(crate) k_time: usize,
329    pub(crate) k_threshold: usize,
330    pub(crate) k_log_sigma: usize,
331    pub(crate) k_wiggle: usize,
332}
333
334impl SurvivalLambdaLayout {
335    pub(crate) fn new(
336        k_time: usize,
337        k_threshold: usize,
338        k_log_sigma: usize,
339        k_wiggle: usize,
340    ) -> Self {
341        Self {
342            k_time,
343            k_threshold,
344            k_log_sigma,
345            k_wiggle,
346        }
347    }
348
349    pub(crate) fn total(&self) -> usize {
350        self.k_time + self.k_threshold + self.k_log_sigma + self.k_wiggle
351    }
352
353    pub(crate) fn time_range(&self) -> std::ops::Range<usize> {
354        0..self.k_time
355    }
356
357    pub(crate) fn threshold_range(&self) -> std::ops::Range<usize> {
358        self.k_time..self.k_time + self.k_threshold
359    }
360
361    pub(crate) fn log_sigma_range(&self) -> std::ops::Range<usize> {
362        self.k_time + self.k_threshold..self.k_time + self.k_threshold + self.k_log_sigma
363    }
364
365    pub(crate) fn wiggle_range(&self) -> std::ops::Range<usize> {
366        self.k_time + self.k_threshold + self.k_log_sigma..self.total()
367    }
368
369    pub(crate) fn validate_rho(&self, rho: &Array1<f64>, label: &str) -> Result<(), String> {
370        if rho.len() != self.total() {
371            return Err(SurvivalLocationScaleError::DimensionMismatch {
372                reason: format!(
373                    "{label} rho length mismatch: got {}, expected {}",
374                    rho.len(),
375                    self.total()
376                ),
377            }
378            .into());
379        }
380        Ok::<(), _>(())
381    }
382
383    pub(crate) fn time_from(&self, rho: &Array1<f64>) -> Array1<f64> {
384        let range = self.time_range();
385        rho.slice(s![range.start..range.end]).to_owned()
386    }
387
388    pub(crate) fn threshold_from(&self, rho: &Array1<f64>) -> Array1<f64> {
389        let range = self.threshold_range();
390        rho.slice(s![range.start..range.end]).to_owned()
391    }
392
393    pub(crate) fn log_sigma_from(&self, rho: &Array1<f64>) -> Array1<f64> {
394        let range = self.log_sigma_range();
395        rho.slice(s![range.start..range.end]).to_owned()
396    }
397
398    pub(crate) fn wiggle_from(&self, rho: &Array1<f64>) -> Option<Array1<f64>> {
399        if self.k_wiggle == 0 {
400            None
401        } else {
402            let range = self.wiggle_range();
403            Some(rho.slice(s![range.start..range.end]).to_owned())
404        }
405    }
406}
407
408/// Build a `UnifiedFitResult` from survival-specific fields.
409pub fn survival_fit_from_parts(
410    parts: SurvivalLocationScaleFitResultParts,
411) -> Result<UnifiedFitResult, String> {
412    let SurvivalLocationScaleFitResultParts {
413        beta_time,
414        beta_threshold,
415        beta_log_sigma,
416        beta_link_wiggle,
417        link_wiggle_knots,
418        link_wiggle_degree,
419        lambdas_time,
420        lambdas_threshold,
421        lambdas_log_sigma,
422        lambdas_linkwiggle,
423        log_likelihood,
424        reml_score,
425        stable_penalty_term,
426        penalized_objective,
427        used_device,
428        outer_iterations,
429        outer_gradient_norm,
430        criterion_certificate,
431        outer_converged,
432        covariance_conditional,
433        geometry,
434        penalty_block_trace,
435        edf_by_block,
436    } = parts;
437
438    // Validation (preserved from the old impl).
439    validate_all_finite_estimation("survival_fit.beta_time", beta_time.iter().copied())
440        .map_err(|e| e.to_string())?;
441    validate_all_finite_estimation(
442        "survival_fit.beta_threshold",
443        beta_threshold.iter().copied(),
444    )
445    .map_err(|e| e.to_string())?;
446    validate_all_finite_estimation(
447        "survival_fit.beta_log_sigma",
448        beta_log_sigma.iter().copied(),
449    )
450    .map_err(|e| e.to_string())?;
451    if let Some(beta_wiggle) = beta_link_wiggle.as_ref() {
452        validate_all_finite_estimation(
453            "survival_fit.beta_link_wiggle",
454            beta_wiggle.iter().copied(),
455        )
456        .map_err(|e| e.to_string())?;
457        let knots = link_wiggle_knots.as_ref().ok_or_else(|| {
458            "survival_fit.beta_link_wiggle requires link_wiggle_knots".to_string()
459        })?;
460        validate_all_finite_estimation("survival_fit.link_wiggle_knots", knots.iter().copied())
461            .map_err(|e| e.to_string())?;
462        if link_wiggle_degree.is_none() {
463            return Err(SurvivalLocationScaleError::InvalidConfiguration {
464                reason: "survival_fit.beta_link_wiggle requires link_wiggle_degree".to_string(),
465            }
466            .into());
467        }
468    } else if link_wiggle_knots.is_some() || link_wiggle_degree.is_some() {
469        return Err(SurvivalLocationScaleError::InvalidConfiguration {
470            reason: "survival_fit link-wiggle metadata requires beta_link_wiggle coefficients"
471                .to_string(),
472        }
473        .into());
474    }
475    validate_all_finite_estimation("survival_fit.lambdas_time", lambdas_time.iter().copied())
476        .map_err(|e| e.to_string())?;
477    validate_all_finite_estimation(
478        "survival_fit.lambdas_threshold",
479        lambdas_threshold.iter().copied(),
480    )
481    .map_err(|e| e.to_string())?;
482    validate_all_finite_estimation(
483        "survival_fit.lambdas_log_sigma",
484        lambdas_log_sigma.iter().copied(),
485    )
486    .map_err(|e| e.to_string())?;
487    // Each block's smoothing-parameter count counts the number of distinct
488    // penalty terms acting on that block's coefficients. A penalty term cannot
489    // outnumber the coefficients it penalizes, so reject `lambdas_<block>`
490    // vectors longer than the corresponding `beta_<block>`. This catches stale
491    // / misaligned lambda slices that would otherwise propagate silently into
492    // downstream inference where the per-block penalty bookkeeping is
493    // unrecoverable.
494    if lambdas_time.len() > beta_time.len() {
495        return Err(SurvivalLocationScaleError::DimensionMismatch {
496            reason: format!(
497                "survival_fit.lambdas_time has {} entries but beta_time has only {} \
498                 coefficients; each lambda corresponds to a penalty term on this block",
499                lambdas_time.len(),
500                beta_time.len()
501            ),
502        }
503        .into());
504    }
505    if lambdas_threshold.len() > beta_threshold.len() {
506        return Err(SurvivalLocationScaleError::DimensionMismatch {
507            reason: format!(
508                "survival_fit.lambdas_threshold has {} entries but beta_threshold has only {} \
509                 coefficients; each lambda corresponds to a penalty term on this block",
510                lambdas_threshold.len(),
511                beta_threshold.len()
512            ),
513        }
514        .into());
515    }
516    if lambdas_log_sigma.len() > beta_log_sigma.len() {
517        return Err(SurvivalLocationScaleError::DimensionMismatch {
518            reason: format!(
519                "survival_fit.lambdas_log_sigma has {} entries but beta_log_sigma has only {} \
520                 coefficients; each lambda corresponds to a penalty term on this block",
521                lambdas_log_sigma.len(),
522                beta_log_sigma.len()
523            ),
524        }
525        .into());
526    }
527    if let Some(lambdas_wiggle) = lambdas_linkwiggle.as_ref() {
528        if beta_link_wiggle.is_none() {
529            return Err(SurvivalLocationScaleError::InvalidConfiguration {
530                reason: "survival_fit.lambdas_linkwiggle requires beta_link_wiggle".to_string(),
531            }
532            .into());
533        }
534        validate_all_finite_estimation(
535            "survival_fit.lambdas_linkwiggle",
536            lambdas_wiggle.iter().copied(),
537        )
538        .map_err(|e| e.to_string())?;
539        let wiggle_len = beta_link_wiggle.as_ref().map_or(0, |beta| beta.len());
540        if lambdas_wiggle.len() > wiggle_len {
541            return Err(SurvivalLocationScaleError::DimensionMismatch {
542                reason: format!(
543                    "survival_fit.lambdas_linkwiggle has {} entries but beta_link_wiggle has \
544                     only {} coefficients; each lambda corresponds to a penalty term on this block",
545                    lambdas_wiggle.len(),
546                    wiggle_len
547                ),
548            }
549            .into());
550        }
551    }
552    ensure_finite_scalar_estimation("survival_fit.log_likelihood", log_likelihood)
553        .map_err(|e| e.to_string())?;
554    ensure_finite_scalar_estimation("survival_fit.reml_score", reml_score)
555        .map_err(|e| e.to_string())?;
556    ensure_finite_scalar_estimation("survival_fit.stable_penalty_term", stable_penalty_term)
557        .map_err(|e| e.to_string())?;
558    ensure_finite_scalar_estimation("survival_fit.penalized_objective", penalized_objective)
559        .map_err(|e| e.to_string())?;
560    if let Some(g) = outer_gradient_norm {
561        ensure_finite_scalar_estimation("survival_fit.outer_gradient_norm", g)
562            .map_err(|e| e.to_string())?;
563    }
564
565    let total_p = beta_time.len()
566        + beta_threshold.len()
567        + beta_log_sigma.len()
568        + beta_link_wiggle.as_ref().map_or(0, |beta| beta.len());
569    if let Some(cov) = covariance_conditional.as_ref() {
570        validate_all_finite_estimation("survival_fit.covariance_conditional", cov.iter().copied())
571            .map_err(|e| e.to_string())?;
572        let (rows, cols) = cov.dim();
573        if rows != total_p || cols != total_p {
574            return Err(SurvivalLocationScaleError::InvalidConfiguration {
575                reason: format!(
576                    "survival_fit.covariance_conditional must be {}x{}, got {}x{}",
577                    total_p, total_p, rows, cols
578                ),
579            }
580            .into());
581        }
582    }
583    if let Some(geom) = geometry.as_ref() {
584        geom.validate_numeric_finiteness()
585            .map_err(|e| e.to_string())?;
586        let mut saved_block_widths =
587            vec![beta_time.len(), beta_threshold.len(), beta_log_sigma.len()];
588        if let Some(beta) = beta_link_wiggle.as_ref() {
589            saved_block_widths.push(beta.len());
590        }
591        if geom.coefficient_gauge.raw_widths() != saved_block_widths {
592            return Err(SurvivalLocationScaleError::InvalidConfiguration {
593                reason: format!(
594                    "survival_fit.geometry coefficient-gauge raw block widths {:?} do not match saved coefficient widths {:?}",
595                    geom.coefficient_gauge.raw_widths(),
596                    saved_block_widths,
597                ),
598            }
599            .into());
600        }
601        let active_p = geom.coefficient_gauge.reduced_total();
602        let (rows, cols) = geom.penalized_hessian.dim();
603        if rows != active_p || cols != active_p {
604            return Err(SurvivalLocationScaleError::InvalidConfiguration {
605                reason: format!(
606                    "survival_fit.geometry active-coordinate penalized_hessian must be {}x{}, got {}x{}",
607                    active_p, active_p, rows, cols
608                ),
609            }
610            .into());
611        }
612    }
613
614    // Effective degrees of freedom per block from the converged penalized
615    // information matrix (issue #2106). The inner blockwise solver already
616    // computes the mgcv-consistent per-penalty trace `tr_kk = λ_kk·tr(H⁻¹ S_kk)`
617    // (see `custom_family_blockwise_edf`); the finalize path threads those
618    // traces through `penalty_block_trace`, aligned 1:1 with the concatenated
619    // block lambdas in block order `[time, threshold, log_sigma, wiggle]`. The
620    // effective d.f. of a block is then `tr(F) = |coeff| − Σ tr_kk`, which
621    // strictly drops below the coefficient count when a positive-rank penalty is
622    // active and shrinks as λ grows. An unpenalized/parametric block (no λ,
623    // hence no trace) keeps its full column count. The traces are basis-invariant
624    // under the finalize gauge lift, so they apply directly to the raw block
625    // coefficient counts here; any raw column added by the lift is an unpenalized
626    // parametric direction that carries its full unit of d.f.
627    let n_time = lambdas_time.len();
628    let n_threshold = lambdas_threshold.len();
629    let n_log_sigma = lambdas_log_sigma.len();
630    let n_wiggle = lambdas_linkwiggle.as_ref().map_or(0, |l| l.len());
631    let total_penalties = n_time + n_threshold + n_log_sigma + n_wiggle;
632    // Only trust the plumbed traces when they align 1:1 with the block lambdas;
633    // otherwise (traces unavailable) fall back to the nominal column count.
634    let traces_available = penalty_block_trace.len() == total_penalties;
635    let block_trace_sum = |offset: usize, count: usize| -> f64 {
636        if traces_available && count > 0 {
637            penalty_block_trace[offset..offset + count].iter().sum()
638        } else {
639            0.0
640        }
641    };
642    let effective_edf = |ncoef: usize, trace_sum: f64| -> f64 {
643        (ncoef as f64 - trace_sum).clamp(0.0, ncoef as f64)
644    };
645    let edf_time = effective_edf(beta_time.len(), block_trace_sum(0, n_time));
646    let edf_threshold = effective_edf(beta_threshold.len(), block_trace_sum(n_time, n_threshold));
647    let edf_log_sigma = effective_edf(
648        beta_log_sigma.len(),
649        block_trace_sum(n_time + n_threshold, n_log_sigma),
650    );
651    let wiggle_len = beta_link_wiggle.as_ref().map_or(0, |beta| beta.len());
652    let edf_link_wiggle = effective_edf(
653        wiggle_len,
654        block_trace_sum(n_time + n_threshold + n_log_sigma, n_wiggle),
655    );
656    let edf_total = edf_time + edf_threshold + edf_log_sigma + edf_link_wiggle;
657
658    use crate::model_types::{BlockRole, FittedBlock, FittedLinkState, UnifiedFitResultParts};
659    let mut blocks = vec![
660        FittedBlock {
661            beta: beta_time.clone(),
662            role: BlockRole::Time,
663            edf: edf_time,
664            lambdas: lambdas_time.clone(),
665        },
666        FittedBlock {
667            beta: beta_threshold.clone(),
668            role: BlockRole::Threshold,
669            edf: edf_threshold,
670            lambdas: lambdas_threshold.clone(),
671        },
672        FittedBlock {
673            beta: beta_log_sigma.clone(),
674            role: BlockRole::Scale,
675            edf: edf_log_sigma,
676            lambdas: lambdas_log_sigma.clone(),
677        },
678    ];
679    if let Some(ref bw) = beta_link_wiggle {
680        blocks.push(FittedBlock {
681            beta: bw.clone(),
682            role: BlockRole::LinkWiggle,
683            edf: edf_link_wiggle,
684            lambdas: lambdas_linkwiggle
685                .clone()
686                .unwrap_or_else(|| Array1::zeros(0)),
687        });
688    }
689    let all_lambdas: Vec<f64> = blocks
690        .iter()
691        .flat_map(|b| b.lambdas.iter().copied())
692        .collect();
693    let log_lambdas = Array1::from_vec(
694        all_lambdas
695            .iter()
696            .map(|&v| if v > 0.0 { v.ln() } else { f64::NEG_INFINITY })
697            .collect(),
698    );
699    // Report the genuine per-penalty trace / effective-d.f. channels when the
700    // inner solver supplied them (aligned 1:1 with `all_lambdas`); otherwise
701    // leave them empty so downstream consumers treat them as unavailable rather
702    // than reading a fabricated uniform split (issue #2106).
703    let inference_penalty_block_trace = if penalty_block_trace.len() == all_lambdas.len() {
704        penalty_block_trace.clone()
705    } else {
706        Vec::new()
707    };
708    let inference_edf_by_block = if edf_by_block.len() == all_lambdas.len() {
709        edf_by_block.clone()
710    } else {
711        Vec::new()
712    };
713    let inference = geometry
714        .as_ref()
715        .map(|geom| gam_solve::estimate::FitInference {
716            edf_by_block: inference_edf_by_block.clone(),
717            penalty_block_trace: inference_penalty_block_trace.clone(),
718            edf_total,
719            smoothing_correction: None,
720            smoothing_correction_method: None,
721            smoothing_correction_first_order: None,
722            smoothing_correction_method_first_order: None,
723            penalized_hessian: geom.penalized_hessian.clone(),
724            reparam_qs: None,
725            dispersion: gam_solve::estimate::Dispersion::UNIT,
726            beta_covariance: covariance_conditional.clone().map(Into::into),
727            beta_standard_errors: covariance_conditional
728                .as_ref()
729                .map(|cov| Array1::from_iter(cov.diag().iter().map(|&v| v.max(0.0).sqrt()))),
730            beta_covariance_corrected: None,
731            beta_standard_errors_corrected: None,
732            beta_covariance_frequentist: None,
733            coefficient_influence: None,
734            weighted_gram: None,
735            bias_correction_beta: None,
736            bias_correction_jacobian: None,
737        });
738
739    let deviance = -2.0 * log_likelihood;
740    crate::model_types::UnifiedFitResult::try_from_parts(UnifiedFitResultParts {
741        blocks,
742        log_lambdas,
743        lambdas: Array1::from_vec(all_lambdas),
744        likelihood_family: None,
745        likelihood_scale: gam_problem::LikelihoodScaleMetadata::Unspecified,
746        log_likelihood_normalization: gam_problem::LogLikelihoodNormalization::UserProvided,
747        log_likelihood,
748        deviance,
749        reml_score,
750        stable_penalty_term,
751        penalized_objective,
752        used_device,
753        outer_iterations,
754        outer_converged,
755        outer_gradient_norm,
756        standard_deviation: 1.0,
757        covariance_conditional,
758        covariance_corrected: None,
759        inference,
760        fitted_link: FittedLinkState::Standard(None),
761        geometry,
762        block_states: Vec::new(),
763        pirls_status: gam_solve::pirls::PirlsStatus::Converged,
764        max_abs_eta: 0.0,
765        constraint_kkt: None,
766        artifacts: crate::model_types::FitArtifacts {
767            pirls: None,
768            null_space_logdet: None,
769            null_space_dim: None,
770            survival_link_wiggle_knots: link_wiggle_knots,
771            survival_link_wiggle_degree: link_wiggle_degree,
772            criterion_certificate,
773            rho_posterior_certificate: None,
774            rho_posterior_escalation: None,
775            rho_covariance: None,
776            joint_log_lambdas: None,
777            // Survival location-scale fits optimize the plain penalized
778            // likelihood; the Firth/Jeffreys adjustment is a binary-separation
779            // remedy and is never engaged here.
780            firth_bias_reduction: false,
781        },
782        inner_cycles: 0,
783    })
784    .map_err(|e| e.to_string())
785}
786
787#[derive(Clone)]
788pub struct SurvivalLocationScalePredictInput {
789    pub x_time_exit: Array2<f64>,
790    pub eta_time_offset_exit: Array1<f64>,
791    pub time_wiggle_knots: Option<Array1<f64>>,
792    pub time_wiggle_degree: Option<usize>,
793    pub time_wiggle_ncols: usize,
794    pub x_threshold: DesignMatrix,
795    pub eta_threshold_offset: Array1<f64>,
796    pub x_log_sigma: DesignMatrix,
797    pub eta_log_sigma_offset: Array1<f64>,
798    pub x_link_wiggle: Option<DesignMatrix>,
799    pub link_wiggle_knots: Option<Array1<f64>>,
800    pub link_wiggle_degree: Option<usize>,
801    pub inverse_link: InverseLink,
802}
803
804#[derive(Clone, Debug)]
805pub struct SurvivalLocationScalePredictResult {
806    pub eta: Array1<f64>,
807    pub survival_prob: Array1<f64>,
808}
809
810#[derive(Clone)]
811pub struct SurvivalLocationScalePredictUncertaintyResult {
812    pub eta: Array1<f64>,
813    pub survival_prob: Array1<f64>,
814    pub eta_standard_error: Array1<f64>,
815    pub response_standard_error: Option<Array1<f64>>,
816}
817
818pub(crate) fn initial_log_lambdas<T>(
819    penalties: &[T],
820    rho0: Option<Array1<f64>>,
821) -> Result<Array1<f64>, String> {
822    let k = penalties.len();
823    let rho = rho0.unwrap_or_else(|| Array1::zeros(k));
824    if rho.len() != k {
825        return Err(SurvivalLocationScaleError::DimensionMismatch {
826            reason: format!(
827                "initial_log_lambdas mismatch: got {}, expected {k}",
828                rho.len()
829            ),
830        }
831        .into());
832    }
833    Ok(rho)
834}