1use 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
66const FAMILY_BERNOULLI_MARGINAL_SLOPE: &str = "bernoulli-marginal-slope";
68
69const FAMILY_TRANSFORMATION_NORMAL: &str = "transformation-normal";
71
72pub 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
139pub 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
164pub 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 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 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 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
393pub 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
490pub 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 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
529fn 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 return Ok(fit_result);
572 }
573 let p_influence = widened_len - p_marginal;
574
575 let pirls_status = fit_result.convergence_evidence().inner_status();
579 let training_sample_size = fit_result.training_sample_size();
580 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 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 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 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 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
681pub 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
716pub 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
753pub 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 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
828pub 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
843pub 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 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
904fn 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
930pub enum LocationScaleResponse<'a> {
936 Gaussian {
939 response_scale: f64,
940 base_link: Option<InverseLink>,
941 },
942 Binomial {
944 link: InverseLink,
945 noise_transform: &'a ScaleDeviationTransform,
946 },
947 Dispersion {
953 likelihood: LikelihoodSpec,
954 base_link: InverseLink,
955 family_tag: &'static str,
956 },
957}
958
959pub struct LocationScaleWiggle {
964 pub knots: Vec<f64>,
965 pub degree: usize,
966 pub beta_link_wiggle: Vec<f64>,
967}
968
969pub 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
983pub 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 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
1084pub 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 pub logslope_time_basis: Option<SurvivalCovariateTimeBasis>,
1110 pub logslope_formula: String,
1111 pub z_column: String,
1112 pub latent_z_normalization: SavedLatentZNormalization,
1113 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
1118 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
1119 pub baseline_logslope: f64,
1120 pub timewiggle: Option<SurvivalTimewiggle>,
1122 pub score_warp_runtime: Option<&'a DeviationRuntime>,
1123 pub link_dev_runtime: Option<&'a DeviationRuntime>,
1124 pub influence_absorber_width: Option<usize>,
1129 pub influence_absorber_design: Option<&'a Array2<f64>>,
1130 pub score_covariance: &'a Array2<f64>,
1131}
1132
1133fn 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
1169pub 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 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
1248pub enum SurvivalTimewiggleBeta {
1251 Single(Vec<f64>),
1252 ByCause(Vec<Vec<f64>>),
1253}
1254
1255fn 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
1271pub 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
1280pub 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 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 pub survival_beta_time: Option<Vec<f64>>,
1300 pub timewiggle: Option<SurvivalTimewiggle>,
1301}
1302
1303pub 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
1351pub struct SurvivalLocationScaleInputs {
1356 pub formula: String,
1357 pub data_schema: DataSchema,
1358 pub fit_result: UnifiedFitResult,
1361 pub fitted_inverse_link: InverseLink,
1362 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
1387pub 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
1443pub 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
1463pub 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
1497pub 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
1521pub fn fit_formula_to_payload(
1526 formula: String,
1527 dataset: &EncodedDataset,
1528 fit_config: &FitConfig,
1529) -> Result<FittedModelPayload, WorkflowError> {
1530 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 apply_request_metadata(&mut payload, fit_config, Vec::new());
1549 return Ok(payload);
1550 }
1551 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 let survival_time_basis = materialized.survival_time_basis;
1567 let mut inference_notes = materialized.inference_notes;
1575
1576 let mut payload = match request {
1577 FitRequest::Standard(standard_request) => {
1578 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 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 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 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 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 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 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 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 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 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 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 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 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
2346fn 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 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
2446fn 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 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 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 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 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 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 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}