Skip to main content

gam_models/inference/
model_payload_builders.rs

1//! Shared, source-agnostic builders for saved-model payloads.
2//!
3//! The CLI (`src/main.rs`) and the Python FFI (`crates/gam-pyffi/src/lib.rs`)
4//! both persist fitted models, and both used to assemble the serialized
5//! [`FittedModelPayload`] independently. That meant the on-disk contract for a
6//! given model kind could silently drift depending on whether the model was
7//! created through the CLI or through Python — exactly the failure mode that
8//! repeatedly bit the marginal-slope save→load path.
9//!
10//! This module assembles the *semantic* payload exactly once. Each caller is
11//! responsible only for the source-specific work of producing the resolved
12//! semantic inputs (the CLI threads them through from its argument parsing and
13//! fit pipeline; the FFI freezes term collections from designs and re-derives
14//! metadata from the [`FitConfig`]). Once both sides hand the same semantic
15//! content to the same assembler, payload drift becomes impossible by
16//! construction.
17
18use crate::bms::deviation_runtime::AnchorComponentTag;
19use crate::bms::{
20    DeviationRuntime, LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
21};
22use crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL;
23use crate::fit_orchestration::drivers::freeze_term_collection_from_design;
24use crate::fit_orchestration::{FitConfig, StandardFitResult, expectile_tau_for_config};
25use crate::inference::model::{
26    FittedEstimator, FittedFamily, FittedModelPayload, MODEL_PAYLOAD_VERSION, ModelKind,
27    SavedAnchorComponent, SavedAnchorKind, SavedCompiledFlexBlock, SavedLatentZNormalization,
28    SavedResidualCascade, SavedSplineScan, SavedSurvivalLocationScaleStructure,
29    SavedTransformationNormalGeometry, TransformationNormalParameterization,
30    TransformationScoreCalibration,
31};
32use crate::scale_design::ScaleDeviationTransform;
33use crate::survival::construction::{
34    SavedSurvivalTimeBasis, SurvivalBaselineConfig, survival_baseline_targetname,
35};
36use crate::survival::location_scale::{
37    ResidualDistribution, SurvivalCovariateTimeBasis, SurvivalLocationScaleTimeParameterization,
38    residual_distribution_from_inverse_link,
39};
40use crate::transformation_normal::TransformationNormalFamily;
41use faer::Side;
42use gam_data::{DataSchema, EncodedDataset};
43use gam_linalg::faer_ndarray::{FaerCholesky, array2_to_nested_vec};
44use gam_problem::types::{
45    InverseLink, LikelihoodSpec, ResponseFamily, StandardLink, inverse_link_to_binomial_spec,
46};
47use gam_solve::estimate::{
48    FittedLinkState, UnifiedFitResult, saved_latent_cloglog_state_from_fit,
49    saved_mixture_state_from_fit, saved_sas_state_from_fit,
50};
51use gam_terms::smooth::{TermCollectionDesign, TermCollectionSpec};
52use ndarray::{Array1, Array2, s};
53
54/// Family tag persisted for Bernoulli marginal-slope saved models.
55const FAMILY_BERNOULLI_MARGINAL_SLOPE: &str = "bernoulli-marginal-slope";
56
57/// Family tag persisted for transformation-normal saved models.
58const FAMILY_TRANSFORMATION_NORMAL: &str = "transformation-normal";
59
60/// Serialize an anchored-deviation [`DeviationRuntime`] (score-warp or
61/// link-deviation block) into its persistable [`SavedCompiledFlexBlock`] form.
62///
63/// This is the single source of truth for that conversion; the CLI and FFI
64/// payload builders both route through it so the serialized flex contract
65/// cannot diverge between the two save paths.
66pub fn serialize_anchored_deviation_runtime(runtime: &DeviationRuntime) -> SavedCompiledFlexBlock {
67    let mut anchor_correction: Option<Vec<Vec<f64>>> = None;
68    let mut anchor_components: Vec<SavedAnchorComponent> = Vec::new();
69    if let Some(installed) = runtime.installed_flex_block() {
70        anchor_correction = Some(
71            installed
72                .anchor_correction
73                .rows()
74                .into_iter()
75                .map(|row| row.to_vec())
76                .collect::<Vec<Vec<f64>>>(),
77        );
78        for component in &installed.anchor_components {
79            anchor_components.push(SavedAnchorComponent {
80                kind: match component {
81                    AnchorComponentTag::Parametric { block, ncols } => {
82                        SavedAnchorKind::Parametric {
83                            block: *block,
84                            ncols: *ncols,
85                        }
86                    }
87                    AnchorComponentTag::FlexEvaluation { ncols } => {
88                        SavedAnchorKind::FlexEvaluation { ncols: *ncols }
89                    }
90                },
91            });
92        }
93    }
94    SavedCompiledFlexBlock {
95        kernel: ANCHORED_DEVIATION_KERNEL.to_string(),
96        breakpoints: runtime.breakpoints().to_vec(),
97        basis_dim: runtime.basis_dim(),
98        span_c0: runtime
99            .span_c0()
100            .rows()
101            .into_iter()
102            .map(|row| row.to_vec())
103            .collect(),
104        span_c1: runtime
105            .span_c1()
106            .rows()
107            .into_iter()
108            .map(|row| row.to_vec())
109            .collect(),
110        span_c2: runtime
111            .span_c2()
112            .rows()
113            .into_iter()
114            .map(|row| row.to_vec())
115            .collect(),
116        span_c3: runtime
117            .span_c3()
118            .rows()
119            .into_iter()
120            .map(|row| row.to_vec())
121            .collect(),
122        anchor_correction,
123        anchor_components,
124    }
125}
126
127/// Source-specific metadata that the CLI and FFI populate differently but that
128/// every saved payload carries.
129///
130/// `training_feature_ranges` is the only field the FFI path cannot currently
131/// supply (it persists headers without per-feature ranges); modeling it as
132/// `Option` keeps that distinction explicit instead of silently encoding an
133/// empty vector as if ranges were known.
134pub struct SavedModelSourceMetadata {
135    pub training_headers: Vec<String>,
136    pub training_feature_ranges: Option<Vec<(f64, f64)>>,
137    pub offset_column: Option<String>,
138    pub noise_offset_column: Option<String>,
139}
140
141impl SavedModelSourceMetadata {
142    fn apply_to(self, payload: &mut FittedModelPayload) {
143        match self.training_feature_ranges {
144            Some(ranges) => payload.set_training_feature_metadata(self.training_headers, ranges),
145            None => payload.training_headers = Some(self.training_headers),
146        }
147        payload.offset_column = self.offset_column;
148        payload.noise_offset_column = self.noise_offset_column;
149    }
150}
151
152/// Complete semantic input for persisting a standard formula fit.
153///
154/// The workflow result is consumed as one value so callers cannot accidentally
155/// mix a design, resolved term specification, fitted link, or wiggle state from
156/// different fits.  Formula front ends should fit through `fit_from_formula`
157/// and hand its `Standard` result directly to this assembler.
158pub struct StandardPayloadInputs<'a> {
159    pub formula: String,
160    pub dataset: &'a EncodedDataset,
161    pub fit_config: &'a FitConfig,
162    pub result: StandardFitResult,
163}
164
165fn fitted_inverse_link(state: &FittedLinkState) -> Option<InverseLink> {
166    match state {
167        FittedLinkState::Standard(Some(link)) => Some(InverseLink::Standard(*link)),
168        FittedLinkState::Standard(None) => None,
169        FittedLinkState::LatentCLogLog { state } => Some(InverseLink::LatentCLogLog(*state)),
170        FittedLinkState::Sas { state, .. } => Some(InverseLink::Sas(*state)),
171        FittedLinkState::BetaLogistic { state, .. } => Some(InverseLink::BetaLogistic(*state)),
172        FittedLinkState::Mixture { state, .. } => Some(InverseLink::Mixture(state.clone())),
173    }
174}
175
176fn standard_null_space_metadata(
177    design: &TermCollectionDesign,
178    fit: &UnifiedFitResult,
179) -> Result<(usize, f64), String> {
180    let hessian = fit
181        .penalized_hessian()
182        .ok_or_else(|| "null-space Hessian logdet requires fitted penalized Hessian".to_string())?;
183    let hessian_dim = hessian.nrows();
184    if hessian.ncols() != hessian_dim {
185        return Err(format!(
186            "null-space Hessian logdet requires a square Hessian, got {}x{}",
187            hessian.nrows(),
188            hessian.ncols()
189        ));
190    }
191    let p = design.design.ncols();
192    if hessian_dim < p {
193        return Err(format!(
194            "null-space Hessian logdet design/Hessian mismatch: design has {p} columns but \
195             Hessian is only {hessian_dim}x{hessian_dim}"
196        ));
197    }
198    if design.penalties.is_empty() {
199        return Ok((0, 0.0));
200    }
201    let hessian = if hessian_dim > p {
202        hessian.slice(s![0..p, 0..p]).to_owned()
203    } else {
204        hessian.clone()
205    };
206    let mut penalty = Array2::<f64>::zeros((p, p));
207    for (idx, block) in design.penalties.iter().enumerate() {
208        let range = block.col_range.clone();
209        if range.start > range.end
210            || range.end > p
211            || block.local.nrows() != range.len()
212            || block.local.ncols() != range.len()
213        {
214            return Err(format!(
215                "null-space Hessian logdet penalty {idx} shape mismatch: range {}..{}, local {}x{}, p={p}",
216                range.start,
217                range.end,
218                block.local.nrows(),
219                block.local.ncols()
220            ));
221        }
222        penalty
223            .slice_mut(s![range.clone(), range])
224            .scaled_add(1.0, &block.local);
225    }
226    let (null_basis, _) = gam_linalg::faer_ndarray::rrqr_nullspace_basis(
227        &penalty,
228        gam_linalg::faer_ndarray::default_rrqr_rank_alpha(),
229    )
230    .map_err(|err| format!("failed to compute penalty null-space basis: {err}"))?;
231    let q = null_basis.ncols();
232    if q == 0 {
233        return Ok((0, 0.0));
234    }
235    let projected = hessian.dot(&null_basis);
236    let mut restricted = null_basis.t().dot(&projected);
237    restricted = (&restricted + &restricted.t()) * 0.5;
238    let chol = restricted
239        .cholesky(Side::Lower)
240        .map_err(|err| format!("null-space Hessian is not positive definite: {err}"))?;
241    let logdet = 2.0 * chol.diag().iter().map(|value| value.ln()).sum::<f64>();
242    if logdet.is_finite() {
243        Ok((q, logdet))
244    } else {
245        Err(format!("null-space Hessian logdet is not finite: {logdet}"))
246    }
247}
248
249fn response_for_standard_payload(formula: &str, dataset: &EncodedDataset) -> Option<Array1<f64>> {
250    let response = gam_terms::inference::formula_dsl::parse_formula(formula)
251        .ok()?
252        .response;
253    let column = *dataset.column_map().get(&response)?;
254    Some(dataset.values.column(column).to_owned())
255}
256
257fn standard_conformal_substrates(
258    formula: &str,
259    dataset: &EncodedDataset,
260    fit_config: &FitConfig,
261    family: &LikelihoodSpec,
262    fit: &UnifiedFitResult,
263    design: &TermCollectionDesign,
264) -> (
265    Option<crate::inference::full_conformal::GaussianJackknifePlusStats>,
266    Option<crate::inference::full_conformal::ExactFullConformalSubstrate>,
267) {
268    let expectile = fit_config.family.as_deref().is_some_and(|family| {
269        let family = family.trim().to_ascii_lowercase();
270        family == "expectile" || family.starts_with("expectile(")
271    });
272    if expectile
273        || !family.is_gaussian_identity()
274        || fit_config.weight_column.is_some()
275        || fit_config.offset_column.is_some()
276        || fit_config.flexible_link
277        || design.affine_offset.iter().any(|value| *value != 0.0)
278    {
279        return (None, None);
280    }
281    let Some(y) = response_for_standard_payload(formula, dataset) else {
282        return (None, None);
283    };
284    let Ok(x) = design.design.try_to_dense_arc("standard conformal design") else {
285        return (None, None);
286    };
287    let Some(normal_matrix) = fit.penalized_hessian() else {
288        return (None, None);
289    };
290    if x.nrows() != y.len()
291        || normal_matrix.nrows() != x.ncols()
292        || normal_matrix.ncols() != x.ncols()
293    {
294        return (None, None);
295    }
296    let weights = Array1::<f64>::ones(y.len());
297    let jackknife = crate::inference::full_conformal::GaussianJackknifePlusStats::from_design_unit_weight_normal_matrix(
298        x.as_ref(),
299        &y,
300        &weights,
301        normal_matrix,
302    )
303    .ok();
304    let full = crate::inference::full_conformal::ExactFullConformalSubstrate::from_design_unit_weight_normal_matrix(
305        x.as_ref(),
306        &y,
307        &weights,
308        normal_matrix,
309    )
310    .ok();
311    (jackknife, full)
312}
313
314/// Assemble the one canonical saved payload for a standard formula fit.
315pub fn assemble_standard_payload(
316    inputs: StandardPayloadInputs<'_>,
317) -> Result<FittedModelPayload, String> {
318    let StandardPayloadInputs {
319        formula,
320        dataset,
321        fit_config,
322        result,
323    } = inputs;
324    let StandardFitResult {
325        mut fit,
326        design,
327        resolvedspec,
328        adaptive_diagnostics,
329        saved_link_state,
330        wiggle_knots,
331        wiggle_degree,
332        wiggle_penalty_metadata,
333        wiggle_saved_warp_beta,
334        wiggle_saved_index_shift,
335        ..
336    } = result;
337    fit.fitted_link = saved_link_state;
338    let resolved_termspec = freeze_term_collection_from_design(&resolvedspec, &design)
339        .map_err(|err| format!("failed to freeze standard term specification: {err}"))?;
340    let (null_space_dim, null_space_logdet) = standard_null_space_metadata(&design, &fit)?;
341    fit.artifacts.null_space_dim = Some(null_space_dim);
342    fit.artifacts.null_space_logdet = Some(null_space_logdet);
343    let family = fit
344        .likelihood_family
345        .clone()
346        .unwrap_or_else(LikelihoodSpec::gaussian_identity);
347    let estimator = expectile_tau_for_config(fit_config)
348        .map_err(|error| format!("failed to persist estimator metadata: {error}"))?
349        .map_or(FittedEstimator::Likelihood, |tau| {
350            FittedEstimator::Expectile { tau }
351        });
352    let family_label = match estimator {
353        FittedEstimator::Likelihood => family.name().to_string(),
354        FittedEstimator::Expectile { tau } => format!("expectile({tau})"),
355    };
356    let (gaussian_jackknife_plus, full_conformal) =
357        standard_conformal_substrates(&formula, dataset, fit_config, &family, &fit, &design);
358    let latent_cloglog_state = if family.is_latent_cloglog() {
359        Some(saved_latent_cloglog_state_from_fit(&fit).ok_or_else(|| {
360            "latent-cloglog-binomial fit did not produce a fitted latent-cloglog state".to_string()
361        })?)
362    } else {
363        saved_latent_cloglog_state_from_fit(&fit)
364    };
365    let mut payload = FittedModelPayload::new(
366        MODEL_PAYLOAD_VERSION,
367        formula,
368        ModelKind::Standard,
369        FittedFamily::Standard {
370            likelihood: family.clone(),
371            link: StandardLink::try_from(family.link_function()).ok(),
372            latent_cloglog_state,
373            mixture_state: saved_mixture_state_from_fit(&fit),
374            sas_state: saved_sas_state_from_fit(&fit),
375        },
376        family_label,
377    );
378    payload.estimator = estimator;
379    payload.unified = Some(fit.clone());
380    payload.fit_result = Some(fit.clone());
381    payload.data_schema = Some(dataset.schema.clone());
382    payload.link = fitted_inverse_link(&fit.fitted_link).or_else(|| Some(family.link.clone()));
383    payload.linkwiggle_knots = wiggle_knots.map(|knots| knots.to_vec());
384    payload.linkwiggle_degree = wiggle_degree;
385    payload.linkwiggle_penalty_metadata = wiggle_penalty_metadata;
386    payload.beta_link_wiggle = wiggle_saved_warp_beta;
387    payload.link_wiggle_index_shift = wiggle_saved_index_shift;
388    match &fit.fitted_link {
389        FittedLinkState::Mixture { covariance, .. } => {
390            payload.mixture_link_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
391        }
392        FittedLinkState::Sas { covariance, .. }
393        | FittedLinkState::BetaLogistic { covariance, .. } => {
394            payload.sas_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
395        }
396        FittedLinkState::Standard(_) | FittedLinkState::LatentCLogLog { .. } => {}
397    }
398    payload.set_training_feature_metadata(dataset.headers.clone(), dataset.feature_ranges());
399    payload.resolved_termspec = Some(resolved_termspec);
400    payload.adaptive_regularization_diagnostics = adaptive_diagnostics;
401    payload.offset_column = fit_config.offset_column.clone();
402    payload.noise_offset_column = fit_config.noise_offset_column.clone();
403    payload.weight_column = fit_config.weight_column.clone();
404    payload.gaussian_jackknife_plus = gaussian_jackknife_plus;
405    payload.full_conformal = full_conformal;
406    Ok(payload)
407}
408
409/// The resolved, source-agnostic semantic content of a Bernoulli
410/// marginal-slope saved model.
411///
412/// The CLI threads these in directly from its fit pipeline; the FFI produces
413/// them by freezing its term collections and reading the [`FitConfig`]. Either
414/// way, the assembler below turns them into the canonical payload.
415pub struct BernoulliMarginalSlopeInputs<'a> {
416    pub formula: String,
417    pub data_schema: DataSchema,
418    pub logslope_formula: String,
419    pub z_column: String,
420    pub resolved_marginalspec: TermCollectionSpec,
421    pub resolved_logslopespec: TermCollectionSpec,
422    pub fit_result: UnifiedFitResult,
423    /// Number of *raw* marginal design columns `p_m` (= the term-collection
424    /// marginal design's `ncols()` BEFORE any #461 influence-absorber widening).
425    ///
426    /// When the Stage-1 influence absorber is active (A2), the fitted marginal
427    /// block carries the widened coefficient `[β_m; γ]` (length `p_m + p₁`) and
428    /// the joint covariance is dimensioned over the widened block. The absorbed
429    /// influence columns `Z̃_infl` are a TRAINING-only leakage absorber that does
430    /// not exist at predict rows, so the persisted model must drop `γ` and the
431    /// marginalized-out covariance sub-block to stay self-consistent against the
432    /// raw `p_m` marginal design at predict. The assembler uses this to truncate
433    /// the fit result once (shared CLI + FFI). With no absorber it equals the
434    /// fitted block width and the truncation is a no-op.
435    pub p_marginal: usize,
436    pub baseline_marginal: f64,
437    pub baseline_logslope: f64,
438    pub latent_z_normalization: SavedLatentZNormalization,
439    pub latent_measure: LatentMeasureKind,
440    pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
441    pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
442    pub score_warp_runtime: Option<&'a DeviationRuntime>,
443    pub link_dev_runtime: Option<&'a DeviationRuntime>,
444    pub base_link: InverseLink,
445    pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
446}
447
448/// Drop the #461 training-only influence-absorber coefficients `γ` from a fitted
449/// Bernoulli marginal-slope result so the persisted model is self-consistent
450/// against the raw `p_m`-column marginal design at predict.
451///
452/// When the A2 influence absorber is active the marginal block (block 0) is the
453/// widened `[β_m; γ]` (length `p_m + p₁`, with `γ` the contiguous trailing `p₁`
454/// columns — see bms `widen_marginal_dense_with_influence`) and the joint
455/// conditional covariance is dimensioned over the widened joint coefficient
456/// vector. The absorbed columns `Z̃_infl` exist only at training rows; predict
457/// reconstructs the marginal index from the raw `p_m` design and the
458/// orthogonalized `β̂_m` is a property of the training fit. So this:
459///
460///  * slices `blocks[0].beta` and `block_states[0].beta` to their first `p_m`
461///    entries (the flat `beta` is recomputed from the blocks by
462///    `try_from_parts`),
463///  * **marginalizes** `γ` out of the joint Gaussian by dropping the `γ`
464///    rows/cols from the conditional covariance — taking the corresponding
465///    SUB-BLOCK of `Σ` is the exact marginal of a joint Gaussian (no
466///    re-inversion), so the kept `[β_m | β_logslope | …]` covariance is the
467///    correct predictive uncertainty accounting for the fitted absorber,
468///  * drops the persisted joint penalized-Hessian geometry: it is a precision
469///    over the *widened* joint coefficient vector, so a sub-block would be the
470///    wrong marginalization, and the only predict path that consumes it is the
471///    covariance-fallback that re-inverts `H` — which post-truncation would have
472///    the wrong dimension anyway. With the dense (already-marginalized) `Σ`
473///    matching the predict dimension, that fallback is never taken, so dropping
474///    the geometry removes a stale, wrong-dimension path rather than a used one.
475///
476/// Block-level `edf` / `lambdas` are left untouched: they are fitted scalars
477/// that legitimately reflect the full model (the absorber consumed real dof at
478/// fit time) and are persisted as-is. With no absorber (`block0.len() == p_m`)
479/// this is a no-op clone.
480fn truncate_marginal_slope_influence_absorber(
481    fit_result: UnifiedFitResult,
482    p_marginal: usize,
483) -> Result<UnifiedFitResult, String> {
484    let Some(block0) = fit_result.blocks.first() else {
485        return Err("marginal-slope fit result has no coefficient blocks".to_string());
486    };
487    let widened_len = block0.beta.len();
488    if widened_len <= p_marginal {
489        // No influence absorber installed (or already raw width): nothing to drop.
490        return Ok(fit_result);
491    }
492    let p_influence = widened_len - p_marginal;
493
494    // The input fit's existence is its convergence proof (sealed
495    // `FitConvergenceEvidence`); carry the certified inner status into the
496    // narrowed reassembly, which revalidates the preserved artifacts.
497    let pirls_status = fit_result.convergence_evidence().inner_status();
498    let UnifiedFitResult {
499        mut blocks,
500        log_lambdas,
501        lambdas,
502        likelihood_family,
503        likelihood_scale,
504        log_likelihood_normalization,
505        log_likelihood,
506        deviance,
507        reml_score,
508        stable_penalty_term,
509        penalized_objective,
510        used_device,
511        outer_iterations,
512        outer_gradient_norm,
513        standard_deviation,
514        covariance_conditional,
515        covariance_corrected,
516        inference,
517        fitted_link,
518        geometry: _,
519        mut block_states,
520        beta: _,
521        max_abs_eta,
522        constraint_kkt,
523        artifacts,
524        inner_cycles,
525        outer_cost_evals: _,
526        inner_pirls_solves: _,
527        ..
528    } = fit_result;
529
530    // Slice block 0's coefficients (and matching block-state) to the raw p_m,
531    // dropping the trailing γ absorber columns.
532    blocks[0].beta = blocks[0].beta.slice(ndarray::s![..p_marginal]).to_owned();
533    if let Some(state0) = block_states.first_mut() {
534        state0.beta = state0.beta.slice(ndarray::s![..p_marginal]).to_owned();
535    }
536
537    // Marginalize γ out of the joint conditional covariance: keep every index
538    // except the contiguous γ block [p_marginal, p_marginal + p_influence).
539    let drop_gamma_block = |cov: Option<Array2<f64>>| -> Option<Array2<f64>> {
540        cov.map(|cov| {
541            let total = cov.nrows();
542            let kept: Vec<usize> = (0..p_marginal)
543                .chain((p_marginal + p_influence)..total)
544                .collect();
545            let mut out = Array2::<f64>::zeros((kept.len(), kept.len()));
546            for (ri, &r) in kept.iter().enumerate() {
547                for (ci, &c) in kept.iter().enumerate() {
548                    out[[ri, ci]] = cov[[r, c]];
549                }
550            }
551            out
552        })
553    };
554    let covariance_conditional = drop_gamma_block(covariance_conditional);
555    let covariance_corrected = drop_gamma_block(covariance_corrected);
556
557    UnifiedFitResult::try_from_parts(gam_solve::estimate::UnifiedFitResultParts {
558        blocks,
559        log_lambdas,
560        lambdas,
561        likelihood_family,
562        likelihood_scale,
563        log_likelihood_normalization,
564        log_likelihood,
565        deviance,
566        reml_score,
567        stable_penalty_term,
568        penalized_objective,
569        // Preserve the GPU-execution flag across the absorber-column
570        // truncation: dropping the trailing γ columns does not change which
571        // device ran the solve.
572        used_device,
573        outer_iterations,
574        outer_converged: true,
575        outer_gradient_norm,
576        standard_deviation,
577        covariance_conditional,
578        covariance_corrected,
579        inference,
580        fitted_link,
581        // Drop the widened-joint penalized Hessian: see the doc comment.
582        geometry: None,
583        block_states,
584        pirls_status,
585        max_abs_eta,
586        constraint_kkt,
587        artifacts,
588        inner_cycles,
589    })
590    .map_err(|e| {
591        format!("marginal-slope influence-absorber truncation produced an invalid fit result: {e}")
592    })
593}
594
595/// Assemble the canonical spline-scan payload (#1030/#1034): a standard
596/// Gaussian-identity model whose fit representation is the exact O(n)
597/// smoothing-spline smoother state instead of a dense `fit_result`. The CLI
598/// and FFI save paths both route through here so the scan on-disk contract
599/// cannot diverge between sources.
600pub fn assemble_spline_scan_payload(
601    formula: String,
602    feature_column: String,
603    fit: &gam_solve::spline_scan::SplineScanFit,
604    data_schema: DataSchema,
605    training_headers: Vec<String>,
606    training_feature_ranges: Vec<(f64, f64)>,
607) -> FittedModelPayload {
608    let mut payload = FittedModelPayload::new(
609        MODEL_PAYLOAD_VERSION,
610        formula,
611        ModelKind::Standard,
612        FittedFamily::Standard {
613            likelihood: LikelihoodSpec::gaussian_identity(),
614            link: None,
615            latent_cloglog_state: None,
616            mixture_state: None,
617            sas_state: None,
618        },
619        "gaussian".to_string(),
620    );
621    payload.spline_scan = Some(SavedSplineScan {
622        feature_column,
623        state: fit.to_state(),
624    });
625    payload.data_schema = Some(data_schema);
626    payload.set_training_feature_metadata(training_headers, training_feature_ranges);
627    payload
628}
629
630/// Assemble the canonical residual-cascade payload (#1032).
631///
632/// The CLI and FFI save paths both route through here so the cascade on-disk
633/// contract cannot diverge between sources.  Mirrors `assemble_spline_scan_payload`
634/// but for d ∈ {2,3} scattered coordinates (the Wendland multilevel-frame state).
635pub fn assemble_residual_cascade_payload(
636    formula: String,
637    feature_columns: Vec<String>,
638    fit: &gam_solve::residual_cascade::ResidualCascadeFit,
639    data_schema: DataSchema,
640    training_headers: Vec<String>,
641    training_feature_ranges: Vec<(f64, f64)>,
642) -> Result<FittedModelPayload, String> {
643    let mut payload = FittedModelPayload::new(
644        MODEL_PAYLOAD_VERSION,
645        formula,
646        ModelKind::Standard,
647        FittedFamily::Standard {
648            likelihood: gam_problem::types::LikelihoodSpec::gaussian_identity(),
649            link: None,
650            latent_cloglog_state: None,
651            mixture_state: None,
652            sas_state: None,
653        },
654        "gaussian".to_string(),
655    );
656    payload.residual_cascade = Some(SavedResidualCascade {
657        feature_columns,
658        state: fit.to_state().map_err(|e| {
659            format!("residual-cascade to_state failed during payload assembly: {e}")
660        })?,
661    });
662    payload.data_schema = Some(data_schema);
663    payload.set_training_feature_metadata(training_headers, training_feature_ranges);
664    Ok(payload)
665}
666
667/// Assemble the canonical Bernoulli marginal-slope payload.
668///
669/// This is the single place that decides which payload fields a marginal-slope
670/// model carries and how the singular/vector mirror fields
671/// (`formula_logslope(s)`, `z_column(s)`, `logslope_baseline(s)`,
672/// `resolved_termspec_logslope(s)`) are kept consistent — so the CLI and FFI
673/// saved models are byte-equivalent for identical semantic content.
674pub fn assemble_bernoulli_marginal_slope_payload(
675    inputs: BernoulliMarginalSlopeInputs<'_>,
676    source: SavedModelSourceMetadata,
677) -> Result<FittedModelPayload, String> {
678    let BernoulliMarginalSlopeInputs {
679        formula,
680        data_schema,
681        logslope_formula,
682        z_column,
683        resolved_marginalspec,
684        resolved_logslopespec,
685        fit_result,
686        p_marginal,
687        baseline_marginal,
688        baseline_logslope,
689        latent_z_normalization,
690        latent_measure,
691        latent_z_rank_int_calibration,
692        latent_z_conditional_calibration,
693        score_warp_runtime,
694        link_dev_runtime,
695        base_link,
696        frailty,
697    } = inputs;
698
699    // #461 predict seam: drop the training-only influence-absorber γ (and
700    // marginalize it out of the covariance) so the persisted model matches the
701    // raw p_m marginal design at predict. No-op when the absorber is inactive.
702    let fit_result = truncate_marginal_slope_influence_absorber(fit_result, p_marginal)?;
703
704    let marginal_likelihood_spec =
705        inverse_link_to_binomial_spec(&base_link).map_err(|e| e.to_string())?;
706
707    let mut payload = FittedModelPayload::new(
708        MODEL_PAYLOAD_VERSION,
709        formula,
710        ModelKind::MarginalSlope,
711        FittedFamily::MarginalSlope {
712            likelihood: marginal_likelihood_spec,
713            base_link: base_link.clone(),
714            frailty,
715        },
716        FAMILY_BERNOULLI_MARGINAL_SLOPE.to_string(),
717    );
718    payload.unified = Some(fit_result.clone());
719    payload.fit_result = Some(fit_result);
720    payload.data_schema = Some(data_schema);
721    payload.formula_logslope = Some(logslope_formula.clone());
722    payload.z_column = Some(z_column.clone());
723    payload.formula_logslopes = Some(vec![logslope_formula]);
724    payload.z_columns = Some(vec![z_column]);
725    payload.latent_z_normalization = Some(latent_z_normalization);
726    payload.latent_measure = Some(latent_measure);
727    payload.latent_z_rank_int_calibration = latent_z_rank_int_calibration;
728    payload.latent_z_conditional_calibration = latent_z_conditional_calibration;
729    payload.marginal_baseline = Some(baseline_marginal);
730    payload.logslope_baseline = Some(baseline_logslope);
731    payload.logslope_baselines = Some(vec![baseline_logslope]);
732    payload.link = Some(base_link);
733    payload.resolved_termspec = Some(resolved_marginalspec);
734    payload.resolved_termspec_logslopes = Some(vec![resolved_logslopespec.clone()]);
735    payload.resolved_termspec_logslope = Some(resolved_logslopespec);
736    payload.score_warp_runtime = score_warp_runtime.map(serialize_anchored_deviation_runtime);
737    payload.link_deviation_runtime = link_dev_runtime.map(serialize_anchored_deviation_runtime);
738    source.apply_to(&mut payload);
739    Ok(payload)
740}
741
742/// The resolved, source-agnostic semantic content of a transformation-normal
743/// saved model.
744///
745/// As with the marginal-slope inputs, the CLI threads the family and resolved
746/// covariate spec straight from its fit pipeline while the FFI reads them off
747/// its fit-result struct (freezing the covariate spec from its design first).
748pub struct TransformationNormalInputs<'a> {
749    pub formula: String,
750    pub data_schema: DataSchema,
751    pub resolved_covariate_spec: TermCollectionSpec,
752    pub fit_result: UnifiedFitResult,
753    pub family: &'a TransformationNormalFamily,
754    pub score_calibration: TransformationScoreCalibration,
755}
756
757/// Assemble the canonical transformation-normal payload.
758///
759/// Centralizing the response-transform snapshot (`knots`, `transform`,
760/// `degree`, `median`) and the fixed Gaussian-identity likelihood means the CLI
761/// and FFI cannot encode a transformation-normal model two different ways.
762pub fn assemble_transformation_normal_payload(
763    inputs: TransformationNormalInputs<'_>,
764    source: SavedModelSourceMetadata,
765) -> FittedModelPayload {
766    let TransformationNormalInputs {
767        formula,
768        data_schema,
769        resolved_covariate_spec,
770        fit_result,
771        family,
772        score_calibration,
773    } = inputs;
774
775    let mut payload = FittedModelPayload::new(
776        MODEL_PAYLOAD_VERSION,
777        formula,
778        ModelKind::TransformationNormal,
779        FittedFamily::TransformationNormal {
780            likelihood: LikelihoodSpec::new(
781                ResponseFamily::Gaussian,
782                InverseLink::Standard(StandardLink::Identity),
783            ),
784        },
785        FAMILY_TRANSFORMATION_NORMAL.to_string(),
786    );
787    payload.unified = Some(fit_result.clone());
788    payload.fit_result = Some(fit_result);
789    payload.data_schema = Some(data_schema);
790    payload.resolved_termspec = Some(resolved_covariate_spec);
791    payload.transformation_response_knots = Some(family.response_knots().to_vec());
792    payload.transformation_response_transform = Some(
793        family
794            .response_transform()
795            .rows()
796            .into_iter()
797            .map(|row| row.to_vec())
798            .collect(),
799    );
800    payload.transformation_response_degree = Some(family.response_degree());
801    payload.transformation_response_median = Some(family.response_median());
802    payload.transformation_geometry = Some(transformation_normal_geometry(family));
803    // Persist the monotonicity-cone carrier Ψ (the fitted covariate design at
804    // κ̂), row-major n × p_cov, so constrained posterior sampling can certify
805    // draws against the positivity cone without replaying the (non-bitwise)
806    // spatial warp. The covariate design is materialized during fitting, so this
807    // is a cache hit; a post-fit materialization failure is an internal invariant
808    // break, not a recoverable condition.
809    let cone_carrier = family
810        .covariate_dense_arc()
811        .expect("CTN covariate design must materialize for the persisted cone carrier");
812    payload.transformation_cone_carrier = Some(cone_carrier.iter().copied().collect());
813    payload.transformation_score_calibration = Some(score_calibration);
814    source.apply_to(&mut payload);
815    payload
816}
817
818/// Snapshot the direct-α CTN geometry (gam#2306) a saved model needs to replay
819/// the transform and the certified-domain prediction refusal.
820///
821/// The response value basis is `[1, I_1, …, I_K]` (`p_resp` columns), so the
822/// shape-coordinate count is `p_resp − 1` (column 0 is the unconstrained
823/// location field). The Khatri-Rao positivity-cone carrier is the `n × p_cov`
824/// covariate design, and the certified response support is the clamped-knot
825/// span `[knots.first, knots.last]` the endpoint bases were evaluated at.
826fn transformation_normal_geometry(
827    family: &TransformationNormalFamily,
828) -> SavedTransformationNormalGeometry {
829    let knots = family.response_knots();
830    let lo = knots.iter().copied().fold(f64::INFINITY, f64::min);
831    let hi = knots.iter().copied().fold(f64::NEG_INFINITY, f64::max);
832    SavedTransformationNormalGeometry {
833        parameterization: TransformationNormalParameterization::DirectAlpha,
834        response_degree: family.response_degree(),
835        response_knot_count: knots.len(),
836        shape_coordinate_count: family.p_resp().saturating_sub(1),
837        cone_carrier_covariate_width: family.p_cov(),
838        cone_carrier_row_count: family.n_obs(),
839        certified_response_support: (lo, hi),
840        response_median: family.response_median(),
841    }
842}
843
844/// Which likelihood a (non-survival) location-scale model carries: Gaussian
845/// (residual response scale) or binomial (noise scale-deviation transform whose
846/// likelihood is resolved from the inverse link). The assembler resolves the
847/// `FittedFamily` from this once, rather than each save path stamping a
848/// (potentially wrong) likelihood and patching it afterwards.
849pub enum LocationScaleResponse<'a> {
850    /// Gaussian identity; `base_link` is the optional resolved base link the CLI
851    /// may pass through from `link(...)` (the FFI leaves it `None`).
852    Gaussian {
853        response_scale: f64,
854        base_link: Option<InverseLink>,
855    },
856    /// Binomial under `link`, with the encoded noise scale-deviation transform.
857    Binomial {
858        link: InverseLink,
859        noise_transform: &'a ScaleDeviationTransform,
860    },
861    /// A genuine-dispersion mean family (NegativeBinomial / Gamma / Beta /
862    /// Tweedie) whose log-precision channel carries `noise_formula` (#913). The
863    /// `likelihood` is the family's own [`LikelihoodSpec`]; `base_link` is the
864    /// mean inverse link (log, or logit for Beta). The log-precision block
865    /// coefficients ride in [`LocationScaleInputs::beta_noise`].
866    Dispersion {
867        likelihood: LikelihoodSpec,
868        base_link: InverseLink,
869        family_tag: &'static str,
870    },
871}
872
873/// Optional link-wiggle metadata persisted alongside a location-scale model.
874/// Knots/coefficients are already in raw response units — the Gaussian
875/// standardization and its inverse remap live inside
876/// `fit_gaussian_location_scale_model`, so the save path persists them verbatim.
877pub struct LocationScaleWiggle {
878    pub knots: Vec<f64>,
879    pub degree: usize,
880    pub beta_link_wiggle: Vec<f64>,
881}
882
883/// Source-agnostic semantic content of a (non-survival) location-scale saved
884/// model — the shared core behind the CLI's Gaussian/binomial save paths and
885/// the FFI's two location-scale builders.
886pub struct LocationScaleInputs {
887    pub formula: String,
888    pub data_schema: DataSchema,
889    pub noise_formula: String,
890    pub resolved_termspec: TermCollectionSpec,
891    pub resolved_termspec_noise: TermCollectionSpec,
892    pub fit_result: UnifiedFitResult,
893    pub beta_noise: Option<Vec<f64>>,
894    pub wiggle: Option<LocationScaleWiggle>,
895}
896
897/// Assemble the canonical (non-survival) location-scale payload — single source
898/// of truth for that on-disk contract. The family/likelihood is resolved from
899/// the [`LocationScaleResponse`] so the binomial branch never persists a wrong
900/// probit likelihood that a caller must patch afterwards.
901pub fn assemble_location_scale_payload(
902    inputs: LocationScaleInputs,
903    response: LocationScaleResponse<'_>,
904    source: SavedModelSourceMetadata,
905) -> Result<FittedModelPayload, String> {
906    let (family_tag, likelihood, base_link, link, response_scale, noise_transform) = match response
907    {
908        LocationScaleResponse::Gaussian {
909            response_scale,
910            base_link,
911        } => (
912            "gaussian-location-scale".to_string(),
913            LikelihoodSpec::gaussian_identity(),
914            // Gaussian location-scale does not carry a base link in its family
915            // state; the resolved link is persisted in `payload.link` below so
916            // prediction can recover it.
917            None,
918            Some(base_link.unwrap_or(InverseLink::Standard(StandardLink::Identity))),
919            Some(response_scale),
920            None,
921        ),
922        LocationScaleResponse::Binomial {
923            link,
924            noise_transform,
925        } => {
926            let likelihood = inverse_link_to_binomial_spec(&link).map_err(|e| {
927                format!("failed to resolve LikelihoodSpec for binomial location-scale link {link:?}: {e}")
928            })?;
929            (
930                "binomial-location-scale".to_string(),
931                likelihood,
932                Some(link.clone()),
933                Some(link),
934                None,
935                Some(noise_transform),
936            )
937        }
938        LocationScaleResponse::Dispersion {
939            likelihood,
940            base_link,
941            family_tag,
942        } => (
943            family_tag.to_string(),
944            likelihood,
945            Some(base_link.clone()),
946            Some(base_link),
947            None,
948            None,
949        ),
950    };
951
952    let mut payload = FittedModelPayload::new(
953        MODEL_PAYLOAD_VERSION,
954        inputs.formula,
955        ModelKind::LocationScale,
956        FittedFamily::LocationScale {
957            likelihood,
958            base_link,
959        },
960        family_tag,
961    );
962    payload.unified = Some(inputs.fit_result.clone());
963    payload.fit_result = Some(inputs.fit_result);
964    payload.data_schema = Some(inputs.data_schema);
965    payload.link = link;
966    payload.formula_noise = Some(inputs.noise_formula);
967    payload.beta_noise = inputs.beta_noise;
968    payload.gaussian_response_scale = response_scale;
969    if let Some(transform) = noise_transform {
970        payload.noise_projection = Some(
971            transform
972                .projection_coef
973                .rows()
974                .into_iter()
975                .map(|row| row.to_vec())
976                .collect(),
977        );
978        payload.noise_center = Some(transform.weighted_column_mean.to_vec());
979        payload.noise_scale = Some(transform.rescale.to_vec());
980        payload.noise_non_intercept_start = Some(transform.non_intercept_start);
981        payload.noise_projection_ridge_alpha = Some(transform.projection_ridge_alpha);
982    }
983    payload.resolved_termspec = Some(inputs.resolved_termspec);
984    payload.resolved_termspec_noise = Some(inputs.resolved_termspec_noise);
985    if let Some(wiggle) = inputs.wiggle {
986        payload.linkwiggle_knots = Some(wiggle.knots);
987        payload.linkwiggle_degree = Some(wiggle.degree);
988        payload.beta_link_wiggle = Some(wiggle.beta_link_wiggle);
989    }
990    source.apply_to(&mut payload);
991    Ok(payload)
992}
993
994/// Source-agnostic semantic content of a survival marginal-slope
995/// (Royston-Parmar net) saved model. Centralizing assembly also fixes the
996/// FFI's prior omission of the `*_logslopes`/`*_columns`/`formula_logslopes`
997/// vector mirrors the CLI wrote.
998pub struct SurvivalMarginalSlopeInputs<'a> {
999    pub formula: String,
1000    pub data_schema: DataSchema,
1001    pub fit_result: UnifiedFitResult,
1002    pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
1003    pub survival_entry: Option<String>,
1004    pub survival_exit: String,
1005    pub survival_event: String,
1006    pub survivalspec: String,
1007    pub baseline_cfg: SurvivalBaselineConfig,
1008    pub time_basis: SavedSurvivalTimeBasis,
1009    pub ridge_lambda: f64,
1010    pub survival_likelihood_label: String,
1011    pub resolved_marginalspec: TermCollectionSpec,
1012    pub resolved_logslopespec: TermCollectionSpec,
1013    pub logslope_formula: String,
1014    pub z_column: String,
1015    pub latent_z_normalization: SavedLatentZNormalization,
1016    pub baseline_logslope: f64,
1017    /// Frozen nonlinear time-wiggle authority, including the raw fitted tail.
1018    pub timewiggle: Option<SurvivalTimewiggle>,
1019    pub score_warp_runtime: Option<&'a DeviationRuntime>,
1020    pub link_dev_runtime: Option<&'a DeviationRuntime>,
1021    /// Width `p₁` of the absorbed Stage-1 influence block (#461) when the fit
1022    /// hosted a dedicated additive absorber. Predict drops the absorber's `γ`;
1023    /// this is persisted only so the predictor accounts for the extra trailing
1024    /// block in the saved block count.
1025    pub influence_absorber_width: Option<usize>,
1026    pub influence_absorber_design: Option<&'a Array2<f64>>,
1027    pub score_covariance: &'a Array2<f64>,
1028}
1029
1030/// Construct a Royston-Parmar survival [`FittedModelPayload`] through the
1031/// canonical `Survival` family scaffold shared by every RP on-disk contract
1032/// (marginal-slope, transformation, location-scale): the identity-link
1033/// `RoystonParmar` likelihood, the persisted likelihood label, and the
1034/// `fit_result` / `data_schema` install. Callers supply the two variants that
1035/// differ — `survival_distribution` and `frailty` — and then set their own
1036/// family-specific fields on the returned payload.
1037fn new_royston_parmar_survival_payload(
1038    formula: String,
1039    fit_result: UnifiedFitResult,
1040    data_schema: DataSchema,
1041    survival_likelihood_label: &str,
1042    survival_distribution: Option<ResidualDistribution>,
1043    frailty: crate::survival::lognormal_kernel::FrailtySpec,
1044) -> FittedModelPayload {
1045    let mut payload = FittedModelPayload::new(
1046        MODEL_PAYLOAD_VERSION,
1047        formula,
1048        ModelKind::Survival,
1049        FittedFamily::Survival {
1050            likelihood: LikelihoodSpec::new(
1051                ResponseFamily::RoystonParmar,
1052                InverseLink::Standard(StandardLink::Identity),
1053            ),
1054            survival_likelihood: Some(survival_likelihood_label.to_string()),
1055            survival_distribution,
1056            frailty,
1057        },
1058        ResponseFamily::RoystonParmar.name().to_string(),
1059    );
1060    payload.unified = Some(fit_result.clone());
1061    payload.fit_result = Some(fit_result);
1062    payload.data_schema = Some(data_schema);
1063    payload
1064}
1065
1066/// Assemble the canonical survival marginal-slope payload — single source of
1067/// truth for that Royston-Parmar / Gaussian-residual on-disk contract.
1068pub fn assemble_survival_marginal_slope_payload(
1069    inputs: SurvivalMarginalSlopeInputs<'_>,
1070    source: SavedModelSourceMetadata,
1071) -> FittedModelPayload {
1072    let mut payload = new_royston_parmar_survival_payload(
1073        inputs.formula,
1074        inputs.fit_result,
1075        inputs.data_schema,
1076        &inputs.survival_likelihood_label,
1077        Some(ResidualDistribution::Gaussian),
1078        inputs.frailty,
1079    );
1080    payload.survival_entry = inputs.survival_entry;
1081    payload.survival_exit = Some(inputs.survival_exit);
1082    payload.survival_event = Some(inputs.survival_event);
1083    payload.survivalspec = Some(inputs.survivalspec);
1084    payload.survival_baseline_target =
1085        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1086    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1087    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1088    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1089    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1090    payload.apply_survival_time_basis(&inputs.time_basis);
1091    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1092    payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1093    payload.survival_distribution = Some(ResidualDistribution::Gaussian);
1094    payload.link = Some(InverseLink::Standard(StandardLink::Probit));
1095    payload.resolved_termspec = Some(inputs.resolved_marginalspec);
1096    payload.resolved_termspec_logslopes = Some(vec![inputs.resolved_logslopespec.clone()]);
1097    payload.resolved_termspec_logslope = Some(inputs.resolved_logslopespec);
1098    payload.formula_logslope = Some(inputs.logslope_formula.clone());
1099    payload.formula_logslopes = Some(vec![inputs.logslope_formula]);
1100    payload.z_column = Some(inputs.z_column.clone());
1101    payload.z_columns = Some(vec![inputs.z_column]);
1102    payload.latent_z_normalization = Some(inputs.latent_z_normalization);
1103    payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
1104    payload.logslope_baseline = Some(inputs.baseline_logslope);
1105    payload.logslope_baselines = Some(vec![inputs.baseline_logslope]);
1106    if let Some(timewiggle) = inputs.timewiggle {
1107        payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1108        payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1109        payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1110        payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1111        apply_timewiggle_beta(&mut payload, timewiggle.beta);
1112    }
1113    payload.score_warp_runtime = inputs
1114        .score_warp_runtime
1115        .map(serialize_anchored_deviation_runtime);
1116    payload.link_deviation_runtime = inputs
1117        .link_dev_runtime
1118        .map(serialize_anchored_deviation_runtime);
1119    payload.influence_absorber_width = inputs.influence_absorber_width;
1120    payload.influence_absorber_design = inputs
1121        .influence_absorber_design
1122        .map(|design| design.rows().into_iter().map(|row| row.to_vec()).collect());
1123    payload.survival_marginal_slope_score_covariance = Some(
1124        inputs
1125            .score_covariance
1126            .rows()
1127            .into_iter()
1128            .map(|row| row.to_vec())
1129            .collect(),
1130    );
1131    source.apply_to(&mut payload);
1132    payload
1133}
1134
1135/// Fitted baseline-timewiggle coefficients: a single block (net) or one per
1136/// cause (joint cause-specific). Callers pass already-sliced coefficients.
1137pub enum SurvivalTimewiggleBeta {
1138    Single(Vec<f64>),
1139    ByCause(Vec<Vec<f64>>),
1140}
1141
1142/// Route the fitted baseline-timewiggle coefficients into the matching payload
1143/// slot. Both survival payload assemblers funnel through this ONE exhaustive
1144/// `match` so a new [`SurvivalTimewiggleBeta`] variant is a compile error rather
1145/// than a silent drop (the location-scale assembler previously `if let`-matched
1146/// only `Single` and silently discarded `ByCause`).
1147fn apply_timewiggle_beta(payload: &mut FittedModelPayload, beta: SurvivalTimewiggleBeta) {
1148    match beta {
1149        SurvivalTimewiggleBeta::Single(beta) => {
1150            payload.beta_baseline_timewiggle = Some(beta);
1151        }
1152        SurvivalTimewiggleBeta::ByCause(by_cause) => {
1153            payload.beta_baseline_timewiggle_by_cause = Some(by_cause);
1154        }
1155    }
1156}
1157
1158/// Snapshot of the baseline-timewiggle block persisted with a survival model.
1159pub struct SurvivalTimewiggle {
1160    pub degree: usize,
1161    pub knots: Vec<f64>,
1162    pub penalty_orders: Option<Vec<usize>>,
1163    pub double_penalty: Option<bool>,
1164    pub beta: SurvivalTimewiggleBeta,
1165}
1166
1167/// Source-agnostic semantic content of a survival transformation
1168/// (Royston-Parmar) saved model — net single-cause or joint cause-specific.
1169pub struct SurvivalTransformationInputs {
1170    pub formula: String,
1171    pub data_schema: DataSchema,
1172    pub fit_result: UnifiedFitResult,
1173    pub survival_entry: Option<String>,
1174    pub survival_exit: String,
1175    pub survival_event: String,
1176    pub survivalspec: String,
1177    /// `None` = net single-cause; `Some(n)` persists `survival_cause_count` and
1178    /// `cause_1..cause_n` endpoint names.
1179    pub cause_count: Option<usize>,
1180    pub baseline_cfg: SurvivalBaselineConfig,
1181    pub time_basis: SavedSurvivalTimeBasis,
1182    pub ridge_lambda: f64,
1183    pub survival_likelihood_label: String,
1184    pub resolved_termspec: TermCollectionSpec,
1185    /// Rigid time-block beta, persisted only by the cause-specific CLI path.
1186    pub survival_beta_time: Option<Vec<f64>>,
1187    pub timewiggle: Option<SurvivalTimewiggle>,
1188}
1189
1190/// Assemble the canonical survival transformation payload — single source of
1191/// truth for the Royston-Parmar transformation on-disk contract.
1192pub fn assemble_survival_transformation_payload(
1193    inputs: SurvivalTransformationInputs,
1194    source: SavedModelSourceMetadata,
1195) -> FittedModelPayload {
1196    let mut payload = new_royston_parmar_survival_payload(
1197        inputs.formula,
1198        inputs.fit_result,
1199        inputs.data_schema,
1200        &inputs.survival_likelihood_label,
1201        None,
1202        crate::survival::lognormal_kernel::FrailtySpec::None,
1203    );
1204    payload.survival_entry = inputs.survival_entry;
1205    payload.survival_exit = Some(inputs.survival_exit);
1206    payload.survival_event = Some(inputs.survival_event);
1207    payload.survivalspec = Some(inputs.survivalspec);
1208    if let Some(cause_count) = inputs.cause_count {
1209        payload.survival_cause_count = Some(cause_count);
1210        payload.survival_endpoint_names = Some(
1211            (1..=cause_count)
1212                .map(|idx| format!("cause_{idx}"))
1213                .collect(),
1214        );
1215    }
1216    payload.survival_baseline_target =
1217        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1218    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1219    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1220    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1221    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1222    payload.apply_survival_time_basis(&inputs.time_basis);
1223    if let Some(timewiggle) = inputs.timewiggle {
1224        payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1225        payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1226        payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1227        payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1228        apply_timewiggle_beta(&mut payload, timewiggle.beta);
1229    }
1230    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1231    payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1232    payload.survival_beta_time = inputs.survival_beta_time;
1233    payload.resolved_termspec = Some(inputs.resolved_termspec);
1234    source.apply_to(&mut payload);
1235    payload
1236}
1237
1238/// Source-agnostic semantic content of a survival location-scale
1239/// (Royston-Parmar with a learned residual link) saved model. Centralizing
1240/// fixes the drift where CLI and FFI disagreed on `formula_noise`,
1241/// `baseline_timewiggle_*`, and exact location-scale replay topology.
1242pub struct SurvivalLocationScaleInputs {
1243    pub formula: String,
1244    pub data_schema: DataSchema,
1245    /// Fit result with the fitted inverse-link state and link-wiggle artifacts
1246    /// already applied by the caller.
1247    pub fit_result: UnifiedFitResult,
1248    pub fitted_inverse_link: InverseLink,
1249    // Independent `Option`s (not an all-or-nothing group) so the assembler
1250    // reproduces exactly what the CLI and FFI each persist independently.
1251    pub linkwiggle_degree: Option<usize>,
1252    pub linkwiggle_knots: Option<Vec<f64>>,
1253    pub beta_link_wiggle: Option<Vec<f64>>,
1254    pub baseline_timewiggle: Option<SurvivalTimewiggle>,
1255    pub survival_entry: Option<String>,
1256    pub survival_exit: String,
1257    pub survival_event: String,
1258    pub survivalspec: String,
1259    pub baseline_cfg: SurvivalBaselineConfig,
1260    pub time_basis: SavedSurvivalTimeBasis,
1261    pub ridge_lambda: f64,
1262    pub survival_likelihood_label: String,
1263    pub time_parameterization: SurvivalLocationScaleTimeParameterization,
1264    pub threshold_time_basis: Option<SurvivalCovariateTimeBasis>,
1265    pub log_sigma_time_basis: Option<SurvivalCovariateTimeBasis>,
1266    pub formula_noise: Option<String>,
1267    pub survival_beta_time: Vec<f64>,
1268    pub survival_beta_threshold: Vec<f64>,
1269    pub survival_beta_log_sigma: Vec<f64>,
1270    pub resolved_thresholdspec: TermCollectionSpec,
1271    pub resolved_log_sigmaspec: TermCollectionSpec,
1272}
1273
1274/// Assemble the canonical survival location-scale payload (the single source of
1275/// truth for that on-disk contract).
1276pub fn assemble_survival_location_scale_payload(
1277    inputs: SurvivalLocationScaleInputs,
1278    source: SavedModelSourceMetadata,
1279) -> FittedModelPayload {
1280    let survival_distribution =
1281        residual_distribution_from_inverse_link(&inputs.fitted_inverse_link);
1282    let mut payload = new_royston_parmar_survival_payload(
1283        inputs.formula,
1284        inputs.fit_result,
1285        inputs.data_schema,
1286        &inputs.survival_likelihood_label,
1287        survival_distribution,
1288        crate::survival::lognormal_kernel::FrailtySpec::None,
1289    );
1290    payload.link = Some(inputs.fitted_inverse_link);
1291    payload.linkwiggle_degree = inputs.linkwiggle_degree;
1292    payload.linkwiggle_knots = inputs.linkwiggle_knots;
1293    payload.beta_link_wiggle = inputs.beta_link_wiggle;
1294    if let Some(timewiggle) = inputs.baseline_timewiggle {
1295        payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1296        payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1297        payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1298        payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1299        apply_timewiggle_beta(&mut payload, timewiggle.beta);
1300    }
1301    payload.survival_entry = inputs.survival_entry;
1302    payload.survival_exit = Some(inputs.survival_exit);
1303    payload.survival_event = Some(inputs.survival_event);
1304    payload.survivalspec = Some(inputs.survivalspec);
1305    payload.survival_baseline_target =
1306        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1307    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1308    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1309    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1310    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1311    payload.apply_survival_time_basis(&inputs.time_basis);
1312    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1313    payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1314    payload.survival_location_scale_structure = Some(SavedSurvivalLocationScaleStructure {
1315        time_parameterization: inputs.time_parameterization,
1316        threshold_time_basis: inputs.threshold_time_basis,
1317        log_sigma_time_basis: inputs.log_sigma_time_basis,
1318    });
1319    payload.formula_noise = inputs.formula_noise;
1320    payload.survival_beta_time = Some(inputs.survival_beta_time);
1321    payload.survival_beta_threshold = Some(inputs.survival_beta_threshold);
1322    payload.survival_beta_log_sigma = Some(inputs.survival_beta_log_sigma);
1323    payload.survival_distribution = survival_distribution;
1324    payload.resolved_termspec = Some(inputs.resolved_thresholdspec);
1325    payload.resolved_termspec_noise = Some(inputs.resolved_log_sigmaspec);
1326    source.apply_to(&mut payload);
1327    payload
1328}
1329
1330/// Source-agnostic semantic content of a latent survival / latent binary saved
1331/// model. The caller resolves the family (splicing the learned latent SD into
1332/// the persisted frailty for survival) and the model-class / likelihood labels.
1333pub struct LatentWindowInputs {
1334    pub formula: String,
1335    pub data_schema: DataSchema,
1336    pub fit_result: UnifiedFitResult,
1337    pub family: FittedFamily,
1338    pub model_class_label: String,
1339    pub likelihood_label: String,
1340    pub survival_entry: Option<String>,
1341    pub survival_exit: String,
1342    pub survival_event: String,
1343    pub baseline_cfg: SurvivalBaselineConfig,
1344    pub time_basis: SavedSurvivalTimeBasis,
1345    pub ridge_lambda: f64,
1346    pub beta_time: Vec<f64>,
1347    pub resolved_termspec: TermCollectionSpec,
1348}
1349
1350/// Assemble the canonical latent survival / latent binary payload.
1351pub fn assemble_latent_window_payload(
1352    inputs: LatentWindowInputs,
1353    source: SavedModelSourceMetadata,
1354) -> FittedModelPayload {
1355    let mut payload = FittedModelPayload::new(
1356        MODEL_PAYLOAD_VERSION,
1357        inputs.formula,
1358        ModelKind::Survival,
1359        inputs.family,
1360        inputs.model_class_label,
1361    );
1362    payload.unified = Some(inputs.fit_result.clone());
1363    payload.fit_result = Some(inputs.fit_result);
1364    payload.data_schema = Some(inputs.data_schema);
1365    payload.survival_entry = inputs.survival_entry;
1366    payload.survival_exit = Some(inputs.survival_exit);
1367    payload.survival_event = Some(inputs.survival_event);
1368    payload.survivalspec = Some("net".to_string());
1369    payload.survival_baseline_target =
1370        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1371    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1372    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1373    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1374    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1375    payload.apply_survival_time_basis(&inputs.time_basis);
1376    payload.survival_likelihood = Some(inputs.likelihood_label);
1377    payload.survival_beta_time = Some(inputs.beta_time);
1378    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1379    payload.resolved_termspec = Some(inputs.resolved_termspec);
1380    source.apply_to(&mut payload);
1381    payload
1382}
1383
1384#[cfg(test)]
1385mod apply_timewiggle_beta_tests {
1386    use super::*;
1387
1388    /// Minimal payload with both baseline-timewiggle slots unset. Uses the
1389    /// fixture-free `LatentBinary` family so the test needs no `LikelihoodSpec`.
1390    fn empty_payload() -> FittedModelPayload {
1391        FittedModelPayload::new(
1392            MODEL_PAYLOAD_VERSION,
1393            "y ~ 1".to_string(),
1394            ModelKind::Survival,
1395            FittedFamily::LatentBinary {
1396                frailty: crate::survival::lognormal_kernel::FrailtySpec::None,
1397            },
1398            "test".to_string(),
1399        )
1400    }
1401
1402    #[test]
1403    fn by_cause_beta_populates_only_the_by_cause_slot() {
1404        let mut payload = empty_payload();
1405        apply_timewiggle_beta(
1406            &mut payload,
1407            SurvivalTimewiggleBeta::ByCause(vec![vec![1.0, 2.0], vec![3.0]]),
1408        );
1409        assert_eq!(
1410            payload.beta_baseline_timewiggle_by_cause,
1411            Some(vec![vec![1.0, 2.0], vec![3.0]]),
1412            "ByCause coefficients must land in the by-cause slot (regression: the \
1413             location-scale assembler used to silently drop them)"
1414        );
1415        assert!(
1416            payload.beta_baseline_timewiggle.is_none(),
1417            "ByCause must not populate the single-block slot"
1418        );
1419    }
1420
1421    #[test]
1422    fn single_beta_populates_only_the_flat_slot() {
1423        let mut payload = empty_payload();
1424        apply_timewiggle_beta(&mut payload, SurvivalTimewiggleBeta::Single(vec![4.0, 5.0]));
1425        assert_eq!(payload.beta_baseline_timewiggle, Some(vec![4.0, 5.0]));
1426        assert!(payload.beta_baseline_timewiggle_by_cause.is_none());
1427    }
1428}