Skip to main content

gam_models/survival/location_scale/
spec.rs

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