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    BernoulliMarginalSlopeFitResult, 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::{
25    DispersionLocationScaleFitResult, FitConfig, FitRequest, FitResult, StandardFitResult,
26    WorkflowError, expectile_tau_for_config, fit_expectile_if_requested,
27    fit_materialized_standard_with_notes, fit_model, materialize,
28};
29use crate::gamlss::{
30    BinomialLocationScaleFitResult, DispersionFamilyKind, GaussianLocationScaleFitResult,
31};
32use crate::inference::model::{
33    FittedEstimator, FittedFamily, FittedModelPayload, MODEL_PAYLOAD_VERSION, ModelKind,
34    SavedAnchorComponent, SavedAnchorKind, SavedCompiledFlexBlock, SavedLatentZNormalization,
35    SavedResidualCascade, SavedSplineScan, SavedSurvivalLocationScaleStructure,
36    SavedTransformationNormalGeometry, TransformationNormalParameterization,
37    TransformationScoreCalibration,
38};
39use crate::scale_design::{ScaleDeviationTransform, build_scale_deviation_transform};
40use crate::survival::construction::{
41    SavedSurvivalTimeBasis, SurvivalBaselineConfig, survival_baseline_targetname,
42};
43use crate::survival::marginal_slope::SurvivalMarginalSlopeFitResult;
44use crate::survival::predict::apply_inverse_link_state_to_fit_result;
45use crate::survival::location_scale::{
46    ResidualDistribution, SurvivalCovariateTimeBasis, SurvivalLocationScaleTimeParameterization,
47    residual_distribution_from_inverse_link,
48};
49use crate::transformation_normal::{TransformationNormalFamily, TransformationNormalFitResult};
50use faer::Side;
51use gam_data::{DataSchema, EncodedDataset};
52use gam_linalg::faer_ndarray::{FaerCholesky, array2_to_nested_vec};
53use gam_problem::BlockRole;
54use gam_problem::types::{
55    InverseLink, LikelihoodSpec, ResponseFamily, StandardLink, inverse_link_to_binomial_spec,
56};
57use gam_solve::estimate::{
58    FittedLinkState, UnifiedFitResult, saved_latent_cloglog_state_from_fit,
59    saved_mixture_state_from_fit, saved_sas_state_from_fit,
60};
61use gam_terms::inference::formula_dsl::{parse_formula, parse_surv_response};
62use gam_terms::smooth::{TermCollectionDesign, TermCollectionSpec};
63use ndarray::{Array1, Array2, s};
64use std::collections::HashMap;
65
66/// Family tag persisted for Bernoulli marginal-slope saved models.
67const FAMILY_BERNOULLI_MARGINAL_SLOPE: &str = "bernoulli-marginal-slope";
68
69/// Family tag persisted for transformation-normal saved models.
70const FAMILY_TRANSFORMATION_NORMAL: &str = "transformation-normal";
71
72/// Serialize an anchored-deviation [`DeviationRuntime`] (score-warp or
73/// link-deviation block) into its persistable [`SavedCompiledFlexBlock`] form.
74///
75/// This is the single source of truth for that conversion; the CLI and FFI
76/// payload builders both route through it so the serialized flex contract
77/// cannot diverge between the two save paths.
78pub fn serialize_anchored_deviation_runtime(runtime: &DeviationRuntime) -> SavedCompiledFlexBlock {
79    let mut anchor_correction: Option<Vec<Vec<f64>>> = None;
80    let mut anchor_components: Vec<SavedAnchorComponent> = Vec::new();
81    if let Some(installed) = runtime.installed_flex_block() {
82        anchor_correction = Some(
83            installed
84                .anchor_correction
85                .rows()
86                .into_iter()
87                .map(|row| row.to_vec())
88                .collect::<Vec<Vec<f64>>>(),
89        );
90        for component in &installed.anchor_components {
91            anchor_components.push(SavedAnchorComponent {
92                kind: match component {
93                    AnchorComponentTag::Parametric { block, ncols } => {
94                        SavedAnchorKind::Parametric {
95                            block: *block,
96                            ncols: *ncols,
97                        }
98                    }
99                    AnchorComponentTag::FlexEvaluation { ncols } => {
100                        SavedAnchorKind::FlexEvaluation { ncols: *ncols }
101                    }
102                },
103            });
104        }
105    }
106    SavedCompiledFlexBlock {
107        kernel: ANCHORED_DEVIATION_KERNEL.to_string(),
108        breakpoints: runtime.breakpoints().to_vec(),
109        basis_dim: runtime.basis_dim(),
110        span_c0: runtime
111            .span_c0()
112            .rows()
113            .into_iter()
114            .map(|row| row.to_vec())
115            .collect(),
116        span_c1: runtime
117            .span_c1()
118            .rows()
119            .into_iter()
120            .map(|row| row.to_vec())
121            .collect(),
122        span_c2: runtime
123            .span_c2()
124            .rows()
125            .into_iter()
126            .map(|row| row.to_vec())
127            .collect(),
128        span_c3: runtime
129            .span_c3()
130            .rows()
131            .into_iter()
132            .map(|row| row.to_vec())
133            .collect(),
134        anchor_correction,
135        anchor_components,
136    }
137}
138
139/// Source-specific metadata that the CLI and FFI populate differently but that
140/// every saved payload carries.
141///
142/// `training_feature_ranges` is the only field the FFI path cannot currently
143/// supply (it persists headers without per-feature ranges); modeling it as
144/// `Option` keeps that distinction explicit instead of silently encoding an
145/// empty vector as if ranges were known.
146pub struct SavedModelSourceMetadata {
147    pub training_headers: Vec<String>,
148    pub training_feature_ranges: Option<Vec<(f64, f64)>>,
149    pub offset_column: Option<String>,
150    pub noise_offset_column: Option<String>,
151}
152
153impl SavedModelSourceMetadata {
154    fn apply_to(self, payload: &mut FittedModelPayload) {
155        match self.training_feature_ranges {
156            Some(ranges) => payload.set_training_feature_metadata(self.training_headers, ranges),
157            None => payload.training_headers = Some(self.training_headers),
158        }
159        payload.offset_column = self.offset_column;
160        payload.noise_offset_column = self.noise_offset_column;
161    }
162}
163
164/// Complete semantic input for persisting a standard formula fit.
165///
166/// The workflow result is consumed as one value so callers cannot accidentally
167/// mix a design, resolved term specification, fitted link, or wiggle state from
168/// different fits.  Formula front ends should fit through `fit_from_formula`
169/// and hand its `Standard` result directly to this assembler.
170pub struct StandardPayloadInputs<'a> {
171    pub formula: String,
172    pub dataset: &'a EncodedDataset,
173    pub fit_config: &'a FitConfig,
174    pub result: StandardFitResult,
175}
176
177fn fitted_inverse_link(state: &FittedLinkState) -> Option<InverseLink> {
178    match state {
179        FittedLinkState::Standard(Some(link)) => Some(InverseLink::Standard(*link)),
180        FittedLinkState::Standard(None) => None,
181        FittedLinkState::LatentCLogLog { state } => Some(InverseLink::LatentCLogLog(*state)),
182        FittedLinkState::Sas { state, .. } => Some(InverseLink::Sas(*state)),
183        FittedLinkState::BetaLogistic { state, .. } => Some(InverseLink::BetaLogistic(*state)),
184        FittedLinkState::Mixture { state, .. } => Some(InverseLink::Mixture(state.clone())),
185    }
186}
187
188fn standard_null_space_metadata(
189    design: &TermCollectionDesign,
190    fit: &UnifiedFitResult,
191) -> Result<(usize, f64), String> {
192    let hessian = fit
193        .penalized_hessian()
194        .ok_or_else(|| "null-space Hessian logdet requires fitted penalized Hessian".to_string())?;
195    let hessian_dim = hessian.nrows();
196    if hessian.ncols() != hessian_dim {
197        return Err(format!(
198            "null-space Hessian logdet requires a square Hessian, got {}x{}",
199            hessian.nrows(),
200            hessian.ncols()
201        ));
202    }
203    let p = design.design.ncols();
204    if design.penalties.is_empty() {
205        return Ok((0, 0.0));
206    }
207    let mut penalty = Array2::<f64>::zeros((p, p));
208    for (idx, block) in design.penalties.iter().enumerate() {
209        let range = block.col_range.clone();
210        if range.start > range.end
211            || range.end > p
212            || block.local.nrows() != range.len()
213            || block.local.ncols() != range.len()
214        {
215            return Err(format!(
216                "null-space Hessian logdet penalty {idx} shape mismatch: range {}..{}, local {}x{}, p={p}",
217                range.start,
218                range.end,
219                block.local.nrows(),
220                block.local.ncols()
221            ));
222        }
223        penalty
224            .slice_mut(s![range.clone(), range])
225            .scaled_add(1.0, &block.local);
226    }
227    let (null_basis, _) = gam_linalg::faer_ndarray::rrqr_nullspace_basis(
228        &penalty,
229        gam_linalg::faer_ndarray::default_rrqr_rank_alpha(),
230    )
231    .map_err(|err| format!("failed to compute penalty null-space basis: {err}"))?;
232    let q = null_basis.ncols();
233    if q == 0 {
234        return Ok((0, 0.0));
235    }
236
237    // The saved Hessian lives in the active coordinates declared by the fit's
238    // gauge, while `null_basis` is expressed in the design's raw coordinates.
239    // Pull every raw null-space direction N back through the injective lift
240    // `T`: solve `T C = N`, then restrict as `C' H_active C`. Treating a
241    // rectangular active Hessian as if it were raw curvature was the hidden
242    // identity-gauge assumption exposed by exact smoothing boundaries (#2623).
243    let active_null_basis = if let Some(geometry) = fit.geometry.as_ref() {
244        let gauge = &geometry.coefficient_gauge;
245        if gauge.raw_total() != p || gauge.reduced_total() != hessian_dim {
246            return Err(format!(
247                "null-space Hessian logdet gauge mismatch: design has {p} raw columns, gauge \
248                 maps {} raw from {} active coordinates, Hessian is {hessian_dim}x{hessian_dim}",
249                gauge.raw_total(),
250                gauge.reduced_total(),
251            ));
252        }
253        let t = &gauge.t_full;
254        let raw_gram = t.t().dot(t);
255        let gram = (&raw_gram + &raw_gram.t().to_owned()) * 0.5;
256        let chol = gram.cholesky(Side::Lower).map_err(|error| {
257            format!(
258                "null-space Hessian logdet coefficient gauge is not injective: {error}"
259            )
260        })?;
261        let coordinates = chol.solve_mat(&t.t().dot(&null_basis));
262        let residual = t.dot(&coordinates) - &null_basis;
263        let residual_max = residual
264            .iter()
265            .copied()
266            .map(f64::abs)
267            .fold(0.0_f64, f64::max);
268        let basis_max = null_basis
269            .iter()
270            .copied()
271            .map(f64::abs)
272            .fold(0.0_f64, f64::max)
273            .max(1.0);
274        let backward_error = residual_max / basis_max;
275        let roundoff_limit = f64::EPSILON.sqrt() * p.max(hessian_dim).max(1) as f64;
276        if !backward_error.is_finite() || backward_error > roundoff_limit {
277            return Err(format!(
278                "null-space Hessian logdet raw penalty null space is not contained in the \
279                 fitted active gauge: relative residual {backward_error:.6e}, numerical limit \
280                 {roundoff_limit:.6e}"
281            ));
282        }
283        coordinates
284    } else {
285        if hessian_dim != p {
286            return Err(format!(
287                "null-space Hessian logdet design/Hessian mismatch without a coefficient \
288                 gauge: design has {p} columns but Hessian is {hessian_dim}x{hessian_dim}"
289            ));
290        }
291        null_basis
292    };
293    let projected = hessian.dot(&active_null_basis);
294    let mut restricted = active_null_basis.t().dot(&projected);
295    restricted = (&restricted + &restricted.t()) * 0.5;
296    let chol = restricted
297        .cholesky(Side::Lower)
298        .map_err(|err| format!("null-space Hessian is not positive definite: {err}"))?;
299    let logdet = 2.0 * chol.diag().iter().map(|value| value.ln()).sum::<f64>();
300    if logdet.is_finite() {
301        Ok((q, logdet))
302    } else {
303        Err(format!("null-space Hessian logdet is not finite: {logdet}"))
304    }
305}
306
307fn response_for_standard_payload(formula: &str, dataset: &EncodedDataset) -> Option<Array1<f64>> {
308    let response = gam_terms::inference::formula_dsl::parse_formula(formula)
309        .ok()?
310        .response;
311    let column = *dataset.column_map().get(&response)?;
312    Some(dataset.values.column(column).to_owned())
313}
314
315fn standard_conformal_substrates(
316    formula: &str,
317    dataset: &EncodedDataset,
318    fit_config: &FitConfig,
319    family: &LikelihoodSpec,
320    fit: &UnifiedFitResult,
321    design: &TermCollectionDesign,
322) -> (
323    Option<crate::inference::full_conformal::GaussianJackknifePlusStats>,
324    Option<crate::inference::full_conformal::ExactFullConformalSubstrate>,
325) {
326    // #2633: these two substrates are ~94% of a saved Gaussian model at
327    // n=20,000 and grow with the training rows, to save ~5.6 ms of rebuild. A
328    // caller that keeps its training data, or never asks for a conformal
329    // interval, can decline them; see `FitConfig::precompute_conformal` for the
330    // measured trade-off and why the default is to keep them.
331    if fit_config.precompute_conformal == Some(false) {
332        return (None, None);
333    }
334    let expectile = fit_config.family.as_deref().is_some_and(|family| {
335        let family = family.trim().to_ascii_lowercase();
336        family == "expectile" || family.starts_with("expectile(")
337    });
338    if expectile
339        || !family.is_gaussian_identity()
340        || fit_config.weight_column.is_some()
341        || fit_config.offset_column.is_some()
342        || fit_config.flexible_link
343        || design.affine_offset.iter().any(|value| *value != 0.0)
344    {
345        return (None, None);
346    }
347    let Some(y) = response_for_standard_payload(formula, dataset) else {
348        return (None, None);
349    };
350    let Ok(x) = design.design.try_to_dense_arc("standard conformal design") else {
351        return (None, None);
352    };
353    let Some(normal_matrix) = fit.penalized_hessian() else {
354        return (None, None);
355    };
356    if x.nrows() != y.len()
357        || normal_matrix.nrows() != x.ncols()
358        || normal_matrix.ncols() != x.ncols()
359    {
360        return (None, None);
361    }
362    let weights = Array1::<f64>::ones(y.len());
363    // Either substrate may legitimately decline this design (rank, shape, or a
364    // non-invertible normal matrix). `None` is the contract, but the reason is
365    // what explains a fit that silently ships without conformal intervals.
366    let jackknife = match crate::inference::full_conformal::GaussianJackknifePlusStats::from_design_unit_weight_normal_matrix(
367        x.as_ref(),
368        &y,
369        &weights,
370        normal_matrix,
371    ) {
372        Ok(stats) => Some(stats),
373        Err(reason) => {
374            log::debug!("jackknife+ conformal substrate unavailable: {reason}");
375            None
376        }
377    };
378    let full = match crate::inference::full_conformal::ExactFullConformalSubstrate::from_design_unit_weight_normal_matrix(
379        x.as_ref(),
380        &y,
381        &weights,
382        normal_matrix,
383    ) {
384        Ok(substrate) => Some(substrate),
385        Err(reason) => {
386            log::debug!("exact full-conformal substrate unavailable: {reason}");
387            None
388        }
389    };
390    (jackknife, full)
391}
392
393/// Assemble the one canonical saved payload for a standard formula fit.
394pub fn assemble_standard_payload(
395    inputs: StandardPayloadInputs<'_>,
396) -> Result<FittedModelPayload, String> {
397    let StandardPayloadInputs {
398        formula,
399        dataset,
400        fit_config,
401        result,
402    } = inputs;
403    let StandardFitResult {
404        mut fit,
405        design,
406        resolvedspec,
407        basis_adequacy,
408        adaptive_diagnostics,
409        saved_link_state,
410        wiggle_knots,
411        wiggle_degree,
412        wiggle_penalty_metadata,
413        wiggle_saved_warp_beta,
414        wiggle_saved_index_shift,
415        ..
416    } = result;
417    fit.fitted_link = saved_link_state;
418    let resolved_termspec = freeze_term_collection_from_design(&resolvedspec, &design)
419        .map_err(|err| format!("failed to freeze standard term specification: {err}"))?;
420    let (null_space_dim, null_space_logdet) = standard_null_space_metadata(&design, &fit)?;
421    fit.artifacts.null_space_dim = Some(null_space_dim);
422    fit.artifacts.null_space_logdet = Some(null_space_logdet);
423    let family = fit
424        .likelihood_family
425        .clone()
426        .unwrap_or_else(LikelihoodSpec::gaussian_identity);
427    let estimator = expectile_tau_for_config(fit_config)
428        .map_err(|error| format!("failed to persist estimator metadata: {error}"))?
429        .map_or(FittedEstimator::Likelihood, |tau| {
430            FittedEstimator::Expectile { tau }
431        });
432    let family_label = match estimator {
433        FittedEstimator::Likelihood => family.name().to_string(),
434        FittedEstimator::Expectile { tau } => format!("expectile({tau})"),
435    };
436    let (gaussian_jackknife_plus, full_conformal) =
437        standard_conformal_substrates(&formula, dataset, fit_config, &family, &fit, &design);
438    let latent_cloglog_state = if family.is_latent_cloglog() {
439        Some(saved_latent_cloglog_state_from_fit(&fit).ok_or_else(|| {
440            "latent-cloglog-binomial fit did not produce a fitted latent-cloglog state".to_string()
441        })?)
442    } else {
443        saved_latent_cloglog_state_from_fit(&fit)
444    };
445    let mut payload = FittedModelPayload::new(
446        MODEL_PAYLOAD_VERSION,
447        formula,
448        ModelKind::Standard,
449        FittedFamily::Standard {
450            likelihood: family.clone(),
451            link: StandardLink::try_from(family.link_function()).ok(),
452            latent_cloglog_state,
453            mixture_state: saved_mixture_state_from_fit(&fit),
454            sas_state: saved_sas_state_from_fit(&fit),
455        },
456        family_label,
457    );
458    payload.estimator = estimator;
459    payload.unified = Some(fit.clone());
460    payload.fit_result = Some(fit.clone());
461    payload.data_schema = Some(dataset.schema.clone());
462    payload.link = fitted_inverse_link(&fit.fitted_link).or_else(|| Some(family.link.clone()));
463    payload.linkwiggle_knots = wiggle_knots.map(|knots| knots.to_vec());
464    payload.linkwiggle_degree = wiggle_degree;
465    payload.linkwiggle_penalty_metadata = wiggle_penalty_metadata;
466    payload.beta_link_wiggle = wiggle_saved_warp_beta;
467    payload.link_wiggle_index_shift = wiggle_saved_index_shift;
468    match &fit.fitted_link {
469        FittedLinkState::Mixture { covariance, .. } => {
470            payload.mixture_link_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
471        }
472        FittedLinkState::Sas { covariance, .. }
473        | FittedLinkState::BetaLogistic { covariance, .. } => {
474            payload.sas_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
475        }
476        FittedLinkState::Standard(_) | FittedLinkState::LatentCLogLog { .. } => {}
477    }
478    payload.set_training_feature_metadata(dataset.headers.clone(), dataset.feature_ranges());
479    payload.resolved_termspec = Some(resolved_termspec);
480    payload.adaptive_regularization_diagnostics = adaptive_diagnostics;
481    payload.basis_adequacy = basis_adequacy;
482    payload.offset_column = fit_config.offset_column.clone();
483    payload.noise_offset_column = fit_config.noise_offset_column.clone();
484    payload.weight_column = fit_config.weight_column.clone();
485    payload.gaussian_jackknife_plus = gaussian_jackknife_plus;
486    payload.full_conformal = full_conformal;
487    Ok(payload)
488}
489
490/// The resolved, source-agnostic semantic content of a Bernoulli
491/// marginal-slope saved model.
492///
493/// The CLI threads these in directly from its fit pipeline; the FFI produces
494/// them by freezing its term collections and reading the [`FitConfig`]. Either
495/// way, the assembler below turns them into the canonical payload.
496pub struct BernoulliMarginalSlopeInputs<'a> {
497    pub formula: String,
498    pub data_schema: DataSchema,
499    pub logslope_formula: String,
500    pub z_column: String,
501    pub resolved_marginalspec: TermCollectionSpec,
502    pub resolved_logslopespec: TermCollectionSpec,
503    pub fit_result: UnifiedFitResult,
504    /// Number of *raw* marginal design columns `p_m` (= the term-collection
505    /// marginal design's `ncols()` BEFORE any #461 influence-absorber widening).
506    ///
507    /// When the Stage-1 influence absorber is active (A2), the fitted marginal
508    /// block carries the widened coefficient `[β_m; γ]` (length `p_m + p₁`) and
509    /// the joint covariance is dimensioned over the widened block. The absorbed
510    /// influence columns `Z̃_infl` are a TRAINING-only leakage absorber that does
511    /// not exist at predict rows, so the persisted model must drop `γ` and the
512    /// marginalized-out covariance sub-block to stay self-consistent against the
513    /// raw `p_m` marginal design at predict. The assembler uses this to truncate
514    /// the fit result once (shared CLI + FFI). With no absorber it equals the
515    /// fitted block width and the truncation is a no-op.
516    pub p_marginal: usize,
517    pub baseline_marginal: f64,
518    pub baseline_logslope: f64,
519    pub latent_z_normalization: SavedLatentZNormalization,
520    pub latent_measure: LatentMeasureKind,
521    pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
522    pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
523    pub score_warp_runtime: Option<&'a DeviationRuntime>,
524    pub link_dev_runtime: Option<&'a DeviationRuntime>,
525    pub base_link: InverseLink,
526    pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
527}
528
529/// Drop the #461 training-only influence-absorber coefficients `γ` from a fitted
530/// Bernoulli marginal-slope result so the persisted model is self-consistent
531/// against the raw `p_m`-column marginal design at predict.
532///
533/// When the A2 influence absorber is active the marginal block (block 0) is the
534/// widened `[β_m; γ]` (length `p_m + p₁`, with `γ` the contiguous trailing `p₁`
535/// columns — see bms `widen_marginal_dense_with_influence`) and the joint
536/// conditional covariance is dimensioned over the widened joint coefficient
537/// vector. The absorbed columns `Z̃_infl` exist only at training rows; predict
538/// reconstructs the marginal index from the raw `p_m` design and the
539/// orthogonalized `β̂_m` is a property of the training fit. So this:
540///
541///  * slices `blocks[0].beta` and `block_states[0].beta` to their first `p_m`
542///    entries (the flat `beta` is recomputed from the blocks by
543///    `try_from_parts`),
544///  * **marginalizes** `γ` out of the joint Gaussian by dropping the `γ`
545///    rows/cols from the conditional covariance — taking the corresponding
546///    SUB-BLOCK of `Σ` is the exact marginal of a joint Gaussian (no
547///    re-inversion), so the kept `[β_m | β_logslope | …]` covariance is the
548///    correct predictive uncertainty accounting for the fitted absorber,
549///  * drops the persisted joint penalized-Hessian geometry: it is a precision
550///    over the *widened* joint coefficient vector, so a sub-block would be the
551///    wrong marginalization, and the only predict path that consumes it is the
552///    covariance-fallback that re-inverts `H` — which post-truncation would have
553///    the wrong dimension anyway. With the dense (already-marginalized) `Σ`
554///    matching the predict dimension, that fallback is never taken, so dropping
555///    the geometry removes a stale, wrong-dimension path rather than a used one.
556///
557/// Block-level `edf` / `lambdas` are left untouched: they are fitted scalars
558/// that legitimately reflect the full model (the absorber consumed real dof at
559/// fit time) and are persisted as-is. With no absorber (`block0.len() == p_m`)
560/// this is a no-op clone.
561fn truncate_marginal_slope_influence_absorber(
562    fit_result: UnifiedFitResult,
563    p_marginal: usize,
564) -> Result<UnifiedFitResult, String> {
565    let Some(block0) = fit_result.blocks.first() else {
566        return Err("marginal-slope fit result has no coefficient blocks".to_string());
567    };
568    let widened_len = block0.beta.len();
569    if widened_len <= p_marginal {
570        // No influence absorber installed (or already raw width): nothing to drop.
571        return Ok(fit_result);
572    }
573    let p_influence = widened_len - p_marginal;
574
575    // The input fit's existence is its convergence proof (sealed
576    // `FitConvergenceEvidence`); carry the certified inner status into the
577    // narrowed reassembly, which revalidates the preserved artifacts.
578    let pirls_status = fit_result.convergence_evidence().inner_status();
579    let training_sample_size = fit_result.training_sample_size();
580    // Read through the accessors before destructuring: the criterion pair is
581    // private so that no consumer can substitute a number for an absent one,
582    // and a narrowing reassembly must carry the absence forward unchanged.
583    let reml_score = fit_result.reml_score();
584    let penalized_objective = fit_result.penalized_objective();
585    let UnifiedFitResult {
586        mut blocks,
587        log_lambdas,
588        lambdas,
589        likelihood_family,
590        likelihood_scale,
591        log_likelihood_normalization,
592        log_likelihood,
593        deviance,
594        stable_penalty_term,
595        used_device,
596        outer_iterations,
597        outer_gradient_norm,
598        standard_deviation,
599        covariance_conditional,
600        covariance_corrected,
601        inference,
602        fitted_link,
603        geometry: _,
604        mut block_states,
605        beta: _,
606        max_abs_eta,
607        constraint_kkt,
608        artifacts,
609        inner_cycles,
610        outer_cost_evals: _,
611        inner_pirls_solves: _,
612        ..
613    } = fit_result;
614
615    // Slice block 0's coefficients (and matching block-state) to the raw p_m,
616    // dropping the trailing γ absorber columns.
617    blocks[0].beta = blocks[0].beta.slice(ndarray::s![..p_marginal]).to_owned();
618    if let Some(state0) = block_states.first_mut() {
619        state0.beta = state0.beta.slice(ndarray::s![..p_marginal]).to_owned();
620    }
621
622    // Marginalize γ out of the joint conditional covariance: keep every index
623    // except the contiguous γ block [p_marginal, p_marginal + p_influence).
624    let drop_gamma_block = |cov: Option<Array2<f64>>| -> Option<Array2<f64>> {
625        cov.map(|cov| {
626            let total = cov.nrows();
627            let kept: Vec<usize> = (0..p_marginal)
628                .chain((p_marginal + p_influence)..total)
629                .collect();
630            let mut out = Array2::<f64>::zeros((kept.len(), kept.len()));
631            for (ri, &r) in kept.iter().enumerate() {
632                for (ci, &c) in kept.iter().enumerate() {
633                    out[[ri, ci]] = cov[[r, c]];
634                }
635            }
636            out
637        })
638    };
639    let covariance_conditional = drop_gamma_block(covariance_conditional);
640    let covariance_corrected = drop_gamma_block(covariance_corrected);
641
642    UnifiedFitResult::try_from_parts(gam_solve::estimate::UnifiedFitResultParts {
643        blocks,
644        training_sample_size,
645        log_lambdas,
646        lambdas,
647        likelihood_family,
648        likelihood_scale,
649        log_likelihood_normalization,
650        log_likelihood,
651        deviance,
652        reml_score,
653        stable_penalty_term,
654        penalized_objective,
655        // Preserve the GPU-execution flag across the absorber-column
656        // truncation: dropping the trailing γ columns does not change which
657        // device ran the solve.
658        used_device,
659        outer_iterations,
660        outer_converged: true,
661        outer_gradient_norm,
662        standard_deviation,
663        covariance_conditional,
664        covariance_corrected,
665        inference,
666        fitted_link,
667        // Drop the widened-joint penalized Hessian: see the doc comment.
668        geometry: None,
669        block_states,
670        pirls_status,
671        max_abs_eta,
672        constraint_kkt,
673        artifacts,
674        inner_cycles,
675    })
676    .map_err(|e| {
677        format!("marginal-slope influence-absorber truncation produced an invalid fit result: {e}")
678    })
679}
680
681/// Assemble the canonical spline-scan payload (#1030/#1034): a standard
682/// Gaussian-identity model whose fit representation is the exact O(n)
683/// smoothing-spline smoother state instead of a dense `fit_result`. The CLI
684/// and FFI save paths both route through here so the scan on-disk contract
685/// cannot diverge between sources.
686pub fn assemble_spline_scan_payload(
687    formula: String,
688    feature_column: String,
689    fit: &gam_solve::spline_scan::SplineScanFit,
690    data_schema: DataSchema,
691    training_headers: Vec<String>,
692    training_feature_ranges: Vec<(f64, f64)>,
693) -> FittedModelPayload {
694    let mut payload = FittedModelPayload::new(
695        MODEL_PAYLOAD_VERSION,
696        formula,
697        ModelKind::Standard,
698        FittedFamily::Standard {
699            likelihood: LikelihoodSpec::gaussian_identity(),
700            link: None,
701            latent_cloglog_state: None,
702            mixture_state: None,
703            sas_state: None,
704        },
705        "gaussian".to_string(),
706    );
707    payload.spline_scan = Some(SavedSplineScan {
708        feature_column,
709        state: fit.to_state(),
710    });
711    payload.data_schema = Some(data_schema);
712    payload.set_training_feature_metadata(training_headers, training_feature_ranges);
713    payload
714}
715
716/// Assemble the canonical residual-cascade payload (#1032).
717///
718/// The CLI and FFI save paths both route through here so the cascade on-disk
719/// contract cannot diverge between sources.  Mirrors `assemble_spline_scan_payload`
720/// but for d ∈ {2,3} scattered coordinates (the Wendland multilevel-frame state).
721pub fn assemble_residual_cascade_payload(
722    formula: String,
723    feature_columns: Vec<String>,
724    fit: &gam_solve::residual_cascade::ResidualCascadeFit,
725    data_schema: DataSchema,
726    training_headers: Vec<String>,
727    training_feature_ranges: Vec<(f64, f64)>,
728) -> Result<FittedModelPayload, String> {
729    let mut payload = FittedModelPayload::new(
730        MODEL_PAYLOAD_VERSION,
731        formula,
732        ModelKind::Standard,
733        FittedFamily::Standard {
734            likelihood: gam_problem::types::LikelihoodSpec::gaussian_identity(),
735            link: None,
736            latent_cloglog_state: None,
737            mixture_state: None,
738            sas_state: None,
739        },
740        "gaussian".to_string(),
741    );
742    payload.residual_cascade = Some(SavedResidualCascade {
743        feature_columns,
744        state: fit.to_state().map_err(|e| {
745            format!("residual-cascade to_state failed during payload assembly: {e}")
746        })?,
747    });
748    payload.data_schema = Some(data_schema);
749    payload.set_training_feature_metadata(training_headers, training_feature_ranges);
750    Ok(payload)
751}
752
753/// Assemble the canonical Bernoulli marginal-slope payload.
754///
755/// This is the single place that decides which payload fields a marginal-slope
756/// model carries and how the singular/vector mirror fields
757/// (`formula_logslope(s)`, `z_column(s)`, `logslope_baseline(s)`,
758/// `resolved_termspec_logslope(s)`) are kept consistent — so the CLI and FFI
759/// saved models are byte-equivalent for identical semantic content.
760pub fn assemble_bernoulli_marginal_slope_payload(
761    inputs: BernoulliMarginalSlopeInputs<'_>,
762    source: SavedModelSourceMetadata,
763) -> Result<FittedModelPayload, String> {
764    let BernoulliMarginalSlopeInputs {
765        formula,
766        data_schema,
767        logslope_formula,
768        z_column,
769        resolved_marginalspec,
770        resolved_logslopespec,
771        fit_result,
772        p_marginal,
773        baseline_marginal,
774        baseline_logslope,
775        latent_z_normalization,
776        latent_measure,
777        latent_z_rank_int_calibration,
778        latent_z_conditional_calibration,
779        score_warp_runtime,
780        link_dev_runtime,
781        base_link,
782        frailty,
783    } = inputs;
784
785    // #461 predict seam: drop the training-only influence-absorber γ (and
786    // marginalize it out of the covariance) so the persisted model matches the
787    // raw p_m marginal design at predict. No-op when the absorber is inactive.
788    let fit_result = truncate_marginal_slope_influence_absorber(fit_result, p_marginal)?;
789
790    let marginal_likelihood_spec =
791        inverse_link_to_binomial_spec(&base_link).map_err(|e| e.to_string())?;
792
793    let mut payload = FittedModelPayload::new(
794        MODEL_PAYLOAD_VERSION,
795        formula,
796        ModelKind::MarginalSlope,
797        FittedFamily::MarginalSlope {
798            likelihood: marginal_likelihood_spec,
799            base_link: base_link.clone(),
800            frailty,
801        },
802        FAMILY_BERNOULLI_MARGINAL_SLOPE.to_string(),
803    );
804    payload.unified = Some(fit_result.clone());
805    payload.fit_result = Some(fit_result);
806    payload.data_schema = Some(data_schema);
807    payload.formula_logslope = Some(logslope_formula.clone());
808    payload.z_column = Some(z_column.clone());
809    payload.formula_logslopes = Some(vec![logslope_formula]);
810    payload.z_columns = Some(vec![z_column]);
811    payload.latent_z_normalization = Some(latent_z_normalization);
812    payload.latent_measure = Some(latent_measure);
813    payload.latent_z_rank_int_calibration = latent_z_rank_int_calibration;
814    payload.latent_z_conditional_calibration = latent_z_conditional_calibration;
815    payload.marginal_baseline = Some(baseline_marginal);
816    payload.logslope_baseline = Some(baseline_logslope);
817    payload.logslope_baselines = Some(vec![baseline_logslope]);
818    payload.link = Some(base_link);
819    payload.resolved_termspec = Some(resolved_marginalspec);
820    payload.resolved_termspec_logslopes = Some(vec![resolved_logslopespec.clone()]);
821    payload.resolved_termspec_logslope = Some(resolved_logslopespec);
822    payload.score_warp_runtime = score_warp_runtime.map(serialize_anchored_deviation_runtime);
823    payload.link_deviation_runtime = link_dev_runtime.map(serialize_anchored_deviation_runtime);
824    source.apply_to(&mut payload);
825    Ok(payload)
826}
827
828/// The resolved, source-agnostic semantic content of a transformation-normal
829/// saved model.
830///
831/// As with the marginal-slope inputs, the CLI threads the family and resolved
832/// covariate spec straight from its fit pipeline while the FFI reads them off
833/// its fit-result struct (freezing the covariate spec from its design first).
834pub struct TransformationNormalInputs<'a> {
835    pub formula: String,
836    pub data_schema: DataSchema,
837    pub resolved_covariate_spec: TermCollectionSpec,
838    pub fit_result: UnifiedFitResult,
839    pub family: &'a TransformationNormalFamily,
840    pub score_calibration: TransformationScoreCalibration,
841}
842
843/// Assemble the canonical transformation-normal payload.
844///
845/// Centralizing the response-transform snapshot (`knots`, `transform`,
846/// `degree`, `median`) and the fixed Gaussian-identity likelihood means the CLI
847/// and FFI cannot encode a transformation-normal model two different ways.
848pub fn assemble_transformation_normal_payload(
849    inputs: TransformationNormalInputs<'_>,
850    source: SavedModelSourceMetadata,
851) -> FittedModelPayload {
852    let TransformationNormalInputs {
853        formula,
854        data_schema,
855        resolved_covariate_spec,
856        fit_result,
857        family,
858        score_calibration,
859    } = inputs;
860
861    let mut payload = FittedModelPayload::new(
862        MODEL_PAYLOAD_VERSION,
863        formula,
864        ModelKind::TransformationNormal,
865        FittedFamily::TransformationNormal {
866            likelihood: LikelihoodSpec::new(
867                ResponseFamily::Gaussian,
868                InverseLink::Standard(StandardLink::Identity),
869            ),
870        },
871        FAMILY_TRANSFORMATION_NORMAL.to_string(),
872    );
873    payload.unified = Some(fit_result.clone());
874    payload.fit_result = Some(fit_result);
875    payload.data_schema = Some(data_schema);
876    payload.resolved_termspec = Some(resolved_covariate_spec);
877    payload.transformation_response_knots = Some(family.response_knots().to_vec());
878    payload.transformation_response_transform = Some(
879        family
880            .response_transform()
881            .rows()
882            .into_iter()
883            .map(|row| row.to_vec())
884            .collect(),
885    );
886    payload.transformation_response_degree = Some(family.response_degree());
887    payload.transformation_response_median = Some(family.response_median());
888    payload.transformation_geometry = Some(transformation_normal_geometry(family));
889    // Persist the monotonicity-cone carrier Ψ (the fitted covariate design at
890    // κ̂), row-major n × p_cov, so constrained posterior sampling can certify
891    // draws against the positivity cone without replaying the (non-bitwise)
892    // spatial warp. The covariate design is materialized during fitting, so this
893    // is a cache hit; a post-fit materialization failure is an internal invariant
894    // break, not a recoverable condition.
895    let cone_carrier = family
896        .covariate_dense_arc()
897        .expect("CTN covariate design must materialize for the persisted cone carrier");
898    payload.transformation_cone_carrier = Some(cone_carrier.iter().copied().collect());
899    payload.transformation_score_calibration = Some(score_calibration);
900    source.apply_to(&mut payload);
901    payload
902}
903
904/// Snapshot the direct-α CTN geometry (gam#2306) a saved model needs to replay
905/// the transform and the certified-domain prediction refusal.
906///
907/// The response value basis is `[1, I_1, …, I_K]` (`p_resp` columns), so the
908/// shape-coordinate count is `p_resp − 1` (column 0 is the unconstrained
909/// location field). The Khatri-Rao positivity-cone carrier is the `n × p_cov`
910/// covariate design, and the certified response support is the clamped-knot
911/// span `[knots.first, knots.last]` the endpoint bases were evaluated at.
912fn transformation_normal_geometry(
913    family: &TransformationNormalFamily,
914) -> SavedTransformationNormalGeometry {
915    let knots = family.response_knots();
916    let lo = knots.iter().copied().fold(f64::INFINITY, f64::min);
917    let hi = knots.iter().copied().fold(f64::NEG_INFINITY, f64::max);
918    SavedTransformationNormalGeometry {
919        parameterization: TransformationNormalParameterization::DirectAlpha,
920        response_degree: family.response_degree(),
921        response_knot_count: knots.len(),
922        shape_coordinate_count: family.p_resp().saturating_sub(1),
923        cone_carrier_covariate_width: family.p_cov(),
924        cone_carrier_row_count: family.n_obs(),
925        certified_response_support: (lo, hi),
926        response_median: family.response_median(),
927    }
928}
929
930/// Which likelihood a (non-survival) location-scale model carries: Gaussian
931/// (residual response scale) or binomial (noise scale-deviation transform whose
932/// likelihood is resolved from the inverse link). The assembler resolves the
933/// `FittedFamily` from this once, rather than each save path stamping a
934/// (potentially wrong) likelihood and patching it afterwards.
935pub enum LocationScaleResponse<'a> {
936    /// Gaussian identity; `base_link` is the optional resolved base link the CLI
937    /// may pass through from `link(...)` (the FFI leaves it `None`).
938    Gaussian {
939        response_scale: f64,
940        base_link: Option<InverseLink>,
941    },
942    /// Binomial under `link`, with the encoded noise scale-deviation transform.
943    Binomial {
944        link: InverseLink,
945        noise_transform: &'a ScaleDeviationTransform,
946    },
947    /// A genuine-dispersion mean family (NegativeBinomial / Gamma / Beta /
948    /// Tweedie) whose log-precision channel carries `noise_formula` (#913). The
949    /// `likelihood` is the family's own [`LikelihoodSpec`]; `base_link` is the
950    /// mean inverse link (log, or logit for Beta). The log-precision block
951    /// coefficients ride in [`LocationScaleInputs::beta_noise`].
952    Dispersion {
953        likelihood: LikelihoodSpec,
954        base_link: InverseLink,
955        family_tag: &'static str,
956    },
957}
958
959/// Optional link-wiggle metadata persisted alongside a location-scale model.
960/// Knots/coefficients are already in raw response units — the Gaussian
961/// standardization and its inverse remap live inside
962/// `fit_gaussian_location_scale_model`, so the save path persists them verbatim.
963pub struct LocationScaleWiggle {
964    pub knots: Vec<f64>,
965    pub degree: usize,
966    pub beta_link_wiggle: Vec<f64>,
967}
968
969/// Source-agnostic semantic content of a (non-survival) location-scale saved
970/// model — the shared core behind the CLI's Gaussian/binomial save paths and
971/// the FFI's two location-scale builders.
972pub struct LocationScaleInputs {
973    pub formula: String,
974    pub data_schema: DataSchema,
975    pub noise_formula: String,
976    pub resolved_termspec: TermCollectionSpec,
977    pub resolved_termspec_noise: TermCollectionSpec,
978    pub fit_result: UnifiedFitResult,
979    pub beta_noise: Option<Vec<f64>>,
980    pub wiggle: Option<LocationScaleWiggle>,
981}
982
983/// Assemble the canonical (non-survival) location-scale payload — single source
984/// of truth for that on-disk contract. The family/likelihood is resolved from
985/// the [`LocationScaleResponse`] so the binomial branch never persists a wrong
986/// probit likelihood that a caller must patch afterwards.
987pub fn assemble_location_scale_payload(
988    inputs: LocationScaleInputs,
989    response: LocationScaleResponse<'_>,
990    source: SavedModelSourceMetadata,
991) -> Result<FittedModelPayload, String> {
992    inputs
993        .fit_result
994        .require_posterior_mean("location-scale saved-model assembly")
995        .map_err(|error| error.to_string())?;
996    let (family_tag, likelihood, base_link, link, response_scale, noise_transform) = match response
997    {
998        LocationScaleResponse::Gaussian {
999            response_scale,
1000            base_link,
1001        } => (
1002            "gaussian-location-scale".to_string(),
1003            LikelihoodSpec::gaussian_identity(),
1004            // Gaussian location-scale does not carry a base link in its family
1005            // state; the resolved link is persisted in `payload.link` below so
1006            // prediction can recover it.
1007            None,
1008            Some(base_link.unwrap_or(InverseLink::Standard(StandardLink::Identity))),
1009            Some(response_scale),
1010            None,
1011        ),
1012        LocationScaleResponse::Binomial {
1013            link,
1014            noise_transform,
1015        } => {
1016            let likelihood = inverse_link_to_binomial_spec(&link).map_err(|e| {
1017                format!("failed to resolve LikelihoodSpec for binomial location-scale link {link:?}: {e}")
1018            })?;
1019            (
1020                "binomial-location-scale".to_string(),
1021                likelihood,
1022                Some(link.clone()),
1023                Some(link),
1024                None,
1025                Some(noise_transform),
1026            )
1027        }
1028        LocationScaleResponse::Dispersion {
1029            likelihood,
1030            base_link,
1031            family_tag,
1032        } => (
1033            family_tag.to_string(),
1034            likelihood,
1035            Some(base_link.clone()),
1036            Some(base_link),
1037            None,
1038            None,
1039        ),
1040    };
1041
1042    let mut payload = FittedModelPayload::new(
1043        MODEL_PAYLOAD_VERSION,
1044        inputs.formula,
1045        ModelKind::LocationScale,
1046        FittedFamily::LocationScale {
1047            likelihood,
1048            base_link,
1049        },
1050        family_tag,
1051    );
1052    payload.unified = Some(inputs.fit_result.clone());
1053    payload.fit_result = Some(inputs.fit_result);
1054    payload.data_schema = Some(inputs.data_schema);
1055    payload.link = link;
1056    payload.formula_noise = Some(inputs.noise_formula);
1057    payload.beta_noise = inputs.beta_noise;
1058    payload.gaussian_response_scale = response_scale;
1059    if let Some(transform) = noise_transform {
1060        payload.noise_projection = Some(
1061            transform
1062                .projection_coef
1063                .rows()
1064                .into_iter()
1065                .map(|row| row.to_vec())
1066                .collect(),
1067        );
1068        payload.noise_center = Some(transform.weighted_column_mean.to_vec());
1069        payload.noise_scale = Some(transform.rescale.to_vec());
1070        payload.noise_non_intercept_start = Some(transform.non_intercept_start);
1071        payload.noise_projection_ridge_alpha = Some(transform.projection_ridge_alpha);
1072    }
1073    payload.resolved_termspec = Some(inputs.resolved_termspec);
1074    payload.resolved_termspec_noise = Some(inputs.resolved_termspec_noise);
1075    if let Some(wiggle) = inputs.wiggle {
1076        payload.linkwiggle_knots = Some(wiggle.knots);
1077        payload.linkwiggle_degree = Some(wiggle.degree);
1078        payload.beta_link_wiggle = Some(wiggle.beta_link_wiggle);
1079    }
1080    source.apply_to(&mut payload);
1081    Ok(payload)
1082}
1083
1084/// Source-agnostic semantic content of a survival marginal-slope
1085/// (Royston-Parmar net) saved model. Centralizing assembly also fixes the
1086/// FFI's prior omission of the `*_logslopes`/`*_columns`/`formula_logslopes`
1087/// vector mirrors the CLI wrote.
1088pub struct SurvivalMarginalSlopeInputs<'a> {
1089    pub formula: String,
1090    pub data_schema: DataSchema,
1091    pub fit_result: UnifiedFitResult,
1092    pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
1093    pub survival_entry: Option<String>,
1094    pub survival_exit: String,
1095    pub survival_event: String,
1096    pub survivalspec: String,
1097    pub baseline_cfg: SurvivalBaselineConfig,
1098    pub time_basis: SavedSurvivalTimeBasis,
1099    pub ridge_lambda: f64,
1100    pub survival_likelihood_label: String,
1101    pub resolved_marginalspec: TermCollectionSpec,
1102    pub resolved_logslopespec: TermCollectionSpec,
1103    /// The fit's resolved log-slope follow-up time margin (gam#2765, gam#2767),
1104    /// or `None` for a slope that is constant within a person.
1105    ///
1106    /// `resolved_logslopespec` names the covariate factor only; with a margin
1107    /// present the fitted coefficients live against `X_cov ⊗ᵣ B(log t)`, so this
1108    /// is the half of the block's authority the term spec cannot carry.
1109    pub logslope_time_basis: Option<SurvivalCovariateTimeBasis>,
1110    pub logslope_formula: String,
1111    pub z_column: String,
1112    pub latent_z_normalization: SavedLatentZNormalization,
1113    /// The automatic latent-measure gate's decision for the persisted score
1114    /// surface (gam#2768), split by
1115    /// [`SurvivalMarginalSlopeFitResult::persisted_latent_z_calibrations`].
1116    /// Mutually exclusive; both `None` when the gate did not fire.
1117    pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
1118    pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
1119    pub baseline_logslope: f64,
1120    /// Frozen nonlinear time-wiggle authority, including the raw fitted tail.
1121    pub timewiggle: Option<SurvivalTimewiggle>,
1122    pub score_warp_runtime: Option<&'a DeviationRuntime>,
1123    pub link_dev_runtime: Option<&'a DeviationRuntime>,
1124    /// Width `p₁` of the absorbed Stage-1 influence block (#461) when the fit
1125    /// hosted a dedicated additive absorber. Predict drops the absorber's `γ`;
1126    /// this is persisted only so the predictor accounts for the extra trailing
1127    /// block in the saved block count.
1128    pub influence_absorber_width: Option<usize>,
1129    pub influence_absorber_design: Option<&'a Array2<f64>>,
1130    pub score_covariance: &'a Array2<f64>,
1131}
1132
1133/// Construct a Royston-Parmar survival [`FittedModelPayload`] through the
1134/// canonical `Survival` family scaffold shared by every RP on-disk contract
1135/// (marginal-slope, transformation, location-scale): the identity-link
1136/// `RoystonParmar` likelihood, the persisted likelihood label, and the
1137/// `fit_result` / `data_schema` install. Callers supply the two variants that
1138/// differ — `survival_distribution` and `frailty` — and then set their own
1139/// family-specific fields on the returned payload.
1140fn new_royston_parmar_survival_payload(
1141    formula: String,
1142    fit_result: UnifiedFitResult,
1143    data_schema: DataSchema,
1144    survival_likelihood_label: &str,
1145    survival_distribution: Option<ResidualDistribution>,
1146    frailty: crate::survival::lognormal_kernel::FrailtySpec,
1147) -> FittedModelPayload {
1148    let mut payload = FittedModelPayload::new(
1149        MODEL_PAYLOAD_VERSION,
1150        formula,
1151        ModelKind::Survival,
1152        FittedFamily::Survival {
1153            likelihood: LikelihoodSpec::new(
1154                ResponseFamily::RoystonParmar,
1155                InverseLink::Standard(StandardLink::Identity),
1156            ),
1157            survival_likelihood: Some(survival_likelihood_label.to_string()),
1158            survival_distribution,
1159            frailty,
1160        },
1161        ResponseFamily::RoystonParmar.name().to_string(),
1162    );
1163    payload.unified = Some(fit_result.clone());
1164    payload.fit_result = Some(fit_result);
1165    payload.data_schema = Some(data_schema);
1166    payload
1167}
1168
1169/// Assemble the canonical survival marginal-slope payload — single source of
1170/// truth for that Royston-Parmar / Gaussian-residual on-disk contract.
1171pub fn assemble_survival_marginal_slope_payload(
1172    inputs: SurvivalMarginalSlopeInputs<'_>,
1173    source: SavedModelSourceMetadata,
1174) -> FittedModelPayload {
1175    let mut payload = new_royston_parmar_survival_payload(
1176        inputs.formula,
1177        inputs.fit_result,
1178        inputs.data_schema,
1179        &inputs.survival_likelihood_label,
1180        Some(ResidualDistribution::Gaussian),
1181        inputs.frailty,
1182    );
1183    payload.survival_entry = inputs.survival_entry;
1184    payload.survival_exit = Some(inputs.survival_exit);
1185    payload.survival_event = Some(inputs.survival_event);
1186    payload.survivalspec = Some(inputs.survivalspec);
1187    payload.survival_baseline_target =
1188        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1189    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1190    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1191    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1192    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1193    payload.apply_survival_time_basis(&inputs.time_basis);
1194    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1195    payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1196    payload.survival_distribution = Some(ResidualDistribution::Gaussian);
1197    payload.link = Some(InverseLink::Standard(StandardLink::Probit));
1198    payload.resolved_termspec = Some(inputs.resolved_marginalspec);
1199    payload.resolved_termspec_logslopes = Some(vec![inputs.resolved_logslopespec.clone()]);
1200    payload.resolved_termspec_logslope = Some(inputs.resolved_logslopespec);
1201    payload.logslope_time_basis = inputs.logslope_time_basis;
1202    payload.formula_logslope = Some(inputs.logslope_formula.clone());
1203    payload.formula_logslopes = Some(vec![inputs.logslope_formula]);
1204    payload.z_column = Some(inputs.z_column.clone());
1205    payload.z_columns = Some(vec![inputs.z_column]);
1206    payload.latent_z_normalization = Some(inputs.latent_z_normalization);
1207    // Not an assumption: the survival marginal-slope row program is the
1208    // closed-form standard-normal probit lowering and owns no empirical-grid
1209    // branch, so its latent-measure gate is asked for
1210    // `EmpiricalLatentMeasureSupport::StandardNormalOnly` and the invariant is
1211    // enforced at the gate's call site in
1212    // `survival/marginal_slope/latent_measure.rs`. What the gate CAN vary is the
1213    // pre-transform applied to z before that kernel, and that is the pair below.
1214    payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
1215    payload.latent_z_rank_int_calibration = inputs.latent_z_rank_int_calibration;
1216    payload.latent_z_conditional_calibration = inputs.latent_z_conditional_calibration;
1217    payload.logslope_baseline = Some(inputs.baseline_logslope);
1218    payload.logslope_baselines = Some(vec![inputs.baseline_logslope]);
1219    if let Some(timewiggle) = inputs.timewiggle {
1220        payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1221        payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1222        payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1223        payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1224        apply_timewiggle_beta(&mut payload, timewiggle.beta);
1225    }
1226    payload.score_warp_runtime = inputs
1227        .score_warp_runtime
1228        .map(serialize_anchored_deviation_runtime);
1229    payload.link_deviation_runtime = inputs
1230        .link_dev_runtime
1231        .map(serialize_anchored_deviation_runtime);
1232    payload.influence_absorber_width = inputs.influence_absorber_width;
1233    payload.influence_absorber_design = inputs
1234        .influence_absorber_design
1235        .map(|design| design.rows().into_iter().map(|row| row.to_vec()).collect());
1236    payload.survival_marginal_slope_score_covariance = Some(
1237        inputs
1238            .score_covariance
1239            .rows()
1240            .into_iter()
1241            .map(|row| row.to_vec())
1242            .collect(),
1243    );
1244    source.apply_to(&mut payload);
1245    payload
1246}
1247
1248/// Fitted baseline-timewiggle coefficients: a single block (net) or one per
1249/// cause (joint cause-specific). Callers pass already-sliced coefficients.
1250pub enum SurvivalTimewiggleBeta {
1251    Single(Vec<f64>),
1252    ByCause(Vec<Vec<f64>>),
1253}
1254
1255/// Route the fitted baseline-timewiggle coefficients into the matching payload
1256/// slot. Both survival payload assemblers funnel through this ONE exhaustive
1257/// `match` so a new [`SurvivalTimewiggleBeta`] variant is a compile error rather
1258/// than a silent drop (the location-scale assembler previously `if let`-matched
1259/// only `Single` and silently discarded `ByCause`).
1260fn apply_timewiggle_beta(payload: &mut FittedModelPayload, beta: SurvivalTimewiggleBeta) {
1261    match beta {
1262        SurvivalTimewiggleBeta::Single(beta) => {
1263            payload.beta_baseline_timewiggle = Some(beta);
1264        }
1265        SurvivalTimewiggleBeta::ByCause(by_cause) => {
1266            payload.beta_baseline_timewiggle_by_cause = Some(by_cause);
1267        }
1268    }
1269}
1270
1271/// Snapshot of the baseline-timewiggle block persisted with a survival model.
1272pub struct SurvivalTimewiggle {
1273    pub degree: usize,
1274    pub knots: Vec<f64>,
1275    pub penalty_orders: Option<Vec<usize>>,
1276    pub double_penalty: Option<bool>,
1277    pub beta: SurvivalTimewiggleBeta,
1278}
1279
1280/// Source-agnostic semantic content of a survival transformation
1281/// (Royston-Parmar) saved model — net single-cause or joint cause-specific.
1282pub struct SurvivalTransformationInputs {
1283    pub formula: String,
1284    pub data_schema: DataSchema,
1285    pub fit_result: UnifiedFitResult,
1286    pub survival_entry: Option<String>,
1287    pub survival_exit: String,
1288    pub survival_event: String,
1289    pub survivalspec: String,
1290    /// `None` = net single-cause; `Some(n)` persists `survival_cause_count` and
1291    /// `cause_1..cause_n` endpoint names.
1292    pub cause_count: Option<usize>,
1293    pub baseline_cfg: SurvivalBaselineConfig,
1294    pub time_basis: SavedSurvivalTimeBasis,
1295    pub ridge_lambda: f64,
1296    pub survival_likelihood_label: String,
1297    pub resolved_termspec: TermCollectionSpec,
1298    /// Rigid time-block beta, persisted only by the cause-specific CLI path.
1299    pub survival_beta_time: Option<Vec<f64>>,
1300    pub timewiggle: Option<SurvivalTimewiggle>,
1301}
1302
1303/// Assemble the canonical survival transformation payload — single source of
1304/// truth for the Royston-Parmar transformation on-disk contract.
1305pub fn assemble_survival_transformation_payload(
1306    inputs: SurvivalTransformationInputs,
1307    source: SavedModelSourceMetadata,
1308) -> FittedModelPayload {
1309    let mut payload = new_royston_parmar_survival_payload(
1310        inputs.formula,
1311        inputs.fit_result,
1312        inputs.data_schema,
1313        &inputs.survival_likelihood_label,
1314        None,
1315        crate::survival::lognormal_kernel::FrailtySpec::None,
1316    );
1317    payload.survival_entry = inputs.survival_entry;
1318    payload.survival_exit = Some(inputs.survival_exit);
1319    payload.survival_event = Some(inputs.survival_event);
1320    payload.survivalspec = Some(inputs.survivalspec);
1321    if let Some(cause_count) = inputs.cause_count {
1322        payload.survival_cause_count = Some(cause_count);
1323        payload.survival_endpoint_names = Some(
1324            (1..=cause_count)
1325                .map(|idx| format!("cause_{idx}"))
1326                .collect(),
1327        );
1328    }
1329    payload.survival_baseline_target =
1330        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1331    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1332    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1333    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1334    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1335    payload.apply_survival_time_basis(&inputs.time_basis);
1336    if let Some(timewiggle) = inputs.timewiggle {
1337        payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1338        payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1339        payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1340        payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1341        apply_timewiggle_beta(&mut payload, timewiggle.beta);
1342    }
1343    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1344    payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1345    payload.survival_beta_time = inputs.survival_beta_time;
1346    payload.resolved_termspec = Some(inputs.resolved_termspec);
1347    source.apply_to(&mut payload);
1348    payload
1349}
1350
1351/// Source-agnostic semantic content of a survival location-scale
1352/// (Royston-Parmar with a learned residual link) saved model. Centralizing
1353/// fixes the drift where CLI and FFI disagreed on `formula_noise`,
1354/// `baseline_timewiggle_*`, and exact location-scale replay topology.
1355pub struct SurvivalLocationScaleInputs {
1356    pub formula: String,
1357    pub data_schema: DataSchema,
1358    /// Fit result with the fitted inverse-link state and link-wiggle artifacts
1359    /// already applied by the caller.
1360    pub fit_result: UnifiedFitResult,
1361    pub fitted_inverse_link: InverseLink,
1362    // Independent `Option`s (not an all-or-nothing group) so the assembler
1363    // reproduces exactly what the CLI and FFI each persist independently.
1364    pub linkwiggle_degree: Option<usize>,
1365    pub linkwiggle_knots: Option<Vec<f64>>,
1366    pub beta_link_wiggle: Option<Vec<f64>>,
1367    pub baseline_timewiggle: Option<SurvivalTimewiggle>,
1368    pub survival_entry: Option<String>,
1369    pub survival_exit: String,
1370    pub survival_event: String,
1371    pub survivalspec: String,
1372    pub baseline_cfg: SurvivalBaselineConfig,
1373    pub time_basis: SavedSurvivalTimeBasis,
1374    pub ridge_lambda: f64,
1375    pub survival_likelihood_label: String,
1376    pub time_parameterization: SurvivalLocationScaleTimeParameterization,
1377    pub threshold_time_basis: Option<SurvivalCovariateTimeBasis>,
1378    pub log_sigma_time_basis: Option<SurvivalCovariateTimeBasis>,
1379    pub formula_noise: Option<String>,
1380    pub survival_beta_time: Vec<f64>,
1381    pub survival_beta_threshold: Vec<f64>,
1382    pub survival_beta_log_sigma: Vec<f64>,
1383    pub resolved_thresholdspec: TermCollectionSpec,
1384    pub resolved_log_sigmaspec: TermCollectionSpec,
1385}
1386
1387/// Assemble the canonical survival location-scale payload (the single source of
1388/// truth for that on-disk contract).
1389pub fn assemble_survival_location_scale_payload(
1390    inputs: SurvivalLocationScaleInputs,
1391    source: SavedModelSourceMetadata,
1392) -> FittedModelPayload {
1393    let survival_distribution =
1394        residual_distribution_from_inverse_link(&inputs.fitted_inverse_link);
1395    let mut payload = new_royston_parmar_survival_payload(
1396        inputs.formula,
1397        inputs.fit_result,
1398        inputs.data_schema,
1399        &inputs.survival_likelihood_label,
1400        survival_distribution,
1401        crate::survival::lognormal_kernel::FrailtySpec::None,
1402    );
1403    payload.link = Some(inputs.fitted_inverse_link);
1404    payload.linkwiggle_degree = inputs.linkwiggle_degree;
1405    payload.linkwiggle_knots = inputs.linkwiggle_knots;
1406    payload.beta_link_wiggle = inputs.beta_link_wiggle;
1407    if let Some(timewiggle) = inputs.baseline_timewiggle {
1408        payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1409        payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1410        payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1411        payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1412        apply_timewiggle_beta(&mut payload, timewiggle.beta);
1413    }
1414    payload.survival_entry = inputs.survival_entry;
1415    payload.survival_exit = Some(inputs.survival_exit);
1416    payload.survival_event = Some(inputs.survival_event);
1417    payload.survivalspec = Some(inputs.survivalspec);
1418    payload.survival_baseline_target =
1419        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1420    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1421    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1422    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1423    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1424    payload.apply_survival_time_basis(&inputs.time_basis);
1425    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1426    payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1427    payload.survival_location_scale_structure = Some(SavedSurvivalLocationScaleStructure {
1428        time_parameterization: inputs.time_parameterization,
1429        threshold_time_basis: inputs.threshold_time_basis,
1430        log_sigma_time_basis: inputs.log_sigma_time_basis,
1431    });
1432    payload.formula_noise = inputs.formula_noise;
1433    payload.survival_beta_time = Some(inputs.survival_beta_time);
1434    payload.survival_beta_threshold = Some(inputs.survival_beta_threshold);
1435    payload.survival_beta_log_sigma = Some(inputs.survival_beta_log_sigma);
1436    payload.survival_distribution = survival_distribution;
1437    payload.resolved_termspec = Some(inputs.resolved_thresholdspec);
1438    payload.resolved_termspec_noise = Some(inputs.resolved_log_sigmaspec);
1439    source.apply_to(&mut payload);
1440    payload
1441}
1442
1443/// Source-agnostic semantic content of a latent survival / latent binary saved
1444/// model. The caller resolves the family (splicing the learned latent SD into
1445/// the persisted frailty for survival) and the model-class / likelihood labels.
1446pub struct LatentWindowInputs {
1447    pub formula: String,
1448    pub data_schema: DataSchema,
1449    pub fit_result: UnifiedFitResult,
1450    pub family: FittedFamily,
1451    pub model_class_label: String,
1452    pub likelihood_label: String,
1453    pub survival_entry: Option<String>,
1454    pub survival_exit: String,
1455    pub survival_event: String,
1456    pub baseline_cfg: SurvivalBaselineConfig,
1457    pub time_basis: SavedSurvivalTimeBasis,
1458    pub ridge_lambda: f64,
1459    pub beta_time: Vec<f64>,
1460    pub resolved_termspec: TermCollectionSpec,
1461}
1462
1463/// Assemble the canonical latent survival / latent binary payload.
1464pub fn assemble_latent_window_payload(
1465    inputs: LatentWindowInputs,
1466    source: SavedModelSourceMetadata,
1467) -> FittedModelPayload {
1468    let mut payload = FittedModelPayload::new(
1469        MODEL_PAYLOAD_VERSION,
1470        inputs.formula,
1471        ModelKind::Survival,
1472        inputs.family,
1473        inputs.model_class_label,
1474    );
1475    payload.unified = Some(inputs.fit_result.clone());
1476    payload.fit_result = Some(inputs.fit_result);
1477    payload.data_schema = Some(inputs.data_schema);
1478    payload.survival_entry = inputs.survival_entry;
1479    payload.survival_exit = Some(inputs.survival_exit);
1480    payload.survival_event = Some(inputs.survival_event);
1481    payload.survivalspec = Some("net".to_string());
1482    payload.survival_baseline_target =
1483        Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1484    payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1485    payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1486    payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1487    payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1488    payload.apply_survival_time_basis(&inputs.time_basis);
1489    payload.survival_likelihood = Some(inputs.likelihood_label);
1490    payload.survival_beta_time = Some(inputs.beta_time);
1491    payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1492    payload.resolved_termspec = Some(inputs.resolved_termspec);
1493    source.apply_to(&mut payload);
1494    payload
1495}
1496
1497/// Copy the frontend-neutral request metadata onto a freshly assembled payload.
1498///
1499/// These three fields are *request* metadata, not fit output: nothing in the
1500/// fitted result can reconstruct them, so every save route has to copy them
1501/// across by hand, and a route that copies two of the three silently persists a
1502/// different model than its sibling front end does for the same canonical
1503/// `gam.fit-request` document. `training_table_kind` was exactly that hole: the
1504/// shared `fit_formula_to_payload` service (Python FFI) copied it, while every
1505/// `gam fit --out` save route in the CLI copied only `group_metadata` and
1506/// `inference_notes`, so a request document carrying `"polars"` persisted as
1507/// `"polars"` from Python and as the `"unknown"` default from the CLI. This
1508/// function is the single owner of that copy so the two cannot drift again;
1509/// `frontend_request_metadata_parity_2470` is the executable statement of it.
1510/// (#2470)
1511pub fn apply_request_metadata(
1512    payload: &mut FittedModelPayload,
1513    fit_config: &FitConfig,
1514    inference_notes: Vec<String>,
1515) {
1516    payload.group_metadata = fit_config.group_metadata.clone();
1517    payload.training_table_kind = fit_config.training_table_kind.clone();
1518    payload.inference_notes = inference_notes;
1519}
1520
1521/// One authoritative "formula fit → saved payload" service: materialize once,
1522/// dispatch on the request variant, fit, and assemble the persistence payload.
1523/// Both front ends (CLI, Python FFI) must route through this function so a fit
1524/// requested through any surface produces an identical saved model. (#2470)
1525pub fn fit_formula_to_payload(
1526    formula: String,
1527    dataset: &EncodedDataset,
1528    fit_config: &FitConfig,
1529) -> Result<FittedModelPayload, WorkflowError> {
1530    // Expectile (Newey–Powell LAWS) family (#1777): the expectile estimator is an
1531    // OUTER driver that wraps the standard Gaussian-identity GAM with iterative
1532    // asymmetric reweighting, so it is selected *before* `materialize` (which has
1533    // no expectile arm) — exactly as the in-process `fit_from_formula` does. We
1534    // route it through the single shared dispatch seam so the Python API reaches
1535    // the same estimator the library call does instead of failing with
1536    // `unknown family 'expectile(τ)'`. The driver returns an ordinary
1537    // `StandardFitResult`, so the persistence payload is built by the same
1538    // `assemble_standard_payload` used for every other standard fit.
1539    if let Some(expectile_result) = fit_expectile_if_requested(&formula, dataset, fit_config)? {
1540        let mut payload = assemble_standard_payload(StandardPayloadInputs {
1541            formula,
1542            dataset,
1543            fit_config,
1544            result: expectile_result,
1545        })?;
1546        // The LAWS driver materializes its inner Gaussian design itself; there are
1547        // no outer materialize advisories to carry (matches `fit_from_formula`).
1548        apply_request_metadata(&mut payload, fit_config, Vec::new());
1549        return Ok(payload);
1550    }
1551    // Calibrated marginal-slope chain (#461): when a CTN Stage-1 recipe is present
1552    // (config.ctn_stage1), the marginal-slope materializer cross-fits the CTN and
1553    // produces the calibrated `z` out-of-fold — no z_column is needed and no
1554    // Stage-1 pre-fit / synthetic column round-trip is performed here. The recipe
1555    // rides on fit_config straight into materialize.
1556    // Standard-fit dispatch must materialize at the adaptive structural start:
1557    // this request becomes the first fitted design below. Other estimator
1558    // materializers do not consume this standard-only orchestration field.
1559    let mut dispatch_config = fit_config.clone();
1560    dispatch_config.spatial_center_counts = Some(Vec::new());
1561    let materialized = materialize(&formula, dataset, &dispatch_config)?;
1562    let request = materialized.request;
1563    // The time basis THIS materialization built, carried to the save path so a
1564    // survival payload records the basis its own fit used instead of a second,
1565    // independently re-derived one (#2470).
1566    let survival_time_basis = materialized.survival_time_basis;
1567    // Advisories produced while materializing (e.g. the mgcv-style "k reduced to
1568    // the data support" / basis-degradation notes from the cr/cs/sz cap, #1541
1569    // #1542). The CLI prints these via `print_inference_summary`; the Python
1570    // path used to drop them on the floor, so a gamfit user whose basis was
1571    // silently capped got no signal at all (#1543). Carry them into the
1572    // serialized payload so gamfit can surface them as `GamInferenceWarning`s
1573    // and via `model.notes`.
1574    let mut inference_notes = materialized.inference_notes;
1575
1576    let mut payload = match request {
1577        FitRequest::Standard(standard_request) => {
1578            // Fit the request that selected this arm, then hand its converged
1579            // result to the same loop owner the CLI uses. Re-entering the
1580            // formula entry point here used to materialize the spatial design a
1581            // second time; before the adaptive loop landed, the first discarded
1582            // design was also the old fully provisioned rank (#1689).
1583            let standard_spec = standard_request.spec.clone();
1584            let initial_notes = std::mem::take(&mut inference_notes);
1585            let outcome = fit_materialized_standard_with_notes(
1586                &formula,
1587                dataset,
1588                fit_config,
1589                standard_request,
1590                initial_notes,
1591            )?;
1592            inference_notes = outcome.inference_notes;
1593            match outcome.result {
1594                FitResult::Standard(standard_result) => {
1595                    assemble_standard_payload(StandardPayloadInputs {
1596                        formula,
1597                        dataset,
1598                        fit_config,
1599                        result: standard_result,
1600                    })?
1601                }
1602                FitResult::SplineScan(scan) => {
1603                    // The scan detection is structural on the materialized
1604                    // shape, so the dispatch request's single smooth is the
1605                    // same 1-D B-spline the entry point scan-routed.
1606                    let feature_col = match &standard_spec.smooth_terms[0].basis {
1607                        gam_terms::smooth::SmoothBasisSpec::BSpline1D { feature_col, .. } => {
1608                            *feature_col
1609                        }
1610                        _ => {
1611                            return Err(WorkflowError::SchemaMismatch {
1612                                reason: "spline-scan detection accepted a non-1D basis".to_string(),
1613                            });
1614                        }
1615                    };
1616                    let feature_column =
1617                        dataset.headers.get(feature_col).cloned().ok_or_else(|| {
1618                            WorkflowError::SchemaMismatch {
1619                                reason: format!(
1620                                    "spline-scan feature column {feature_col} has no header"
1621                                ),
1622                            }
1623                        })?;
1624                    let mut scan_payload = assemble_spline_scan_payload(
1625                        formula,
1626                        feature_column,
1627                        &scan,
1628                        dataset.schema.clone(),
1629                        dataset.headers.clone(),
1630                        dataset.feature_ranges(),
1631                    );
1632                    scan_payload.weight_column = fit_config.weight_column.clone();
1633                    apply_request_metadata(&mut scan_payload, fit_config, inference_notes);
1634                    return Ok(scan_payload);
1635                }
1636                FitResult::ResidualCascade(cascade) => {
1637                    // The cascade fires only for a single scattered radial
1638                    // smooth; recover its feature columns from the dispatch
1639                    // request the same way the CLI does from its parsed
1640                    // formula.
1641                    let feature_cols = standard_spec
1642                        .smooth_terms
1643                        .iter()
1644                        .find_map(|term| match &term.basis {
1645                            gam_terms::smooth::SmoothBasisSpec::ThinPlate {
1646                                feature_cols, ..
1647                            }
1648                            | gam_terms::smooth::SmoothBasisSpec::Duchon {
1649                                feature_cols, ..
1650                            }
1651                            | gam_terms::smooth::SmoothBasisSpec::Matern {
1652                                feature_cols, ..
1653                            } => Some(feature_cols.clone()),
1654                            _ => None,
1655                        })
1656                        .ok_or_else(|| WorkflowError::SchemaMismatch {
1657                            reason: "residual-cascade result has no radial smooth in the \
1658                                     materialized request"
1659                                .to_string(),
1660                        })?;
1661                    let feature_columns = feature_cols
1662                        .into_iter()
1663                        .map(|col| {
1664                            dataset.headers.get(col).cloned().ok_or_else(|| {
1665                                WorkflowError::SchemaMismatch {
1666                                    reason: format!(
1667                                        "residual-cascade feature column {col} has no header"
1668                                    ),
1669                                }
1670                            })
1671                        })
1672                        .collect::<Result<Vec<_>, _>>()?;
1673                    let mut cascade_payload = assemble_residual_cascade_payload(
1674                        formula,
1675                        feature_columns,
1676                        &cascade,
1677                        dataset.schema.clone(),
1678                        dataset.headers.clone(),
1679                        dataset.feature_ranges(),
1680                    )
1681                    .map_err(|reason| WorkflowError::IntegrationFailed { reason })?;
1682                    apply_request_metadata(&mut cascade_payload, fit_config, inference_notes);
1683                    return Ok(cascade_payload);
1684                }
1685                _ => {
1686                    return Err(WorkflowError::SchemaMismatch {
1687                        reason: "python binding expected the standard workflow to return a standard fit result"
1688                            .to_string(),
1689                    });
1690                }
1691            }
1692        }
1693        FitRequest::TransformationNormal(tn_request) => {
1694            let fit_result = fit_model(FitRequest::TransformationNormal(tn_request))?;
1695            let tn_result = match fit_result {
1696                FitResult::TransformationNormal(result) => result,
1697                _ => {
1698                    return Err(WorkflowError::SchemaMismatch {
1699                        reason: "python binding expected the transformation-normal workflow to return a transformation-normal fit result"
1700                            .to_string(),
1701                    });
1702                }
1703            };
1704            payload_for_transformation_normal(formula, dataset, fit_config, tn_result)?
1705        }
1706        FitRequest::BernoulliMarginalSlope(ms_request) => {
1707            let base_link = ms_request.spec.base_link.clone();
1708            let frailty = ms_request.spec.frailty.clone();
1709            let fit_result = fit_model(FitRequest::BernoulliMarginalSlope(ms_request))?;
1710            let ms_result = match fit_result {
1711                FitResult::BernoulliMarginalSlope(result) => result,
1712                _ => {
1713                    return Err(WorkflowError::SchemaMismatch {
1714                        reason: "python binding expected the bernoulli marginal-slope workflow to return a marginal-slope fit result"
1715                            .to_string(),
1716                    });
1717                }
1718            };
1719            payload_for_bernoulli_marginal_slope(
1720                formula,
1721                dataset,
1722                fit_config,
1723                base_link,
1724                frailty,
1725                ms_result,
1726            )?
1727        }
1728        FitRequest::SurvivalMarginalSlope(ms_request) => {
1729            let frailty = ms_request.spec.frailty.clone();
1730            let fit_result = fit_model(FitRequest::SurvivalMarginalSlope(ms_request))?;
1731            let ms_result = match fit_result {
1732                FitResult::SurvivalMarginalSlope(result) => result,
1733                _ => {
1734                    return Err(WorkflowError::SchemaMismatch {
1735                        reason: "python binding expected the survival marginal-slope workflow to return a survival marginal-slope fit result"
1736                            .to_string(),
1737                    });
1738                }
1739            };
1740            payload_for_survival_marginal_slope(formula, dataset, fit_config, frailty, ms_result)?
1741        }
1742        FitRequest::GaussianLocationScale(ls_request) => {
1743            let fit_result = fit_model(FitRequest::GaussianLocationScale(ls_request))?;
1744            let ls_result = match fit_result {
1745                FitResult::GaussianLocationScale(result) => result,
1746                _ => {
1747                    return Err(WorkflowError::SchemaMismatch {
1748                        reason: "python binding expected the gaussian location-scale workflow to return a gaussian location-scale fit result"
1749                            .to_string(),
1750                    });
1751                }
1752            };
1753            // Persist the response standardization factor the fit applied so
1754            // prediction reconstructs the σ floor at `response_scale·0.01`,
1755            // keeping predictive σ response-scale-equivariant (#884). The fit
1756            // already mapped the log-σ `exp(η)` term to raw units via the
1757            // `+ln(response_scale)` intercept shift; only the additive floor
1758            // still needs the factor at reconstruction time.
1759            let response_scale = ls_result.response_scale;
1760            payload_for_gaussian_location_scale(
1761                formula,
1762                dataset,
1763                fit_config,
1764                ls_result,
1765                response_scale,
1766            )?
1767        }
1768        FitRequest::BinomialLocationScale(ls_request) => {
1769            let weights = ls_request.spec.weights.clone();
1770            let link_kind = ls_request.spec.link_kind.clone();
1771            let fit_result = fit_model(FitRequest::BinomialLocationScale(ls_request))?;
1772            let ls_result = match fit_result {
1773                FitResult::BinomialLocationScale(result) => result,
1774                _ => {
1775                    return Err(WorkflowError::SchemaMismatch {
1776                        reason: "python binding expected the binomial location-scale workflow to return a binomial location-scale fit result"
1777                            .to_string(),
1778                    });
1779                }
1780            };
1781            payload_for_binomial_location_scale(
1782                formula,
1783                dataset,
1784                fit_config,
1785                link_kind,
1786                &weights,
1787                ls_result,
1788            )?
1789        }
1790        FitRequest::SurvivalLocationScale(ls_request) => {
1791            let fit_result = fit_model(FitRequest::SurvivalLocationScale(ls_request))?;
1792            let ls_result = match fit_result {
1793                FitResult::SurvivalLocationScale(result) => result,
1794                _ => {
1795                    return Err(WorkflowError::SchemaMismatch {
1796                        reason: "python binding expected the survival location-scale workflow to return a survival location-scale fit result"
1797                            .to_string(),
1798                    });
1799                }
1800            };
1801            payload_for_survival_location_scale(
1802                formula,
1803                dataset,
1804                fit_config,
1805                ls_result,
1806                survival_time_basis,
1807            )?
1808        }
1809        FitRequest::SurvivalTransformation(rp_request) => {
1810            let fit_result = fit_model(FitRequest::SurvivalTransformation(rp_request))?;
1811            let rp_result = match fit_result {
1812                FitResult::SurvivalTransformation(result) => result,
1813                _ => {
1814                    return Err(WorkflowError::SchemaMismatch {
1815                        reason: "python binding expected the survival transformation workflow to return a survival transformation fit result"
1816                            .to_string(),
1817                    });
1818                }
1819            };
1820            payload_for_survival_transformation(formula, dataset, fit_config, rp_result)?
1821        }
1822        FitRequest::LatentSurvival(lat_request) => {
1823            let frailty = lat_request.frailty.clone();
1824            let fit_result = fit_model(FitRequest::LatentSurvival(lat_request))?;
1825            let lat_result = match fit_result {
1826                FitResult::LatentSurvival(result) => result,
1827                _ => {
1828                    return Err(WorkflowError::SchemaMismatch {
1829                        reason: "python binding expected the latent survival workflow to return a latent survival fit result"
1830                            .to_string(),
1831                    });
1832                }
1833            };
1834            payload_for_latent_survival(
1835                formula,
1836                dataset,
1837                fit_config,
1838                frailty,
1839                lat_result,
1840                survival_time_basis,
1841            )?
1842        }
1843        FitRequest::LatentBinary(lat_request) => {
1844            let frailty = lat_request.frailty.clone();
1845            let fit_result = fit_model(FitRequest::LatentBinary(lat_request))?;
1846            let lat_result = match fit_result {
1847                FitResult::LatentBinary(result) => result,
1848                _ => {
1849                    return Err(WorkflowError::SchemaMismatch {
1850                        reason: "python binding expected the latent binary workflow to return a latent binary fit result"
1851                            .to_string(),
1852                    });
1853                }
1854            };
1855            payload_for_latent_binary(
1856                formula,
1857                dataset,
1858                fit_config,
1859                frailty,
1860                lat_result,
1861                survival_time_basis,
1862            )?
1863        }
1864        FitRequest::DispersionLocationScale(ls_request) => {
1865            // Genuine-dispersion location-scale family (#913): NB / Gamma / Beta
1866            // / Tweedie mean families whose `noise_formula` models the
1867            // overdispersion channel. Magic-detected upstream from a
1868            // `noise_formula` on one of those families; the FFI freezes the mean
1869            // and log-precision specs and persists them via the same shared
1870            // location-scale assembler the CLI uses.
1871            let kind = ls_request.spec.kind;
1872            let fit_result = fit_model(FitRequest::DispersionLocationScale(ls_request))?;
1873            let ls_result = match fit_result {
1874                FitResult::DispersionLocationScale(result) => result,
1875                _ => {
1876                    return Err(WorkflowError::SchemaMismatch {
1877                        reason: "python binding expected the dispersion location-scale workflow to return a dispersion location-scale fit result"
1878                            .to_string(),
1879                    });
1880                }
1881            };
1882            payload_for_dispersion_location_scale(formula, dataset, fit_config, kind, ls_result)?
1883        }
1884    };
1885    apply_request_metadata(&mut payload, fit_config, inference_notes);
1886    Ok(payload)
1887}
1888
1889fn payload_for_transformation_normal(
1890    formula: String,
1891    dataset: &EncodedDataset,
1892    fit_config: &FitConfig,
1893    tn_result: TransformationNormalFitResult,
1894) -> Result<FittedModelPayload, String> {
1895    let frozen_covariate = freeze_term_collection_from_design(
1896        &tn_result.covariate_spec_resolved,
1897        &tn_result.covariate_design,
1898    )
1899    .map_err(|err| format!("failed to freeze transformation-normal covariate spec: {err}"))?;
1900
1901    // Thin adapter over the shared core assembler; the FFI freezes the
1902    // covariate spec from its design and reads the offset column from the
1903    // FitConfig. See `assemble_transformation_normal_payload`.
1904    Ok(assemble_transformation_normal_payload(
1905        TransformationNormalInputs {
1906            formula,
1907            data_schema: dataset.schema.clone(),
1908            resolved_covariate_spec: frozen_covariate,
1909            fit_result: tn_result.fit.clone(),
1910            family: &tn_result.family,
1911            score_calibration: tn_result.score_calibration.clone(),
1912        },
1913        SavedModelSourceMetadata {
1914            training_headers: dataset.headers.clone(),
1915            training_feature_ranges: Some(dataset.feature_ranges()),
1916            offset_column: fit_config.offset_column.clone(),
1917            noise_offset_column: None,
1918        },
1919    ))
1920}
1921
1922fn payload_for_bernoulli_marginal_slope(
1923    formula: String,
1924    dataset: &EncodedDataset,
1925    fit_config: &FitConfig,
1926    base_link: InverseLink,
1927    frailty: crate::survival::lognormal_kernel::FrailtySpec,
1928    ms_result: BernoulliMarginalSlopeFitResult,
1929) -> Result<FittedModelPayload, String> {
1930    let frozen_marginal = freeze_term_collection_from_design(
1931        &ms_result.marginalspec_resolved,
1932        &ms_result.marginal_design,
1933    )
1934    .map_err(|err| format!("failed to freeze marginal spec: {err}"))?;
1935    let frozen_logslope = freeze_term_collection_from_design(
1936        &ms_result.logslopespec_resolved,
1937        &ms_result.logslope_design,
1938    )
1939    .map_err(|err| format!("failed to freeze logslope spec: {err}"))?;
1940
1941    let logslope_formula = fit_config
1942        .logslope_formula
1943        .clone()
1944        .ok_or_else(|| "bernoulli marginal-slope requires logslope_formula".to_string())?;
1945    let z_column = fit_config
1946        .z_column
1947        .clone()
1948        .ok_or_else(|| "bernoulli marginal-slope requires z_column".to_string())?;
1949
1950    // Thin adapter over the shared core assembler. The FFI's source-specific
1951    // work is freezing term collections from their designs, reading the
1952    // logslope formula / z column / offset columns from the FitConfig, and
1953    // persisting headers without per-feature ranges; the semantic payload is
1954    // assembled by the same core path the CLI uses, so the two save routes
1955    // produce identical contracts.
1956    assemble_bernoulli_marginal_slope_payload(
1957        BernoulliMarginalSlopeInputs {
1958            formula,
1959            data_schema: dataset.schema.clone(),
1960            logslope_formula,
1961            z_column,
1962            resolved_marginalspec: frozen_marginal,
1963            resolved_logslopespec: frozen_logslope,
1964            fit_result: ms_result.fit.clone(),
1965            p_marginal: ms_result.marginal_design.design.ncols(),
1966            baseline_marginal: ms_result.baseline_marginal,
1967            baseline_logslope: ms_result.baseline_logslope,
1968            latent_z_normalization: SavedLatentZNormalization {
1969                mean: ms_result.z_normalization.mean,
1970                sd: ms_result.z_normalization.sd,
1971            },
1972            latent_measure: ms_result.latent_measure.clone(),
1973            latent_z_rank_int_calibration: ms_result.latent_z_rank_int_calibration.clone(),
1974            latent_z_conditional_calibration: ms_result.latent_z_conditional_calibration.clone(),
1975            score_warp_runtime: ms_result.score_warp_runtime.as_ref(),
1976            link_dev_runtime: ms_result.link_dev_runtime.as_ref(),
1977            base_link,
1978            frailty,
1979        },
1980        SavedModelSourceMetadata {
1981            training_headers: dataset.headers.clone(),
1982            // Every other adapter persists per-feature ranges; this arm alone
1983            // passed `None`, so Python-saved Bernoulli marginal-slope models
1984            // were the only ones that could not clip out-of-hull predict rows
1985            // (#2470).
1986            training_feature_ranges: Some(dataset.feature_ranges()),
1987            offset_column: fit_config.offset_column.clone(),
1988            noise_offset_column: fit_config.noise_offset_column.clone(),
1989        },
1990    )
1991}
1992
1993fn payload_for_survival_marginal_slope(
1994    formula: String,
1995    dataset: &EncodedDataset,
1996    fit_config: &FitConfig,
1997    frailty: crate::survival::lognormal_kernel::FrailtySpec,
1998    ms_result: SurvivalMarginalSlopeFitResult,
1999) -> Result<FittedModelPayload, String> {
2000    use crate::survival::construction::{
2001        build_survival_time_basis, parse_survival_baseline_config, parse_survival_likelihood_mode,
2002        parse_survival_time_basis_config, resolve_survival_time_anchor_for_mode,
2003        survival_likelihood_modename, survival_marginal_slope_offset_baseline_config,
2004    };
2005    use ndarray::s;
2006
2007    let frozen_marginal = freeze_term_collection_from_design(
2008        &ms_result.marginalspec_resolved,
2009        &ms_result.marginal_design,
2010    )
2011    .map_err(|err| format!("failed to freeze survival marginal spec: {err}"))?;
2012    let frozen_logslope = freeze_term_collection_from_design(
2013        &ms_result.logslopespec_resolved,
2014        &ms_result.logslope_design,
2015    )
2016    .map_err(|err| format!("failed to freeze survival logslope spec: {err}"))?;
2017
2018    let logslope_formula = fit_config
2019        .logslope_formula
2020        .clone()
2021        .unwrap_or_else(|| "same-as-main".to_string());
2022    let z_column = fit_config
2023        .z_column
2024        .clone()
2025        .ok_or_else(|| "survival marginal-slope requires z_column".to_string())?;
2026    let parsed = parse_formula(&formula)
2027        .map_err(|err| format!("failed to re-parse survival marginal formula: {err}"))?;
2028    let (entryname, exitname, eventname) = parse_surv_response(&parsed.response)?
2029        .ok_or_else(|| "survival marginal-slope FFI requires Surv(...) response".to_string())?;
2030    let col_map: HashMap<String, usize> = dataset
2031        .headers
2032        .iter()
2033        .enumerate()
2034        .map(|(i, h)| (h.clone(), i))
2035        .collect();
2036    // `entryname == None` is the right-censored shorthand `Surv(time, event)`:
2037    // entry times are synthesized as zero, no column lookup required.
2038    let entry_idx: Option<usize> = entryname
2039        .as_deref()
2040        .map(|name| {
2041            col_map
2042                .get(name)
2043                .copied()
2044                .ok_or_else(|| format!("entry column '{name}' not found"))
2045        })
2046        .transpose()?;
2047    let exit_idx = *col_map
2048        .get(&exitname)
2049        .ok_or_else(|| format!("exit column '{exitname}' not found"))?;
2050    let n = dataset.values.nrows();
2051    let mut age_entry = Array1::<f64>::zeros(n);
2052    let mut age_exit = Array1::<f64>::zeros(n);
2053    for i in 0..n {
2054        let entry_val = entry_idx.map_or(0.0, |idx| dataset.values[[i, idx]]);
2055        let (t0, t1) = crate::survival::construction::normalize_survival_time_pair(
2056            entry_val,
2057            dataset.values[[i, exit_idx]],
2058            i,
2059        )?;
2060        age_entry[i] = t0;
2061        age_exit[i] = t1;
2062    }
2063    let baseline_cfg = parse_survival_baseline_config(
2064        &fit_config.baseline_target,
2065        fit_config.baseline_scale,
2066        fit_config.baseline_shape,
2067        fit_config.baseline_rate,
2068        fit_config.baseline_makeham,
2069    )?;
2070    let likelihood_mode = parse_survival_likelihood_mode(fit_config.resolved_survival_likelihood())?;
2071    let time_cfg = if parsed.timewiggle.is_some() {
2072        crate::survival::construction::SurvivalTimeBasisConfig::None
2073    } else {
2074        parse_survival_time_basis_config(
2075            &fit_config.time_basis,
2076            fit_config.time_degree,
2077            fit_config.time_num_internal_knots,
2078            fit_config.time_smooth_lambda,
2079        )?
2080    };
2081    // Re-derivation, so it must ask the same question the fit asked — including
2082    // the caller's explicit anchor, which this site used to ignore, persisting
2083    // the median exit onto a model whose fit centered somewhere else (#2631).
2084    let time_anchor = resolve_survival_time_anchor_for_mode(
2085        likelihood_mode,
2086        &age_entry,
2087        &age_exit,
2088        fit_config.survival_time_anchor,
2089    )?;
2090    let time_build = build_survival_time_basis(
2091        &age_entry,
2092        &age_exit,
2093        time_cfg,
2094        Some((
2095            fit_config.time_num_internal_knots,
2096            fit_config.time_smooth_lambda,
2097        )),
2098    )?;
2099    let timewiggle = match (
2100        ms_result.time_wiggle_knots.as_ref(),
2101        ms_result.time_wiggle_degree,
2102        ms_result.time_wiggle_ncols,
2103    ) {
2104        (None, None, 0) => None,
2105        (Some(knots), Some(degree), ncols) if ncols > 0 => {
2106            let beta_time = &ms_result
2107                .fit
2108                .blocks
2109                .first()
2110                .ok_or_else(|| {
2111                    "survival marginal-slope FFI fit is missing its time block".to_string()
2112                })?
2113                .beta;
2114            let p_base = time_build.x_exit_time.ncols();
2115            if beta_time.len() != p_base + ncols {
2116                return Err(format!(
2117                    "survival marginal-slope FFI timewiggle width mismatch: time beta={}, base={p_base}, wiggle={ncols}",
2118                    beta_time.len(),
2119                ));
2120            }
2121            Some(SurvivalTimewiggle {
2122                degree,
2123                knots: knots.to_vec(),
2124                penalty_orders: parsed
2125                    .timewiggle
2126                    .as_ref()
2127                    .map(|config| config.penalty_orders.clone()),
2128                double_penalty: parsed
2129                    .timewiggle
2130                    .as_ref()
2131                    .map(|config| config.double_penalty),
2132                beta: SurvivalTimewiggleBeta::Single(beta_time.slice(s![p_base..]).to_vec()),
2133            })
2134        }
2135        _ => {
2136            return Err(
2137                "survival marginal-slope FFI fit has incomplete timewiggle authority".to_string(),
2138            );
2139        }
2140    };
2141    let saved_offset_baseline =
2142        survival_marginal_slope_offset_baseline_config(&age_exit, &baseline_cfg);
2143    let (persisted_rank_int, persisted_conditional) =
2144        ms_result.persisted_latent_z_calibrations()?;
2145
2146    // Thin adapter over the shared core assembler. The FFI's source-specific
2147    // work is re-deriving the survival response columns, baseline config, and
2148    // time basis from the formula + FitConfig and freezing its term collections
2149    // from their designs; the semantic payload is assembled by the same core
2150    // path the CLI uses, so the two save routes produce identical contracts.
2151    Ok(assemble_survival_marginal_slope_payload(
2152        SurvivalMarginalSlopeInputs {
2153            formula,
2154            data_schema: dataset.schema.clone(),
2155            fit_result: ms_result.fit.clone(),
2156            frailty,
2157            survival_entry: entryname,
2158            survival_exit: exitname,
2159            survival_event: eventname,
2160            survivalspec: "net".to_string(),
2161            baseline_cfg: saved_offset_baseline,
2162            time_basis: SavedSurvivalTimeBasis::from_build(&time_build, time_anchor),
2163            ridge_lambda: fit_config.ridge_lambda,
2164            survival_likelihood_label: survival_likelihood_modename(likelihood_mode).to_string(),
2165            resolved_marginalspec: frozen_marginal,
2166            resolved_logslopespec: frozen_logslope,
2167            logslope_time_basis: ms_result.logslope_time_basis.clone(),
2168            logslope_formula,
2169            z_column,
2170            latent_z_normalization: SavedLatentZNormalization {
2171                mean: ms_result.z_normalization.mean,
2172                sd: ms_result.z_normalization.sd,
2173            },
2174            latent_z_rank_int_calibration: persisted_rank_int,
2175            latent_z_conditional_calibration: persisted_conditional,
2176            baseline_logslope: ms_result.baseline_slope,
2177            timewiggle,
2178            score_warp_runtime: ms_result.score_warp_runtime.as_ref(),
2179            link_dev_runtime: ms_result.link_dev_runtime.as_ref(),
2180            influence_absorber_width: ms_result.influence_absorber_width,
2181            influence_absorber_design: ms_result.influence_absorber_design.as_ref(),
2182            score_covariance: ms_result.persistable_score_covariance()?,
2183        },
2184        SavedModelSourceMetadata {
2185            training_headers: dataset.headers.clone(),
2186            training_feature_ranges: Some(dataset.feature_ranges()),
2187            offset_column: fit_config.offset_column.clone(),
2188            noise_offset_column: fit_config.noise_offset_column.clone(),
2189        },
2190    ))
2191}
2192
2193fn payload_for_survival_transformation(
2194    formula: String,
2195    dataset: &EncodedDataset,
2196    fit_config: &FitConfig,
2197    rp_result: crate::fit_orchestration::SurvivalTransformationFitResult,
2198) -> Result<FittedModelPayload, String> {
2199    use crate::survival::construction::survival_likelihood_modename;
2200    use ndarray::s;
2201
2202    let parsed = parse_formula(&formula)
2203        .map_err(|err| format!("failed to re-parse survival transformation formula: {err}"))?;
2204    let (entryname, exitname, eventname) = parse_surv_response(&parsed.response)?
2205        .ok_or_else(|| "survival transformation FFI requires Surv(...) response".to_string())?;
2206    let likelihood_label = survival_likelihood_modename(rp_result.likelihood_mode).to_string();
2207
2208    let cause_count = rp_result.fit.blocks.len().max(1);
2209    let is_joint_cause_specific = cause_count > 1;
2210
2211    // Source-specific work: extract the baseline-timewiggle coefficients from
2212    // the differently-shaped fit struct (one block for net, one per cause for
2213    // joint cause-specific). The canonical payload is then assembled by the same
2214    // shared core the CLI uses.
2215    let timewiggle = rp_result
2216        .baseline_timewiggle
2217        .as_ref()
2218        .map(|timewiggle| -> Result<SurvivalTimewiggle, String> {
2219            let start = rp_result.time_base_ncols;
2220            let end = start + timewiggle.ncols;
2221            let beta = if is_joint_cause_specific {
2222                let mut by_cause = Vec::with_capacity(cause_count);
2223                for (cause_idx, block) in rp_result.fit.blocks.iter().enumerate() {
2224                    if block.beta.len() < end {
2225                        return Err(format!(
2226                            "joint cause-specific survival timewiggle beta mismatch for cause {}: beta has {}, needs {end}",
2227                            cause_idx + 1,
2228                            block.beta.len()
2229                        ));
2230                    }
2231                    by_cause.push(block.beta.slice(s![start..end]).to_vec());
2232                }
2233                SurvivalTimewiggleBeta::ByCause(by_cause)
2234            } else {
2235                let beta = &rp_result.fit.beta;
2236                if beta.len() < end {
2237                    return Err(format!(
2238                        "survival transformation timewiggle beta mismatch: beta has {}, needs {end}",
2239                        beta.len()
2240                    ));
2241                }
2242                SurvivalTimewiggleBeta::Single(beta.slice(s![start..end]).to_vec())
2243            };
2244            Ok(SurvivalTimewiggle {
2245                degree: timewiggle.degree,
2246                knots: timewiggle.knots.to_vec(),
2247                penalty_orders: parsed.timewiggle.as_ref().map(|cfg| cfg.penalty_orders.clone()),
2248                double_penalty: parsed.timewiggle.as_ref().map(|cfg| cfg.double_penalty),
2249                beta,
2250            })
2251        })
2252        .transpose()?;
2253
2254    let payload = assemble_survival_transformation_payload(
2255        SurvivalTransformationInputs {
2256            formula,
2257            data_schema: dataset.schema.clone(),
2258            fit_result: rp_result.fit.clone(),
2259            survival_entry: entryname,
2260            survival_exit: exitname,
2261            survival_event: eventname,
2262            survivalspec: if is_joint_cause_specific {
2263                "cause-specific".to_string()
2264            } else {
2265                "net".to_string()
2266            },
2267            cause_count: is_joint_cause_specific.then_some(cause_count),
2268            baseline_cfg: rp_result.baseline_cfg.clone(),
2269            time_basis: rp_result.time_basis.clone(),
2270            ridge_lambda: fit_config.ridge_lambda,
2271            survival_likelihood_label: likelihood_label,
2272            resolved_termspec: rp_result.resolvedspec,
2273            survival_beta_time: None,
2274            timewiggle,
2275        },
2276        SavedModelSourceMetadata {
2277            training_headers: dataset.headers.clone(),
2278            training_feature_ranges: Some(dataset.feature_ranges()),
2279            offset_column: fit_config.offset_column.clone(),
2280            noise_offset_column: None,
2281        },
2282    );
2283    Ok(payload)
2284}
2285
2286fn payload_for_gaussian_location_scale(
2287    formula: String,
2288    dataset: &EncodedDataset,
2289    fit_config: &FitConfig,
2290    ls_result: GaussianLocationScaleFitResult,
2291    response_scale: f64,
2292) -> Result<FittedModelPayload, String> {
2293    let frozen_meanspec = freeze_term_collection_from_design(
2294        &ls_result.fit.meanspec_resolved,
2295        &ls_result.fit.mean_design,
2296    )
2297    .map_err(|err| format!("failed to freeze gaussian location-scale mean spec: {err}"))?;
2298    let frozen_noisespec = freeze_term_collection_from_design(
2299        &ls_result.fit.noisespec_resolved,
2300        &ls_result.fit.noise_design,
2301    )
2302    .map_err(|err| format!("failed to freeze gaussian location-scale noise spec: {err}"))?;
2303
2304    let noise_formula = fit_config
2305        .noise_formula
2306        .clone()
2307        .ok_or_else(|| "gaussian location-scale requires noise_formula".to_string())?;
2308
2309    let fit = ls_result.fit.fit;
2310    let scale_beta = fit
2311        .block_by_role(BlockRole::Scale)
2312        .map(|block| block.beta.to_vec());
2313    let wiggle = location_scale_wiggle_from_parts(
2314        ls_result.wiggle_knots,
2315        ls_result.wiggle_degree,
2316        ls_result.beta_link_wiggle,
2317    );
2318
2319    // Thin adapter over the shared core assembler; the FFI freezes the mean and
2320    // noise specs from their designs and reads offset columns from the
2321    // FitConfig. See `assemble_location_scale_payload`.
2322    assemble_location_scale_payload(
2323        LocationScaleInputs {
2324            formula,
2325            data_schema: dataset.schema.clone(),
2326            noise_formula,
2327            resolved_termspec: frozen_meanspec,
2328            resolved_termspec_noise: frozen_noisespec,
2329            fit_result: fit,
2330            beta_noise: scale_beta,
2331            wiggle,
2332        },
2333        LocationScaleResponse::Gaussian {
2334            response_scale,
2335            base_link: None,
2336        },
2337        SavedModelSourceMetadata {
2338            training_headers: dataset.headers.clone(),
2339            training_feature_ranges: Some(dataset.feature_ranges()),
2340            offset_column: fit_config.offset_column.clone(),
2341            noise_offset_column: fit_config.noise_offset_column.clone(),
2342        },
2343    )
2344}
2345
2346/// Map the optional `(knots, degree, beta)` link-wiggle parts a location-scale
2347/// fit may produce into the shared [`LocationScaleWiggle`] form. All three are
2348/// present together or not at all.
2349fn location_scale_wiggle_from_parts(
2350    knots: Option<Array1<f64>>,
2351    degree: Option<usize>,
2352    beta_link_wiggle: Option<Vec<f64>>,
2353) -> Option<LocationScaleWiggle> {
2354    match (knots, degree, beta_link_wiggle) {
2355        (Some(knots), Some(degree), Some(beta_link_wiggle)) => Some(LocationScaleWiggle {
2356            knots: knots.to_vec(),
2357            degree,
2358            beta_link_wiggle,
2359        }),
2360        _ => None,
2361    }
2362}
2363
2364fn payload_for_binomial_location_scale(
2365    formula: String,
2366    dataset: &EncodedDataset,
2367    fit_config: &FitConfig,
2368    link_kind: InverseLink,
2369    weights: &Array1<f64>,
2370    ls_result: BinomialLocationScaleFitResult,
2371) -> Result<FittedModelPayload, String> {
2372    let frozen_meanspec = freeze_term_collection_from_design(
2373        &ls_result.fit.meanspec_resolved,
2374        &ls_result.fit.mean_design,
2375    )
2376    .map_err(|err| format!("failed to freeze binomial location-scale threshold spec: {err}"))?;
2377    let frozen_noisespec = freeze_term_collection_from_design(
2378        &ls_result.fit.noisespec_resolved,
2379        &ls_result.fit.noise_design,
2380    )
2381    .map_err(|err| format!("failed to freeze binomial location-scale noise spec: {err}"))?;
2382
2383    let noise_formula = fit_config
2384        .noise_formula
2385        .clone()
2386        .ok_or_else(|| "binomial location-scale requires noise_formula".to_string())?;
2387
2388    let dense_mean = ls_result
2389        .fit
2390        .mean_design
2391        .design
2392        .try_to_dense_by_chunks("binomial location-scale mean design")?;
2393    let dense_noise = ls_result
2394        .fit
2395        .noise_design
2396        .design
2397        .try_to_dense_by_chunks("binomial location-scale noise design")?;
2398    let non_intercept_start = ls_result
2399        .fit
2400        .noise_design
2401        .intercept_range
2402        .end
2403        .min(ls_result.fit.noise_design.design.ncols());
2404    let binomial_noise_transform =
2405        build_scale_deviation_transform(&dense_mean, &dense_noise, weights, non_intercept_start)
2406            .map_err(|err| format!("failed to encode binomial noise transform: {err}"))?;
2407
2408    let fit = ls_result.fit.fit;
2409    let scale_beta = fit
2410        .block_by_role(BlockRole::Scale)
2411        .map(|block| block.beta.to_vec());
2412    let wiggle = location_scale_wiggle_from_parts(
2413        ls_result.wiggle_knots,
2414        ls_result.wiggle_degree,
2415        ls_result.beta_link_wiggle,
2416    );
2417
2418    // Thin adapter over the shared core assembler; the FFI freezes the threshold
2419    // and noise specs from their designs, encodes the binomial noise
2420    // scale-deviation transform, and reads offset columns from the FitConfig.
2421    // See `assemble_location_scale_payload`.
2422    assemble_location_scale_payload(
2423        LocationScaleInputs {
2424            formula,
2425            data_schema: dataset.schema.clone(),
2426            noise_formula,
2427            resolved_termspec: frozen_meanspec,
2428            resolved_termspec_noise: frozen_noisespec,
2429            fit_result: fit,
2430            beta_noise: scale_beta,
2431            wiggle,
2432        },
2433        LocationScaleResponse::Binomial {
2434            link: link_kind,
2435            noise_transform: &binomial_noise_transform,
2436        },
2437        SavedModelSourceMetadata {
2438            training_headers: dataset.headers.clone(),
2439            training_feature_ranges: Some(dataset.feature_ranges()),
2440            offset_column: fit_config.offset_column.clone(),
2441            noise_offset_column: fit_config.noise_offset_column.clone(),
2442        },
2443    )
2444}
2445
2446/// Assemble the saved-model payload for a genuine-dispersion location-scale fit
2447/// (#913): NegativeBinomial / Gamma / Beta / Tweedie with a `noise_formula` on
2448/// the overdispersion channel. Mirrors the CLI dispersion save path
2449/// (`assemble_location_scale_payload` + `LocationScaleResponse::Dispersion`),
2450/// deriving the persisted likelihood and mean base-link from the single
2451/// source of truth on [`DispersionFamilyKind`]. The log-precision block
2452/// coefficients ride in `beta_noise`; there is no link-wiggle and no response
2453/// standardization for these families.
2454fn payload_for_dispersion_location_scale(
2455    formula: String,
2456    dataset: &EncodedDataset,
2457    fit_config: &FitConfig,
2458    kind: DispersionFamilyKind,
2459    ls_result: DispersionLocationScaleFitResult,
2460) -> Result<FittedModelPayload, String> {
2461    let frozen_meanspec = freeze_term_collection_from_design(
2462        &ls_result.fit.meanspec_resolved,
2463        &ls_result.fit.mean_design,
2464    )
2465    .map_err(|err| format!("failed to freeze dispersion location-scale mean spec: {err}"))?;
2466    let frozen_noisespec = freeze_term_collection_from_design(
2467        &ls_result.fit.noisespec_resolved,
2468        &ls_result.fit.noise_design,
2469    )
2470    .map_err(|err| format!("failed to freeze dispersion location-scale noise spec: {err}"))?;
2471
2472    let noise_formula = fit_config
2473        .noise_formula
2474        .clone()
2475        .ok_or_else(|| "dispersion location-scale requires noise_formula".to_string())?;
2476
2477    let fit = ls_result.fit.fit;
2478    let scale_beta = fit
2479        .block_by_role(BlockRole::Scale)
2480        .map(|block| block.beta.to_vec());
2481
2482    assemble_location_scale_payload(
2483        LocationScaleInputs {
2484            formula,
2485            data_schema: dataset.schema.clone(),
2486            noise_formula,
2487            resolved_termspec: frozen_meanspec,
2488            resolved_termspec_noise: frozen_noisespec,
2489            fit_result: fit,
2490            beta_noise: scale_beta,
2491            wiggle: None,
2492        },
2493        LocationScaleResponse::Dispersion {
2494            likelihood: kind.likelihood_spec(),
2495            base_link: kind.base_link(),
2496            family_tag: kind.family_tag(),
2497        },
2498        SavedModelSourceMetadata {
2499            training_headers: dataset.headers.clone(),
2500            training_feature_ranges: Some(dataset.feature_ranges()),
2501            offset_column: fit_config.offset_column.clone(),
2502            noise_offset_column: fit_config.noise_offset_column.clone(),
2503        },
2504    )
2505}
2506
2507fn payload_for_survival_location_scale(
2508    formula: String,
2509    dataset: &EncodedDataset,
2510    fit_config: &FitConfig,
2511    ls_result: crate::fit_orchestration::SurvivalLocationScaleFitResult,
2512    time_basis: Option<SavedSurvivalTimeBasis>,
2513) -> Result<FittedModelPayload, String> {
2514    use crate::survival::construction::{
2515        parse_survival_baseline_config, parse_survival_likelihood_mode,
2516        survival_likelihood_modename,
2517    };
2518    // The time basis is CARRIED from the materialization that produced this fit
2519    // (#2470). It is not re-derived here: `materialize_survival` switches the
2520    // time anchor to the robust interior exit time whenever the data is left
2521    // truncated, and the re-derivation this replaced always took the
2522    // earliest-entry anchor — so a left-truncated model persisted an anchor its
2523    // own fit never centered at, and predict then re-centered the design in a
2524    // different affine frame than the coefficients were fitted in.
2525    let time_basis = time_basis.ok_or_else(|| {
2526        "survival location-scale payload requires the materialized survival time basis".to_string()
2527    })?;
2528    let parsed = parse_formula(&formula)
2529        .map_err(|err| format!("failed to re-parse survival formula for FFI payload: {err}"))?;
2530    let (entryname, exitname, eventname) = parse_surv_response(&parsed.response)?
2531        .ok_or_else(|| "survival location-scale FFI requires Surv(...) response".to_string())?;
2532    let baseline_cfg = parse_survival_baseline_config(
2533        &fit_config.baseline_target,
2534        fit_config.baseline_scale,
2535        fit_config.baseline_shape,
2536        fit_config.baseline_rate,
2537        fit_config.baseline_makeham,
2538    )?;
2539    let likelihood_mode = parse_survival_likelihood_mode(fit_config.resolved_survival_likelihood())?;
2540
2541    let fitted_inverse_link = ls_result.inverse_link.clone();
2542    // Compact the inner UnifiedFitResult and apply the fitted link state so
2543    // downstream prediction can recover the inverse-link parameters from the
2544    // saved fit_result. Mirrors the CLI's
2545    // compact_saved_survival_location_scale_fit_result helper.
2546    let mut fit_result = ls_result.fit.fit.clone();
2547    apply_inverse_link_state_to_fit_result(&mut fit_result, &fitted_inverse_link);
2548    fit_result.artifacts.survival_link_wiggle_knots = ls_result.wiggle_knots.clone();
2549    fit_result.artifacts.survival_link_wiggle_degree = ls_result.wiggle_degree;
2550
2551    let resolved_thresholdspec = freeze_term_collection_from_design(
2552        &ls_result.fit.resolved_thresholdspec,
2553        &ls_result.fit.threshold_design,
2554    )
2555    .map_err(|err| err.to_string())?;
2556    let resolved_log_sigmaspec = freeze_term_collection_from_design(
2557        &ls_result.fit.resolved_log_sigmaspec,
2558        &ls_result.fit.log_sigma_design,
2559    )
2560    .map_err(|err| err.to_string())?;
2561
2562    // Thin adapter over the shared core assembler. The FFI's source-specific
2563    // work above re-derives the survival metadata and compacts the fit result
2564    // with the fitted link state; the canonical payload is assembled by the
2565    // same path the CLI uses.
2566    Ok(assemble_survival_location_scale_payload(
2567        SurvivalLocationScaleInputs {
2568            formula,
2569            data_schema: dataset.schema.clone(),
2570            fit_result,
2571            fitted_inverse_link: fitted_inverse_link.clone(),
2572            linkwiggle_degree: ls_result.wiggle_degree,
2573            linkwiggle_knots: ls_result.wiggle_knots.as_ref().map(|k| k.to_vec()),
2574            beta_link_wiggle: ls_result
2575                .fit
2576                .fit
2577                .beta_link_wiggle()
2578                .as_ref()
2579                .map(|b| b.to_vec()),
2580            baseline_timewiggle: None,
2581            survival_entry: entryname,
2582            survival_exit: exitname,
2583            survival_event: eventname,
2584            survivalspec: "net".to_string(),
2585            baseline_cfg,
2586            time_basis,
2587            ridge_lambda: fit_config.ridge_lambda,
2588            survival_likelihood_label: survival_likelihood_modename(likelihood_mode).to_string(),
2589            time_parameterization: ls_result.fit.time_parameterization,
2590            threshold_time_basis: ls_result.fit.threshold_time_basis.clone(),
2591            log_sigma_time_basis: ls_result.fit.log_sigma_time_basis.clone(),
2592            formula_noise: None,
2593            survival_beta_time: ls_result.fit.fit.beta_time().to_vec(),
2594            survival_beta_threshold: ls_result.fit.fit.beta_threshold().to_vec(),
2595            survival_beta_log_sigma: ls_result.fit.fit.beta_log_sigma().to_vec(),
2596            resolved_thresholdspec,
2597            resolved_log_sigmaspec,
2598        },
2599        SavedModelSourceMetadata {
2600            training_headers: dataset.headers.clone(),
2601            training_feature_ranges: Some(dataset.feature_ranges()),
2602            offset_column: fit_config.offset_column.clone(),
2603            noise_offset_column: fit_config.noise_offset_column.clone(),
2604        },
2605    ))
2606}
2607
2608fn payload_for_latent_survival(
2609    formula: String,
2610    dataset: &EncodedDataset,
2611    fit_config: &FitConfig,
2612    request_frailty: crate::survival::lognormal_kernel::FrailtySpec,
2613    lat_result: crate::survival::latent::LatentSurvivalTermFitResult,
2614    time_basis: Option<SavedSurvivalTimeBasis>,
2615) -> Result<FittedModelPayload, String> {
2616    payload_for_latent_window(
2617        formula,
2618        dataset,
2619        fit_config,
2620        request_frailty,
2621        lat_result.fit,
2622        lat_result.resolvedspec,
2623        lat_result.design,
2624        Some(lat_result.latent_sd),
2625        true,
2626        time_basis,
2627    )
2628}
2629
2630fn payload_for_latent_binary(
2631    formula: String,
2632    dataset: &EncodedDataset,
2633    fit_config: &FitConfig,
2634    request_frailty: crate::survival::lognormal_kernel::FrailtySpec,
2635    lat_result: crate::survival::latent::LatentBinaryTermFitResult,
2636    time_basis: Option<SavedSurvivalTimeBasis>,
2637) -> Result<FittedModelPayload, String> {
2638    payload_for_latent_window(
2639        formula,
2640        dataset,
2641        fit_config,
2642        request_frailty,
2643        lat_result.fit,
2644        lat_result.resolvedspec,
2645        lat_result.design,
2646        None,
2647        false,
2648        time_basis,
2649    )
2650}
2651
2652fn payload_for_latent_window(
2653    formula: String,
2654    dataset: &EncodedDataset,
2655    fit_config: &FitConfig,
2656    request_frailty: crate::survival::lognormal_kernel::FrailtySpec,
2657    fit: UnifiedFitResult,
2658    resolvedspec: TermCollectionSpec,
2659    cov_design: TermCollectionDesign,
2660    learned_latent_sd: Option<f64>,
2661    is_survival: bool,
2662    time_basis: Option<SavedSurvivalTimeBasis>,
2663) -> Result<FittedModelPayload, String> {
2664    use crate::survival::construction::parse_survival_baseline_config;
2665
2666    // Carried from the materialization that produced this fit, not re-derived
2667    // (#2470) — see `payload_for_survival_location_scale` for the anchor
2668    // divergence this closes.
2669    let time_basis = time_basis.ok_or_else(|| {
2670        "latent survival/binary payload requires the materialized survival time basis".to_string()
2671    })?;
2672    let parsed = parse_formula(&formula).map_err(|err| {
2673        format!("failed to re-parse latent survival formula for FFI payload: {err}")
2674    })?;
2675    let (entryname, exitname, eventname) = parse_surv_response(&parsed.response)?
2676        .ok_or_else(|| "latent survival/binary FFI requires Surv(...) response".to_string())?;
2677    let baseline_cfg = parse_survival_baseline_config(
2678        &fit_config.baseline_target,
2679        fit_config.baseline_scale,
2680        fit_config.baseline_shape,
2681        fit_config.baseline_rate,
2682        fit_config.baseline_makeham,
2683    )?;
2684
2685    // For latent survival, splice the fitted latent_sd into the persisted
2686    // HazardMultiplier frailty (mirrors CLI behaviour at main.rs:5541).
2687    let saved_family = if is_survival {
2688        let frailty = match (&request_frailty, learned_latent_sd) {
2689            (
2690                crate::survival::lognormal_kernel::FrailtySpec::HazardMultiplier {
2691                    scale: crate::survival::lognormal_kernel::FrailtyScale::Learned { .. },
2692                    loading,
2693                },
2694                Some(sigma),
2695            ) => crate::survival::lognormal_kernel::FrailtySpec::HazardMultiplier {
2696                scale: crate::survival::lognormal_kernel::FrailtyScale::Fixed { sigma },
2697                loading: *loading,
2698            },
2699            _ => request_frailty.clone(),
2700        };
2701        FittedFamily::LatentSurvival { frailty }
2702    } else {
2703        FittedFamily::LatentBinary {
2704            frailty: request_frailty.clone(),
2705        }
2706    };
2707    let model_class_label = if is_survival {
2708        "latent-survival".to_string()
2709    } else {
2710        "latent-binary".to_string()
2711    };
2712    let likelihood_label = if is_survival {
2713        "latent".to_string()
2714    } else {
2715        "latent-binary".to_string()
2716    };
2717
2718    let beta_time = fit.beta_time().to_vec();
2719    let resolved_termspec = freeze_term_collection_from_design(&resolvedspec, &cov_design)
2720        .map_err(|err| err.to_string())?;
2721
2722    Ok(assemble_latent_window_payload(
2723        LatentWindowInputs {
2724            formula,
2725            data_schema: dataset.schema.clone(),
2726            fit_result: fit,
2727            family: saved_family,
2728            model_class_label,
2729            likelihood_label,
2730            survival_entry: entryname,
2731            survival_exit: exitname,
2732            survival_event: eventname,
2733            baseline_cfg,
2734            time_basis,
2735            ridge_lambda: fit_config.ridge_lambda,
2736            beta_time,
2737            resolved_termspec,
2738        },
2739        SavedModelSourceMetadata {
2740            training_headers: dataset.headers.clone(),
2741            training_feature_ranges: Some(dataset.feature_ranges()),
2742            offset_column: fit_config.offset_column.clone(),
2743            noise_offset_column: fit_config.noise_offset_column.clone(),
2744        },
2745    ))
2746}
2747
2748#[cfg(test)]
2749mod apply_timewiggle_beta_tests {
2750    use super::*;
2751
2752    /// Minimal payload with both baseline-timewiggle slots unset. Uses the
2753    /// fixture-free `LatentBinary` family so the test needs no `LikelihoodSpec`.
2754    fn empty_payload() -> FittedModelPayload {
2755        FittedModelPayload::new(
2756            MODEL_PAYLOAD_VERSION,
2757            "y ~ 1".to_string(),
2758            ModelKind::Survival,
2759            FittedFamily::LatentBinary {
2760                frailty: crate::survival::lognormal_kernel::FrailtySpec::None,
2761            },
2762            "test".to_string(),
2763        )
2764    }
2765
2766    #[test]
2767    fn by_cause_beta_populates_only_the_by_cause_slot() {
2768        let mut payload = empty_payload();
2769        apply_timewiggle_beta(
2770            &mut payload,
2771            SurvivalTimewiggleBeta::ByCause(vec![vec![1.0, 2.0], vec![3.0]]),
2772        );
2773        assert_eq!(
2774            payload.beta_baseline_timewiggle_by_cause,
2775            Some(vec![vec![1.0, 2.0], vec![3.0]]),
2776            "ByCause coefficients must land in the by-cause slot (regression: the \
2777             location-scale assembler used to silently drop them)"
2778        );
2779        assert!(
2780            payload.beta_baseline_timewiggle.is_none(),
2781            "ByCause must not populate the single-block slot"
2782        );
2783    }
2784
2785    #[test]
2786    fn single_beta_populates_only_the_flat_slot() {
2787        let mut payload = empty_payload();
2788        apply_timewiggle_beta(&mut payload, SurvivalTimewiggleBeta::Single(vec![4.0, 5.0]));
2789        assert_eq!(payload.beta_baseline_timewiggle, Some(vec![4.0, 5.0]));
2790        assert!(payload.beta_baseline_timewiggle_by_cause.is_none());
2791    }
2792}