1use crate::bms::deviation_runtime::AnchorComponentTag;
19use crate::bms::{
20 DeviationRuntime, LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
21};
22use crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL;
23use crate::fit_orchestration::drivers::freeze_term_collection_from_design;
24use crate::fit_orchestration::{FitConfig, StandardFitResult, expectile_tau_for_config};
25use crate::inference::model::{
26 FittedEstimator, FittedFamily, FittedModelPayload, MODEL_PAYLOAD_VERSION, ModelKind,
27 SavedAnchorComponent, SavedAnchorKind, SavedCompiledFlexBlock, SavedLatentZNormalization,
28 SavedResidualCascade, SavedSplineScan, SavedSurvivalLocationScaleStructure,
29 SavedTransformationNormalGeometry, TransformationNormalParameterization,
30 TransformationScoreCalibration,
31};
32use crate::scale_design::ScaleDeviationTransform;
33use crate::survival::construction::{
34 SavedSurvivalTimeBasis, SurvivalBaselineConfig, survival_baseline_targetname,
35};
36use crate::survival::location_scale::{
37 ResidualDistribution, SurvivalCovariateTimeBasis, SurvivalLocationScaleTimeParameterization,
38 residual_distribution_from_inverse_link,
39};
40use crate::transformation_normal::TransformationNormalFamily;
41use faer::Side;
42use gam_data::{DataSchema, EncodedDataset};
43use gam_linalg::faer_ndarray::{FaerCholesky, array2_to_nested_vec};
44use gam_problem::types::{
45 InverseLink, LikelihoodSpec, ResponseFamily, StandardLink, inverse_link_to_binomial_spec,
46};
47use gam_solve::estimate::{
48 FittedLinkState, UnifiedFitResult, saved_latent_cloglog_state_from_fit,
49 saved_mixture_state_from_fit, saved_sas_state_from_fit,
50};
51use gam_terms::smooth::{TermCollectionDesign, TermCollectionSpec};
52use ndarray::{Array1, Array2, s};
53
54const FAMILY_BERNOULLI_MARGINAL_SLOPE: &str = "bernoulli-marginal-slope";
56
57const FAMILY_TRANSFORMATION_NORMAL: &str = "transformation-normal";
59
60pub fn serialize_anchored_deviation_runtime(runtime: &DeviationRuntime) -> SavedCompiledFlexBlock {
67 let mut anchor_correction: Option<Vec<Vec<f64>>> = None;
68 let mut anchor_components: Vec<SavedAnchorComponent> = Vec::new();
69 if let Some(installed) = runtime.installed_flex_block() {
70 anchor_correction = Some(
71 installed
72 .anchor_correction
73 .rows()
74 .into_iter()
75 .map(|row| row.to_vec())
76 .collect::<Vec<Vec<f64>>>(),
77 );
78 for component in &installed.anchor_components {
79 anchor_components.push(SavedAnchorComponent {
80 kind: match component {
81 AnchorComponentTag::Parametric { block, ncols } => {
82 SavedAnchorKind::Parametric {
83 block: *block,
84 ncols: *ncols,
85 }
86 }
87 AnchorComponentTag::FlexEvaluation { ncols } => {
88 SavedAnchorKind::FlexEvaluation { ncols: *ncols }
89 }
90 },
91 });
92 }
93 }
94 SavedCompiledFlexBlock {
95 kernel: ANCHORED_DEVIATION_KERNEL.to_string(),
96 breakpoints: runtime.breakpoints().to_vec(),
97 basis_dim: runtime.basis_dim(),
98 span_c0: runtime
99 .span_c0()
100 .rows()
101 .into_iter()
102 .map(|row| row.to_vec())
103 .collect(),
104 span_c1: runtime
105 .span_c1()
106 .rows()
107 .into_iter()
108 .map(|row| row.to_vec())
109 .collect(),
110 span_c2: runtime
111 .span_c2()
112 .rows()
113 .into_iter()
114 .map(|row| row.to_vec())
115 .collect(),
116 span_c3: runtime
117 .span_c3()
118 .rows()
119 .into_iter()
120 .map(|row| row.to_vec())
121 .collect(),
122 anchor_correction,
123 anchor_components,
124 }
125}
126
127pub struct SavedModelSourceMetadata {
135 pub training_headers: Vec<String>,
136 pub training_feature_ranges: Option<Vec<(f64, f64)>>,
137 pub offset_column: Option<String>,
138 pub noise_offset_column: Option<String>,
139}
140
141impl SavedModelSourceMetadata {
142 fn apply_to(self, payload: &mut FittedModelPayload) {
143 match self.training_feature_ranges {
144 Some(ranges) => payload.set_training_feature_metadata(self.training_headers, ranges),
145 None => payload.training_headers = Some(self.training_headers),
146 }
147 payload.offset_column = self.offset_column;
148 payload.noise_offset_column = self.noise_offset_column;
149 }
150}
151
152pub struct StandardPayloadInputs<'a> {
159 pub formula: String,
160 pub dataset: &'a EncodedDataset,
161 pub fit_config: &'a FitConfig,
162 pub result: StandardFitResult,
163}
164
165fn fitted_inverse_link(state: &FittedLinkState) -> Option<InverseLink> {
166 match state {
167 FittedLinkState::Standard(Some(link)) => Some(InverseLink::Standard(*link)),
168 FittedLinkState::Standard(None) => None,
169 FittedLinkState::LatentCLogLog { state } => Some(InverseLink::LatentCLogLog(*state)),
170 FittedLinkState::Sas { state, .. } => Some(InverseLink::Sas(*state)),
171 FittedLinkState::BetaLogistic { state, .. } => Some(InverseLink::BetaLogistic(*state)),
172 FittedLinkState::Mixture { state, .. } => Some(InverseLink::Mixture(state.clone())),
173 }
174}
175
176fn standard_null_space_metadata(
177 design: &TermCollectionDesign,
178 fit: &UnifiedFitResult,
179) -> Result<(usize, f64), String> {
180 let hessian = fit
181 .penalized_hessian()
182 .ok_or_else(|| "null-space Hessian logdet requires fitted penalized Hessian".to_string())?;
183 let hessian_dim = hessian.nrows();
184 if hessian.ncols() != hessian_dim {
185 return Err(format!(
186 "null-space Hessian logdet requires a square Hessian, got {}x{}",
187 hessian.nrows(),
188 hessian.ncols()
189 ));
190 }
191 let p = design.design.ncols();
192 if hessian_dim < p {
193 return Err(format!(
194 "null-space Hessian logdet design/Hessian mismatch: design has {p} columns but \
195 Hessian is only {hessian_dim}x{hessian_dim}"
196 ));
197 }
198 if design.penalties.is_empty() {
199 return Ok((0, 0.0));
200 }
201 let hessian = if hessian_dim > p {
202 hessian.slice(s![0..p, 0..p]).to_owned()
203 } else {
204 hessian.clone()
205 };
206 let mut penalty = Array2::<f64>::zeros((p, p));
207 for (idx, block) in design.penalties.iter().enumerate() {
208 let range = block.col_range.clone();
209 if range.start > range.end
210 || range.end > p
211 || block.local.nrows() != range.len()
212 || block.local.ncols() != range.len()
213 {
214 return Err(format!(
215 "null-space Hessian logdet penalty {idx} shape mismatch: range {}..{}, local {}x{}, p={p}",
216 range.start,
217 range.end,
218 block.local.nrows(),
219 block.local.ncols()
220 ));
221 }
222 penalty
223 .slice_mut(s![range.clone(), range])
224 .scaled_add(1.0, &block.local);
225 }
226 let (null_basis, _) = gam_linalg::faer_ndarray::rrqr_nullspace_basis(
227 &penalty,
228 gam_linalg::faer_ndarray::default_rrqr_rank_alpha(),
229 )
230 .map_err(|err| format!("failed to compute penalty null-space basis: {err}"))?;
231 let q = null_basis.ncols();
232 if q == 0 {
233 return Ok((0, 0.0));
234 }
235 let projected = hessian.dot(&null_basis);
236 let mut restricted = null_basis.t().dot(&projected);
237 restricted = (&restricted + &restricted.t()) * 0.5;
238 let chol = restricted
239 .cholesky(Side::Lower)
240 .map_err(|err| format!("null-space Hessian is not positive definite: {err}"))?;
241 let logdet = 2.0 * chol.diag().iter().map(|value| value.ln()).sum::<f64>();
242 if logdet.is_finite() {
243 Ok((q, logdet))
244 } else {
245 Err(format!("null-space Hessian logdet is not finite: {logdet}"))
246 }
247}
248
249fn response_for_standard_payload(formula: &str, dataset: &EncodedDataset) -> Option<Array1<f64>> {
250 let response = gam_terms::inference::formula_dsl::parse_formula(formula)
251 .ok()?
252 .response;
253 let column = *dataset.column_map().get(&response)?;
254 Some(dataset.values.column(column).to_owned())
255}
256
257fn standard_conformal_substrates(
258 formula: &str,
259 dataset: &EncodedDataset,
260 fit_config: &FitConfig,
261 family: &LikelihoodSpec,
262 fit: &UnifiedFitResult,
263 design: &TermCollectionDesign,
264) -> (
265 Option<crate::inference::full_conformal::GaussianJackknifePlusStats>,
266 Option<crate::inference::full_conformal::ExactFullConformalSubstrate>,
267) {
268 let expectile = fit_config.family.as_deref().is_some_and(|family| {
269 let family = family.trim().to_ascii_lowercase();
270 family == "expectile" || family.starts_with("expectile(")
271 });
272 if expectile
273 || !family.is_gaussian_identity()
274 || fit_config.weight_column.is_some()
275 || fit_config.offset_column.is_some()
276 || fit_config.flexible_link
277 || design.affine_offset.iter().any(|value| *value != 0.0)
278 {
279 return (None, None);
280 }
281 let Some(y) = response_for_standard_payload(formula, dataset) else {
282 return (None, None);
283 };
284 let Ok(x) = design.design.try_to_dense_arc("standard conformal design") else {
285 return (None, None);
286 };
287 let Some(normal_matrix) = fit.penalized_hessian() else {
288 return (None, None);
289 };
290 if x.nrows() != y.len()
291 || normal_matrix.nrows() != x.ncols()
292 || normal_matrix.ncols() != x.ncols()
293 {
294 return (None, None);
295 }
296 let weights = Array1::<f64>::ones(y.len());
297 let jackknife = crate::inference::full_conformal::GaussianJackknifePlusStats::from_design_unit_weight_normal_matrix(
298 x.as_ref(),
299 &y,
300 &weights,
301 normal_matrix,
302 )
303 .ok();
304 let full = crate::inference::full_conformal::ExactFullConformalSubstrate::from_design_unit_weight_normal_matrix(
305 x.as_ref(),
306 &y,
307 &weights,
308 normal_matrix,
309 )
310 .ok();
311 (jackknife, full)
312}
313
314pub fn assemble_standard_payload(
316 inputs: StandardPayloadInputs<'_>,
317) -> Result<FittedModelPayload, String> {
318 let StandardPayloadInputs {
319 formula,
320 dataset,
321 fit_config,
322 result,
323 } = inputs;
324 let StandardFitResult {
325 mut fit,
326 design,
327 resolvedspec,
328 adaptive_diagnostics,
329 saved_link_state,
330 wiggle_knots,
331 wiggle_degree,
332 wiggle_penalty_metadata,
333 wiggle_saved_warp_beta,
334 wiggle_saved_index_shift,
335 ..
336 } = result;
337 fit.fitted_link = saved_link_state;
338 let resolved_termspec = freeze_term_collection_from_design(&resolvedspec, &design)
339 .map_err(|err| format!("failed to freeze standard term specification: {err}"))?;
340 let (null_space_dim, null_space_logdet) = standard_null_space_metadata(&design, &fit)?;
341 fit.artifacts.null_space_dim = Some(null_space_dim);
342 fit.artifacts.null_space_logdet = Some(null_space_logdet);
343 let family = fit
344 .likelihood_family
345 .clone()
346 .unwrap_or_else(LikelihoodSpec::gaussian_identity);
347 let estimator = expectile_tau_for_config(fit_config)
348 .map_err(|error| format!("failed to persist estimator metadata: {error}"))?
349 .map_or(FittedEstimator::Likelihood, |tau| {
350 FittedEstimator::Expectile { tau }
351 });
352 let family_label = match estimator {
353 FittedEstimator::Likelihood => family.name().to_string(),
354 FittedEstimator::Expectile { tau } => format!("expectile({tau})"),
355 };
356 let (gaussian_jackknife_plus, full_conformal) =
357 standard_conformal_substrates(&formula, dataset, fit_config, &family, &fit, &design);
358 let latent_cloglog_state = if family.is_latent_cloglog() {
359 Some(saved_latent_cloglog_state_from_fit(&fit).ok_or_else(|| {
360 "latent-cloglog-binomial fit did not produce a fitted latent-cloglog state".to_string()
361 })?)
362 } else {
363 saved_latent_cloglog_state_from_fit(&fit)
364 };
365 let mut payload = FittedModelPayload::new(
366 MODEL_PAYLOAD_VERSION,
367 formula,
368 ModelKind::Standard,
369 FittedFamily::Standard {
370 likelihood: family.clone(),
371 link: StandardLink::try_from(family.link_function()).ok(),
372 latent_cloglog_state,
373 mixture_state: saved_mixture_state_from_fit(&fit),
374 sas_state: saved_sas_state_from_fit(&fit),
375 },
376 family_label,
377 );
378 payload.estimator = estimator;
379 payload.unified = Some(fit.clone());
380 payload.fit_result = Some(fit.clone());
381 payload.data_schema = Some(dataset.schema.clone());
382 payload.link = fitted_inverse_link(&fit.fitted_link).or_else(|| Some(family.link.clone()));
383 payload.linkwiggle_knots = wiggle_knots.map(|knots| knots.to_vec());
384 payload.linkwiggle_degree = wiggle_degree;
385 payload.linkwiggle_penalty_metadata = wiggle_penalty_metadata;
386 payload.beta_link_wiggle = wiggle_saved_warp_beta;
387 payload.link_wiggle_index_shift = wiggle_saved_index_shift;
388 match &fit.fitted_link {
389 FittedLinkState::Mixture { covariance, .. } => {
390 payload.mixture_link_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
391 }
392 FittedLinkState::Sas { covariance, .. }
393 | FittedLinkState::BetaLogistic { covariance, .. } => {
394 payload.sas_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
395 }
396 FittedLinkState::Standard(_) | FittedLinkState::LatentCLogLog { .. } => {}
397 }
398 payload.set_training_feature_metadata(dataset.headers.clone(), dataset.feature_ranges());
399 payload.resolved_termspec = Some(resolved_termspec);
400 payload.adaptive_regularization_diagnostics = adaptive_diagnostics;
401 payload.offset_column = fit_config.offset_column.clone();
402 payload.noise_offset_column = fit_config.noise_offset_column.clone();
403 payload.weight_column = fit_config.weight_column.clone();
404 payload.gaussian_jackknife_plus = gaussian_jackknife_plus;
405 payload.full_conformal = full_conformal;
406 Ok(payload)
407}
408
409pub struct BernoulliMarginalSlopeInputs<'a> {
416 pub formula: String,
417 pub data_schema: DataSchema,
418 pub logslope_formula: String,
419 pub z_column: String,
420 pub resolved_marginalspec: TermCollectionSpec,
421 pub resolved_logslopespec: TermCollectionSpec,
422 pub fit_result: UnifiedFitResult,
423 pub p_marginal: usize,
436 pub baseline_marginal: f64,
437 pub baseline_logslope: f64,
438 pub latent_z_normalization: SavedLatentZNormalization,
439 pub latent_measure: LatentMeasureKind,
440 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
441 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
442 pub score_warp_runtime: Option<&'a DeviationRuntime>,
443 pub link_dev_runtime: Option<&'a DeviationRuntime>,
444 pub base_link: InverseLink,
445 pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
446}
447
448fn truncate_marginal_slope_influence_absorber(
481 fit_result: UnifiedFitResult,
482 p_marginal: usize,
483) -> Result<UnifiedFitResult, String> {
484 let Some(block0) = fit_result.blocks.first() else {
485 return Err("marginal-slope fit result has no coefficient blocks".to_string());
486 };
487 let widened_len = block0.beta.len();
488 if widened_len <= p_marginal {
489 return Ok(fit_result);
491 }
492 let p_influence = widened_len - p_marginal;
493
494 let pirls_status = fit_result.convergence_evidence().inner_status();
498 let UnifiedFitResult {
499 mut blocks,
500 log_lambdas,
501 lambdas,
502 likelihood_family,
503 likelihood_scale,
504 log_likelihood_normalization,
505 log_likelihood,
506 deviance,
507 reml_score,
508 stable_penalty_term,
509 penalized_objective,
510 used_device,
511 outer_iterations,
512 outer_gradient_norm,
513 standard_deviation,
514 covariance_conditional,
515 covariance_corrected,
516 inference,
517 fitted_link,
518 geometry: _,
519 mut block_states,
520 beta: _,
521 max_abs_eta,
522 constraint_kkt,
523 artifacts,
524 inner_cycles,
525 outer_cost_evals: _,
526 inner_pirls_solves: _,
527 ..
528 } = fit_result;
529
530 blocks[0].beta = blocks[0].beta.slice(ndarray::s![..p_marginal]).to_owned();
533 if let Some(state0) = block_states.first_mut() {
534 state0.beta = state0.beta.slice(ndarray::s![..p_marginal]).to_owned();
535 }
536
537 let drop_gamma_block = |cov: Option<Array2<f64>>| -> Option<Array2<f64>> {
540 cov.map(|cov| {
541 let total = cov.nrows();
542 let kept: Vec<usize> = (0..p_marginal)
543 .chain((p_marginal + p_influence)..total)
544 .collect();
545 let mut out = Array2::<f64>::zeros((kept.len(), kept.len()));
546 for (ri, &r) in kept.iter().enumerate() {
547 for (ci, &c) in kept.iter().enumerate() {
548 out[[ri, ci]] = cov[[r, c]];
549 }
550 }
551 out
552 })
553 };
554 let covariance_conditional = drop_gamma_block(covariance_conditional);
555 let covariance_corrected = drop_gamma_block(covariance_corrected);
556
557 UnifiedFitResult::try_from_parts(gam_solve::estimate::UnifiedFitResultParts {
558 blocks,
559 log_lambdas,
560 lambdas,
561 likelihood_family,
562 likelihood_scale,
563 log_likelihood_normalization,
564 log_likelihood,
565 deviance,
566 reml_score,
567 stable_penalty_term,
568 penalized_objective,
569 used_device,
573 outer_iterations,
574 outer_converged: true,
575 outer_gradient_norm,
576 standard_deviation,
577 covariance_conditional,
578 covariance_corrected,
579 inference,
580 fitted_link,
581 geometry: None,
583 block_states,
584 pirls_status,
585 max_abs_eta,
586 constraint_kkt,
587 artifacts,
588 inner_cycles,
589 })
590 .map_err(|e| {
591 format!("marginal-slope influence-absorber truncation produced an invalid fit result: {e}")
592 })
593}
594
595pub fn assemble_spline_scan_payload(
601 formula: String,
602 feature_column: String,
603 fit: &gam_solve::spline_scan::SplineScanFit,
604 data_schema: DataSchema,
605 training_headers: Vec<String>,
606 training_feature_ranges: Vec<(f64, f64)>,
607) -> FittedModelPayload {
608 let mut payload = FittedModelPayload::new(
609 MODEL_PAYLOAD_VERSION,
610 formula,
611 ModelKind::Standard,
612 FittedFamily::Standard {
613 likelihood: LikelihoodSpec::gaussian_identity(),
614 link: None,
615 latent_cloglog_state: None,
616 mixture_state: None,
617 sas_state: None,
618 },
619 "gaussian".to_string(),
620 );
621 payload.spline_scan = Some(SavedSplineScan {
622 feature_column,
623 state: fit.to_state(),
624 });
625 payload.data_schema = Some(data_schema);
626 payload.set_training_feature_metadata(training_headers, training_feature_ranges);
627 payload
628}
629
630pub fn assemble_residual_cascade_payload(
636 formula: String,
637 feature_columns: Vec<String>,
638 fit: &gam_solve::residual_cascade::ResidualCascadeFit,
639 data_schema: DataSchema,
640 training_headers: Vec<String>,
641 training_feature_ranges: Vec<(f64, f64)>,
642) -> Result<FittedModelPayload, String> {
643 let mut payload = FittedModelPayload::new(
644 MODEL_PAYLOAD_VERSION,
645 formula,
646 ModelKind::Standard,
647 FittedFamily::Standard {
648 likelihood: gam_problem::types::LikelihoodSpec::gaussian_identity(),
649 link: None,
650 latent_cloglog_state: None,
651 mixture_state: None,
652 sas_state: None,
653 },
654 "gaussian".to_string(),
655 );
656 payload.residual_cascade = Some(SavedResidualCascade {
657 feature_columns,
658 state: fit.to_state().map_err(|e| {
659 format!("residual-cascade to_state failed during payload assembly: {e}")
660 })?,
661 });
662 payload.data_schema = Some(data_schema);
663 payload.set_training_feature_metadata(training_headers, training_feature_ranges);
664 Ok(payload)
665}
666
667pub fn assemble_bernoulli_marginal_slope_payload(
675 inputs: BernoulliMarginalSlopeInputs<'_>,
676 source: SavedModelSourceMetadata,
677) -> Result<FittedModelPayload, String> {
678 let BernoulliMarginalSlopeInputs {
679 formula,
680 data_schema,
681 logslope_formula,
682 z_column,
683 resolved_marginalspec,
684 resolved_logslopespec,
685 fit_result,
686 p_marginal,
687 baseline_marginal,
688 baseline_logslope,
689 latent_z_normalization,
690 latent_measure,
691 latent_z_rank_int_calibration,
692 latent_z_conditional_calibration,
693 score_warp_runtime,
694 link_dev_runtime,
695 base_link,
696 frailty,
697 } = inputs;
698
699 let fit_result = truncate_marginal_slope_influence_absorber(fit_result, p_marginal)?;
703
704 let marginal_likelihood_spec =
705 inverse_link_to_binomial_spec(&base_link).map_err(|e| e.to_string())?;
706
707 let mut payload = FittedModelPayload::new(
708 MODEL_PAYLOAD_VERSION,
709 formula,
710 ModelKind::MarginalSlope,
711 FittedFamily::MarginalSlope {
712 likelihood: marginal_likelihood_spec,
713 base_link: base_link.clone(),
714 frailty,
715 },
716 FAMILY_BERNOULLI_MARGINAL_SLOPE.to_string(),
717 );
718 payload.unified = Some(fit_result.clone());
719 payload.fit_result = Some(fit_result);
720 payload.data_schema = Some(data_schema);
721 payload.formula_logslope = Some(logslope_formula.clone());
722 payload.z_column = Some(z_column.clone());
723 payload.formula_logslopes = Some(vec![logslope_formula]);
724 payload.z_columns = Some(vec![z_column]);
725 payload.latent_z_normalization = Some(latent_z_normalization);
726 payload.latent_measure = Some(latent_measure);
727 payload.latent_z_rank_int_calibration = latent_z_rank_int_calibration;
728 payload.latent_z_conditional_calibration = latent_z_conditional_calibration;
729 payload.marginal_baseline = Some(baseline_marginal);
730 payload.logslope_baseline = Some(baseline_logslope);
731 payload.logslope_baselines = Some(vec![baseline_logslope]);
732 payload.link = Some(base_link);
733 payload.resolved_termspec = Some(resolved_marginalspec);
734 payload.resolved_termspec_logslopes = Some(vec![resolved_logslopespec.clone()]);
735 payload.resolved_termspec_logslope = Some(resolved_logslopespec);
736 payload.score_warp_runtime = score_warp_runtime.map(serialize_anchored_deviation_runtime);
737 payload.link_deviation_runtime = link_dev_runtime.map(serialize_anchored_deviation_runtime);
738 source.apply_to(&mut payload);
739 Ok(payload)
740}
741
742pub struct TransformationNormalInputs<'a> {
749 pub formula: String,
750 pub data_schema: DataSchema,
751 pub resolved_covariate_spec: TermCollectionSpec,
752 pub fit_result: UnifiedFitResult,
753 pub family: &'a TransformationNormalFamily,
754 pub score_calibration: TransformationScoreCalibration,
755}
756
757pub fn assemble_transformation_normal_payload(
763 inputs: TransformationNormalInputs<'_>,
764 source: SavedModelSourceMetadata,
765) -> FittedModelPayload {
766 let TransformationNormalInputs {
767 formula,
768 data_schema,
769 resolved_covariate_spec,
770 fit_result,
771 family,
772 score_calibration,
773 } = inputs;
774
775 let mut payload = FittedModelPayload::new(
776 MODEL_PAYLOAD_VERSION,
777 formula,
778 ModelKind::TransformationNormal,
779 FittedFamily::TransformationNormal {
780 likelihood: LikelihoodSpec::new(
781 ResponseFamily::Gaussian,
782 InverseLink::Standard(StandardLink::Identity),
783 ),
784 },
785 FAMILY_TRANSFORMATION_NORMAL.to_string(),
786 );
787 payload.unified = Some(fit_result.clone());
788 payload.fit_result = Some(fit_result);
789 payload.data_schema = Some(data_schema);
790 payload.resolved_termspec = Some(resolved_covariate_spec);
791 payload.transformation_response_knots = Some(family.response_knots().to_vec());
792 payload.transformation_response_transform = Some(
793 family
794 .response_transform()
795 .rows()
796 .into_iter()
797 .map(|row| row.to_vec())
798 .collect(),
799 );
800 payload.transformation_response_degree = Some(family.response_degree());
801 payload.transformation_response_median = Some(family.response_median());
802 payload.transformation_geometry = Some(transformation_normal_geometry(family));
803 let cone_carrier = family
810 .covariate_dense_arc()
811 .expect("CTN covariate design must materialize for the persisted cone carrier");
812 payload.transformation_cone_carrier = Some(cone_carrier.iter().copied().collect());
813 payload.transformation_score_calibration = Some(score_calibration);
814 source.apply_to(&mut payload);
815 payload
816}
817
818fn transformation_normal_geometry(
827 family: &TransformationNormalFamily,
828) -> SavedTransformationNormalGeometry {
829 let knots = family.response_knots();
830 let lo = knots.iter().copied().fold(f64::INFINITY, f64::min);
831 let hi = knots.iter().copied().fold(f64::NEG_INFINITY, f64::max);
832 SavedTransformationNormalGeometry {
833 parameterization: TransformationNormalParameterization::DirectAlpha,
834 response_degree: family.response_degree(),
835 response_knot_count: knots.len(),
836 shape_coordinate_count: family.p_resp().saturating_sub(1),
837 cone_carrier_covariate_width: family.p_cov(),
838 cone_carrier_row_count: family.n_obs(),
839 certified_response_support: (lo, hi),
840 response_median: family.response_median(),
841 }
842}
843
844pub enum LocationScaleResponse<'a> {
850 Gaussian {
853 response_scale: f64,
854 base_link: Option<InverseLink>,
855 },
856 Binomial {
858 link: InverseLink,
859 noise_transform: &'a ScaleDeviationTransform,
860 },
861 Dispersion {
867 likelihood: LikelihoodSpec,
868 base_link: InverseLink,
869 family_tag: &'static str,
870 },
871}
872
873pub struct LocationScaleWiggle {
878 pub knots: Vec<f64>,
879 pub degree: usize,
880 pub beta_link_wiggle: Vec<f64>,
881}
882
883pub struct LocationScaleInputs {
887 pub formula: String,
888 pub data_schema: DataSchema,
889 pub noise_formula: String,
890 pub resolved_termspec: TermCollectionSpec,
891 pub resolved_termspec_noise: TermCollectionSpec,
892 pub fit_result: UnifiedFitResult,
893 pub beta_noise: Option<Vec<f64>>,
894 pub wiggle: Option<LocationScaleWiggle>,
895}
896
897pub fn assemble_location_scale_payload(
902 inputs: LocationScaleInputs,
903 response: LocationScaleResponse<'_>,
904 source: SavedModelSourceMetadata,
905) -> Result<FittedModelPayload, String> {
906 let (family_tag, likelihood, base_link, link, response_scale, noise_transform) = match response
907 {
908 LocationScaleResponse::Gaussian {
909 response_scale,
910 base_link,
911 } => (
912 "gaussian-location-scale".to_string(),
913 LikelihoodSpec::gaussian_identity(),
914 None,
918 Some(base_link.unwrap_or(InverseLink::Standard(StandardLink::Identity))),
919 Some(response_scale),
920 None,
921 ),
922 LocationScaleResponse::Binomial {
923 link,
924 noise_transform,
925 } => {
926 let likelihood = inverse_link_to_binomial_spec(&link).map_err(|e| {
927 format!("failed to resolve LikelihoodSpec for binomial location-scale link {link:?}: {e}")
928 })?;
929 (
930 "binomial-location-scale".to_string(),
931 likelihood,
932 Some(link.clone()),
933 Some(link),
934 None,
935 Some(noise_transform),
936 )
937 }
938 LocationScaleResponse::Dispersion {
939 likelihood,
940 base_link,
941 family_tag,
942 } => (
943 family_tag.to_string(),
944 likelihood,
945 Some(base_link.clone()),
946 Some(base_link),
947 None,
948 None,
949 ),
950 };
951
952 let mut payload = FittedModelPayload::new(
953 MODEL_PAYLOAD_VERSION,
954 inputs.formula,
955 ModelKind::LocationScale,
956 FittedFamily::LocationScale {
957 likelihood,
958 base_link,
959 },
960 family_tag,
961 );
962 payload.unified = Some(inputs.fit_result.clone());
963 payload.fit_result = Some(inputs.fit_result);
964 payload.data_schema = Some(inputs.data_schema);
965 payload.link = link;
966 payload.formula_noise = Some(inputs.noise_formula);
967 payload.beta_noise = inputs.beta_noise;
968 payload.gaussian_response_scale = response_scale;
969 if let Some(transform) = noise_transform {
970 payload.noise_projection = Some(
971 transform
972 .projection_coef
973 .rows()
974 .into_iter()
975 .map(|row| row.to_vec())
976 .collect(),
977 );
978 payload.noise_center = Some(transform.weighted_column_mean.to_vec());
979 payload.noise_scale = Some(transform.rescale.to_vec());
980 payload.noise_non_intercept_start = Some(transform.non_intercept_start);
981 payload.noise_projection_ridge_alpha = Some(transform.projection_ridge_alpha);
982 }
983 payload.resolved_termspec = Some(inputs.resolved_termspec);
984 payload.resolved_termspec_noise = Some(inputs.resolved_termspec_noise);
985 if let Some(wiggle) = inputs.wiggle {
986 payload.linkwiggle_knots = Some(wiggle.knots);
987 payload.linkwiggle_degree = Some(wiggle.degree);
988 payload.beta_link_wiggle = Some(wiggle.beta_link_wiggle);
989 }
990 source.apply_to(&mut payload);
991 Ok(payload)
992}
993
994pub struct SurvivalMarginalSlopeInputs<'a> {
999 pub formula: String,
1000 pub data_schema: DataSchema,
1001 pub fit_result: UnifiedFitResult,
1002 pub frailty: crate::survival::lognormal_kernel::FrailtySpec,
1003 pub survival_entry: Option<String>,
1004 pub survival_exit: String,
1005 pub survival_event: String,
1006 pub survivalspec: String,
1007 pub baseline_cfg: SurvivalBaselineConfig,
1008 pub time_basis: SavedSurvivalTimeBasis,
1009 pub ridge_lambda: f64,
1010 pub survival_likelihood_label: String,
1011 pub resolved_marginalspec: TermCollectionSpec,
1012 pub resolved_logslopespec: TermCollectionSpec,
1013 pub logslope_formula: String,
1014 pub z_column: String,
1015 pub latent_z_normalization: SavedLatentZNormalization,
1016 pub baseline_logslope: f64,
1017 pub timewiggle: Option<SurvivalTimewiggle>,
1019 pub score_warp_runtime: Option<&'a DeviationRuntime>,
1020 pub link_dev_runtime: Option<&'a DeviationRuntime>,
1021 pub influence_absorber_width: Option<usize>,
1026 pub influence_absorber_design: Option<&'a Array2<f64>>,
1027 pub score_covariance: &'a Array2<f64>,
1028}
1029
1030fn new_royston_parmar_survival_payload(
1038 formula: String,
1039 fit_result: UnifiedFitResult,
1040 data_schema: DataSchema,
1041 survival_likelihood_label: &str,
1042 survival_distribution: Option<ResidualDistribution>,
1043 frailty: crate::survival::lognormal_kernel::FrailtySpec,
1044) -> FittedModelPayload {
1045 let mut payload = FittedModelPayload::new(
1046 MODEL_PAYLOAD_VERSION,
1047 formula,
1048 ModelKind::Survival,
1049 FittedFamily::Survival {
1050 likelihood: LikelihoodSpec::new(
1051 ResponseFamily::RoystonParmar,
1052 InverseLink::Standard(StandardLink::Identity),
1053 ),
1054 survival_likelihood: Some(survival_likelihood_label.to_string()),
1055 survival_distribution,
1056 frailty,
1057 },
1058 ResponseFamily::RoystonParmar.name().to_string(),
1059 );
1060 payload.unified = Some(fit_result.clone());
1061 payload.fit_result = Some(fit_result);
1062 payload.data_schema = Some(data_schema);
1063 payload
1064}
1065
1066pub fn assemble_survival_marginal_slope_payload(
1069 inputs: SurvivalMarginalSlopeInputs<'_>,
1070 source: SavedModelSourceMetadata,
1071) -> FittedModelPayload {
1072 let mut payload = new_royston_parmar_survival_payload(
1073 inputs.formula,
1074 inputs.fit_result,
1075 inputs.data_schema,
1076 &inputs.survival_likelihood_label,
1077 Some(ResidualDistribution::Gaussian),
1078 inputs.frailty,
1079 );
1080 payload.survival_entry = inputs.survival_entry;
1081 payload.survival_exit = Some(inputs.survival_exit);
1082 payload.survival_event = Some(inputs.survival_event);
1083 payload.survivalspec = Some(inputs.survivalspec);
1084 payload.survival_baseline_target =
1085 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1086 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1087 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1088 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1089 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1090 payload.apply_survival_time_basis(&inputs.time_basis);
1091 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1092 payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1093 payload.survival_distribution = Some(ResidualDistribution::Gaussian);
1094 payload.link = Some(InverseLink::Standard(StandardLink::Probit));
1095 payload.resolved_termspec = Some(inputs.resolved_marginalspec);
1096 payload.resolved_termspec_logslopes = Some(vec![inputs.resolved_logslopespec.clone()]);
1097 payload.resolved_termspec_logslope = Some(inputs.resolved_logslopespec);
1098 payload.formula_logslope = Some(inputs.logslope_formula.clone());
1099 payload.formula_logslopes = Some(vec![inputs.logslope_formula]);
1100 payload.z_column = Some(inputs.z_column.clone());
1101 payload.z_columns = Some(vec![inputs.z_column]);
1102 payload.latent_z_normalization = Some(inputs.latent_z_normalization);
1103 payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
1104 payload.logslope_baseline = Some(inputs.baseline_logslope);
1105 payload.logslope_baselines = Some(vec![inputs.baseline_logslope]);
1106 if let Some(timewiggle) = inputs.timewiggle {
1107 payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1108 payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1109 payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1110 payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1111 apply_timewiggle_beta(&mut payload, timewiggle.beta);
1112 }
1113 payload.score_warp_runtime = inputs
1114 .score_warp_runtime
1115 .map(serialize_anchored_deviation_runtime);
1116 payload.link_deviation_runtime = inputs
1117 .link_dev_runtime
1118 .map(serialize_anchored_deviation_runtime);
1119 payload.influence_absorber_width = inputs.influence_absorber_width;
1120 payload.influence_absorber_design = inputs
1121 .influence_absorber_design
1122 .map(|design| design.rows().into_iter().map(|row| row.to_vec()).collect());
1123 payload.survival_marginal_slope_score_covariance = Some(
1124 inputs
1125 .score_covariance
1126 .rows()
1127 .into_iter()
1128 .map(|row| row.to_vec())
1129 .collect(),
1130 );
1131 source.apply_to(&mut payload);
1132 payload
1133}
1134
1135pub enum SurvivalTimewiggleBeta {
1138 Single(Vec<f64>),
1139 ByCause(Vec<Vec<f64>>),
1140}
1141
1142fn apply_timewiggle_beta(payload: &mut FittedModelPayload, beta: SurvivalTimewiggleBeta) {
1148 match beta {
1149 SurvivalTimewiggleBeta::Single(beta) => {
1150 payload.beta_baseline_timewiggle = Some(beta);
1151 }
1152 SurvivalTimewiggleBeta::ByCause(by_cause) => {
1153 payload.beta_baseline_timewiggle_by_cause = Some(by_cause);
1154 }
1155 }
1156}
1157
1158pub struct SurvivalTimewiggle {
1160 pub degree: usize,
1161 pub knots: Vec<f64>,
1162 pub penalty_orders: Option<Vec<usize>>,
1163 pub double_penalty: Option<bool>,
1164 pub beta: SurvivalTimewiggleBeta,
1165}
1166
1167pub struct SurvivalTransformationInputs {
1170 pub formula: String,
1171 pub data_schema: DataSchema,
1172 pub fit_result: UnifiedFitResult,
1173 pub survival_entry: Option<String>,
1174 pub survival_exit: String,
1175 pub survival_event: String,
1176 pub survivalspec: String,
1177 pub cause_count: Option<usize>,
1180 pub baseline_cfg: SurvivalBaselineConfig,
1181 pub time_basis: SavedSurvivalTimeBasis,
1182 pub ridge_lambda: f64,
1183 pub survival_likelihood_label: String,
1184 pub resolved_termspec: TermCollectionSpec,
1185 pub survival_beta_time: Option<Vec<f64>>,
1187 pub timewiggle: Option<SurvivalTimewiggle>,
1188}
1189
1190pub fn assemble_survival_transformation_payload(
1193 inputs: SurvivalTransformationInputs,
1194 source: SavedModelSourceMetadata,
1195) -> FittedModelPayload {
1196 let mut payload = new_royston_parmar_survival_payload(
1197 inputs.formula,
1198 inputs.fit_result,
1199 inputs.data_schema,
1200 &inputs.survival_likelihood_label,
1201 None,
1202 crate::survival::lognormal_kernel::FrailtySpec::None,
1203 );
1204 payload.survival_entry = inputs.survival_entry;
1205 payload.survival_exit = Some(inputs.survival_exit);
1206 payload.survival_event = Some(inputs.survival_event);
1207 payload.survivalspec = Some(inputs.survivalspec);
1208 if let Some(cause_count) = inputs.cause_count {
1209 payload.survival_cause_count = Some(cause_count);
1210 payload.survival_endpoint_names = Some(
1211 (1..=cause_count)
1212 .map(|idx| format!("cause_{idx}"))
1213 .collect(),
1214 );
1215 }
1216 payload.survival_baseline_target =
1217 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1218 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1219 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1220 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1221 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1222 payload.apply_survival_time_basis(&inputs.time_basis);
1223 if let Some(timewiggle) = inputs.timewiggle {
1224 payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1225 payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1226 payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1227 payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1228 apply_timewiggle_beta(&mut payload, timewiggle.beta);
1229 }
1230 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1231 payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1232 payload.survival_beta_time = inputs.survival_beta_time;
1233 payload.resolved_termspec = Some(inputs.resolved_termspec);
1234 source.apply_to(&mut payload);
1235 payload
1236}
1237
1238pub struct SurvivalLocationScaleInputs {
1243 pub formula: String,
1244 pub data_schema: DataSchema,
1245 pub fit_result: UnifiedFitResult,
1248 pub fitted_inverse_link: InverseLink,
1249 pub linkwiggle_degree: Option<usize>,
1252 pub linkwiggle_knots: Option<Vec<f64>>,
1253 pub beta_link_wiggle: Option<Vec<f64>>,
1254 pub baseline_timewiggle: Option<SurvivalTimewiggle>,
1255 pub survival_entry: Option<String>,
1256 pub survival_exit: String,
1257 pub survival_event: String,
1258 pub survivalspec: String,
1259 pub baseline_cfg: SurvivalBaselineConfig,
1260 pub time_basis: SavedSurvivalTimeBasis,
1261 pub ridge_lambda: f64,
1262 pub survival_likelihood_label: String,
1263 pub time_parameterization: SurvivalLocationScaleTimeParameterization,
1264 pub threshold_time_basis: Option<SurvivalCovariateTimeBasis>,
1265 pub log_sigma_time_basis: Option<SurvivalCovariateTimeBasis>,
1266 pub formula_noise: Option<String>,
1267 pub survival_beta_time: Vec<f64>,
1268 pub survival_beta_threshold: Vec<f64>,
1269 pub survival_beta_log_sigma: Vec<f64>,
1270 pub resolved_thresholdspec: TermCollectionSpec,
1271 pub resolved_log_sigmaspec: TermCollectionSpec,
1272}
1273
1274pub fn assemble_survival_location_scale_payload(
1277 inputs: SurvivalLocationScaleInputs,
1278 source: SavedModelSourceMetadata,
1279) -> FittedModelPayload {
1280 let survival_distribution =
1281 residual_distribution_from_inverse_link(&inputs.fitted_inverse_link);
1282 let mut payload = new_royston_parmar_survival_payload(
1283 inputs.formula,
1284 inputs.fit_result,
1285 inputs.data_schema,
1286 &inputs.survival_likelihood_label,
1287 survival_distribution,
1288 crate::survival::lognormal_kernel::FrailtySpec::None,
1289 );
1290 payload.link = Some(inputs.fitted_inverse_link);
1291 payload.linkwiggle_degree = inputs.linkwiggle_degree;
1292 payload.linkwiggle_knots = inputs.linkwiggle_knots;
1293 payload.beta_link_wiggle = inputs.beta_link_wiggle;
1294 if let Some(timewiggle) = inputs.baseline_timewiggle {
1295 payload.baseline_timewiggle_degree = Some(timewiggle.degree);
1296 payload.baseline_timewiggle_knots = Some(timewiggle.knots);
1297 payload.baseline_timewiggle_penalty_orders = timewiggle.penalty_orders;
1298 payload.baseline_timewiggle_double_penalty = timewiggle.double_penalty;
1299 apply_timewiggle_beta(&mut payload, timewiggle.beta);
1300 }
1301 payload.survival_entry = inputs.survival_entry;
1302 payload.survival_exit = Some(inputs.survival_exit);
1303 payload.survival_event = Some(inputs.survival_event);
1304 payload.survivalspec = Some(inputs.survivalspec);
1305 payload.survival_baseline_target =
1306 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1307 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1308 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1309 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1310 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1311 payload.apply_survival_time_basis(&inputs.time_basis);
1312 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1313 payload.survival_likelihood = Some(inputs.survival_likelihood_label);
1314 payload.survival_location_scale_structure = Some(SavedSurvivalLocationScaleStructure {
1315 time_parameterization: inputs.time_parameterization,
1316 threshold_time_basis: inputs.threshold_time_basis,
1317 log_sigma_time_basis: inputs.log_sigma_time_basis,
1318 });
1319 payload.formula_noise = inputs.formula_noise;
1320 payload.survival_beta_time = Some(inputs.survival_beta_time);
1321 payload.survival_beta_threshold = Some(inputs.survival_beta_threshold);
1322 payload.survival_beta_log_sigma = Some(inputs.survival_beta_log_sigma);
1323 payload.survival_distribution = survival_distribution;
1324 payload.resolved_termspec = Some(inputs.resolved_thresholdspec);
1325 payload.resolved_termspec_noise = Some(inputs.resolved_log_sigmaspec);
1326 source.apply_to(&mut payload);
1327 payload
1328}
1329
1330pub struct LatentWindowInputs {
1334 pub formula: String,
1335 pub data_schema: DataSchema,
1336 pub fit_result: UnifiedFitResult,
1337 pub family: FittedFamily,
1338 pub model_class_label: String,
1339 pub likelihood_label: String,
1340 pub survival_entry: Option<String>,
1341 pub survival_exit: String,
1342 pub survival_event: String,
1343 pub baseline_cfg: SurvivalBaselineConfig,
1344 pub time_basis: SavedSurvivalTimeBasis,
1345 pub ridge_lambda: f64,
1346 pub beta_time: Vec<f64>,
1347 pub resolved_termspec: TermCollectionSpec,
1348}
1349
1350pub fn assemble_latent_window_payload(
1352 inputs: LatentWindowInputs,
1353 source: SavedModelSourceMetadata,
1354) -> FittedModelPayload {
1355 let mut payload = FittedModelPayload::new(
1356 MODEL_PAYLOAD_VERSION,
1357 inputs.formula,
1358 ModelKind::Survival,
1359 inputs.family,
1360 inputs.model_class_label,
1361 );
1362 payload.unified = Some(inputs.fit_result.clone());
1363 payload.fit_result = Some(inputs.fit_result);
1364 payload.data_schema = Some(inputs.data_schema);
1365 payload.survival_entry = inputs.survival_entry;
1366 payload.survival_exit = Some(inputs.survival_exit);
1367 payload.survival_event = Some(inputs.survival_event);
1368 payload.survivalspec = Some("net".to_string());
1369 payload.survival_baseline_target =
1370 Some(survival_baseline_targetname(inputs.baseline_cfg.target).to_string());
1371 payload.survival_baseline_scale = inputs.baseline_cfg.scale;
1372 payload.survival_baseline_shape = inputs.baseline_cfg.shape;
1373 payload.survival_baseline_rate = inputs.baseline_cfg.rate;
1374 payload.survival_baseline_makeham = inputs.baseline_cfg.makeham;
1375 payload.apply_survival_time_basis(&inputs.time_basis);
1376 payload.survival_likelihood = Some(inputs.likelihood_label);
1377 payload.survival_beta_time = Some(inputs.beta_time);
1378 payload.survivalridge_lambda = Some(inputs.ridge_lambda);
1379 payload.resolved_termspec = Some(inputs.resolved_termspec);
1380 source.apply_to(&mut payload);
1381 payload
1382}
1383
1384#[cfg(test)]
1385mod apply_timewiggle_beta_tests {
1386 use super::*;
1387
1388 fn empty_payload() -> FittedModelPayload {
1391 FittedModelPayload::new(
1392 MODEL_PAYLOAD_VERSION,
1393 "y ~ 1".to_string(),
1394 ModelKind::Survival,
1395 FittedFamily::LatentBinary {
1396 frailty: crate::survival::lognormal_kernel::FrailtySpec::None,
1397 },
1398 "test".to_string(),
1399 )
1400 }
1401
1402 #[test]
1403 fn by_cause_beta_populates_only_the_by_cause_slot() {
1404 let mut payload = empty_payload();
1405 apply_timewiggle_beta(
1406 &mut payload,
1407 SurvivalTimewiggleBeta::ByCause(vec![vec![1.0, 2.0], vec![3.0]]),
1408 );
1409 assert_eq!(
1410 payload.beta_baseline_timewiggle_by_cause,
1411 Some(vec![vec![1.0, 2.0], vec![3.0]]),
1412 "ByCause coefficients must land in the by-cause slot (regression: the \
1413 location-scale assembler used to silently drop them)"
1414 );
1415 assert!(
1416 payload.beta_baseline_timewiggle.is_none(),
1417 "ByCause must not populate the single-block slot"
1418 );
1419 }
1420
1421 #[test]
1422 fn single_beta_populates_only_the_flat_slot() {
1423 let mut payload = empty_payload();
1424 apply_timewiggle_beta(&mut payload, SurvivalTimewiggleBeta::Single(vec![4.0, 5.0]));
1425 assert_eq!(payload.beta_baseline_timewiggle, Some(vec![4.0, 5.0]));
1426 assert!(payload.beta_baseline_timewiggle_by_cause.is_none());
1427 }
1428}