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