1use gam_terms::basis::BasisOptions;
2use gam_solve::estimate::{BlockRole, FittedLinkState, UnifiedFitResult};
3use crate::bms::{
4 LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
5};
6use crate::survival::construction::{
7 SurvivalBaselineConfig, SurvivalTimeBasisConfig, parse_survival_baseline_config,
8};
9use crate::survival::location_scale::ResidualDistribution;
10use crate::survival::lognormal_kernel::FrailtySpec;
11use crate::wiggle::{
12 monotone_wiggle_basis_with_derivative_order, validate_monotone_wiggle_beta_nonnegative,
13};
14use gam_terms::inference::formula_dsl::{
15 inverse_link_supports_joint_wiggle, joint_wiggle_unsupported_link_message, parse_formula,
16 parse_surv_interval_response, parse_surv_response, parsed_term_column_names,
17};
18use gam_solve::mixture_link::{state_from_beta_logisticspec, state_from_sasspec};
19use gam_terms::smooth::{AdaptiveRegularizationDiagnostics, TermCollectionSpec};
20use gam_problem::types::{
21 InverseLink, LatentCLogLogState, LikelihoodSpec, MixtureLinkState, ResponseFamily, SasLinkSpec,
22 SasLinkState, StandardLink,
23};
24use gam_runtime::span::span_index_for_breakpoints;
25pub use gam_data::{ColumnKindTag, DataSchema, SchemaColumn};
31use ndarray::{Array1, Array2};
32use serde::{Deserialize, Serialize};
33use serde_json::Value as JsonValue;
34use std::collections::{BTreeMap, HashMap, HashSet};
35use std::fs;
36use std::ops::{Deref, DerefMut};
37use std::path::Path;
38
39pub const MODEL_PAYLOAD_VERSION: u32 = 7;
59
60pub type GroupMetadata = BTreeMap<String, JsonValue>;
67
68#[derive(Clone, Debug, Serialize, Deserialize)]
71pub struct SavedSplineScan {
72 pub feature_column: String,
74 pub state: gam_solve::spline_scan::SplineScanState,
75}
76
77#[derive(Clone, Debug, Serialize, Deserialize)]
82pub struct SavedResidualCascade {
83 pub feature_columns: Vec<String>,
85 pub state: gam_solve::residual_cascade::ResidualCascadeState,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum FittedModelError {
99 SchemaMismatch { reason: String },
103 PayloadCorrupt { reason: String },
107 MissingField { reason: String },
110 IncompatibleConfig { reason: String },
114 InvalidInput { reason: String },
117}
118
119impl_reason_error_boilerplate! {
120 FittedModelError {
121 SchemaMismatch,
122 PayloadCorrupt,
123 MissingField,
124 IncompatibleConfig,
125 InvalidInput,
126 }
127}
128
129impl From<FittedModelError> for gam_solve::model_types::EstimationError {
134 fn from(err: FittedModelError) -> Self {
135 gam_solve::model_types::EstimationError::InvalidInput(err.to_string())
136 }
137}
138
139impl From<FittedModelError> for crate::survival::predict::SurvivalPredictError {
140 fn from(err: FittedModelError) -> Self {
141 crate::survival::predict::SurvivalPredictError::ModelPayload {
142 context: "saved-model survival prediction payload",
143 source: err,
144 }
145 }
146}
147
148#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
149pub struct SavedLatentZNormalization {
150 pub mean: f64,
151 pub sd: f64,
152}
153
154impl SavedLatentZNormalization {
155 pub fn validate(&self, context: &str) -> Result<(), FittedModelError> {
156 if !self.mean.is_finite() {
157 return Err(FittedModelError::PayloadCorrupt {
158 reason: format!("{context} latent z mean must be finite"),
159 });
160 }
161 if !(self.sd.is_finite() && self.sd > 1e-12) {
162 return Err(FittedModelError::PayloadCorrupt {
163 reason: format!(
164 "{context} latent z sd must be finite and > 1e-12; got {}",
165 self.sd
166 ),
167 });
168 }
169 Ok::<(), _>(())
170 }
171
172 pub fn apply(&self, z: &Array1<f64>, context: &str) -> Result<Array1<f64>, FittedModelError> {
173 self.validate(context)?;
174 if z.iter().any(|value| !value.is_finite()) {
175 return Err(FittedModelError::PayloadCorrupt {
176 reason: format!("{context} requires finite z values"),
177 });
178 }
179 Ok(z.mapv(|zi| (zi - self.mean) / self.sd))
180 }
181}
182
183pub const TRANSFORMATION_SCORE_PIT_CLIP_EPS: f64 = 1.0e-12;
184
185#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
186#[serde(rename_all = "kebab-case")]
187#[derive(Default)]
188pub enum TransformationScoreKind {
189 #[default]
190 FiniteSupportPit,
191}
192
193#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
194pub struct TransformationScoreCalibration {
195 #[serde(default)]
196 pub score_kind: TransformationScoreKind,
197 #[serde(default = "default_transformation_score_pit_clip_eps")]
198 pub clip_eps: f64,
199}
200
201const fn default_transformation_score_pit_clip_eps() -> f64 {
202 TRANSFORMATION_SCORE_PIT_CLIP_EPS
203}
204
205impl TransformationScoreCalibration {
206 pub fn finite_support_pit() -> Self {
207 Self {
208 score_kind: TransformationScoreKind::FiniteSupportPit,
209 clip_eps: TRANSFORMATION_SCORE_PIT_CLIP_EPS,
210 }
211 }
212
213 pub fn validate(&self, context: &str) -> Result<(), FittedModelError> {
214 if self.score_kind != TransformationScoreKind::FiniteSupportPit {
215 return Err(FittedModelError::IncompatibleConfig {
216 reason: format!("{context} supports only finite-support CTN PIT score semantics"),
217 });
218 }
219 if !(self.clip_eps.is_finite() && self.clip_eps > 0.0 && self.clip_eps < 0.5) {
220 return Err(FittedModelError::IncompatibleConfig {
221 reason: format!(
222 "{context} requires PIT clip_eps in (0, 0.5), got {}",
223 self.clip_eps
224 ),
225 });
226 }
227 Ok::<(), _>(())
228 }
229}
230
231#[derive(Clone, Serialize, Deserialize)]
232pub struct FittedModelPayload {
233 pub version: u32,
234 pub formula: String,
235 pub model_kind: ModelKind,
236 pub family_state: FittedFamily,
237 pub family: String,
238 #[serde(default)]
247 pub inference_notes: Vec<String>,
248 #[serde(default)]
249 pub used_device: bool,
250 #[serde(default)]
251 pub fit_result: Option<UnifiedFitResult>,
252 #[serde(default)]
254 pub unified: Option<UnifiedFitResult>,
255 #[serde(default)]
263 pub spline_scan: Option<SavedSplineScan>,
264 #[serde(default)]
270 pub residual_cascade: Option<SavedResidualCascade>,
271 #[serde(default)]
272 pub data_schema: Option<DataSchema>,
273 pub link: Option<InverseLink>,
274 #[serde(default)]
275 pub mixture_link_param_covariance: Option<Vec<Vec<f64>>>,
276 #[serde(default)]
277 pub sas_param_covariance: Option<Vec<Vec<f64>>>,
278 #[serde(default)]
279 pub formula_noise: Option<String>,
280 #[serde(default)]
281 pub formula_logslope: Option<String>,
282 #[serde(default)]
283 pub formula_logslopes: Option<Vec<String>>,
284 #[serde(default)]
285 pub offset_column: Option<String>,
286 #[serde(default)]
287 pub noise_offset_column: Option<String>,
288 #[serde(default)]
293 pub weight_column: Option<String>,
294 #[serde(default)]
295 pub beta_noise: Option<Vec<f64>>,
296 #[serde(default)]
297 pub noise_projection: Option<Vec<Vec<f64>>>,
298 #[serde(default)]
299 pub noise_center: Option<Vec<f64>>,
300 #[serde(default)]
301 pub noise_scale: Option<Vec<f64>>,
302 #[serde(default)]
303 pub noise_non_intercept_start: Option<usize>,
304 #[serde(default)]
308 pub noise_projection_ridge_alpha: Option<f64>,
309 #[serde(default)]
310 pub gaussian_response_scale: Option<f64>,
311 #[serde(default)]
312 pub linkwiggle_knots: Option<Vec<f64>>,
313 #[serde(default)]
314 pub linkwiggle_degree: Option<usize>,
315 #[serde(default)]
316 pub beta_link_wiggle: Option<Vec<f64>>,
317 #[serde(default)]
318 pub baseline_timewiggle_knots: Option<Vec<f64>>,
319 #[serde(default)]
320 pub baseline_timewiggle_degree: Option<usize>,
321 #[serde(default)]
322 pub baseline_timewiggle_penalty_orders: Option<Vec<usize>>,
323 #[serde(default)]
324 pub baseline_timewiggle_double_penalty: Option<bool>,
325 #[serde(default)]
326 pub beta_baseline_timewiggle: Option<Vec<f64>>,
327 #[serde(default)]
328 pub beta_baseline_timewiggle_by_cause: Option<Vec<Vec<f64>>>,
329 #[serde(default)]
330 pub z_column: Option<String>,
331 #[serde(default)]
332 pub z_columns: Option<Vec<String>>,
333 #[serde(default)]
334 pub latent_z_normalization: Option<SavedLatentZNormalization>,
335 #[serde(default)]
336 pub latent_score_contract: Option<SavedLatentScoreContract>,
337 #[serde(default)]
338 pub latent_measure: Option<LatentMeasureKind>,
339 #[serde(default)]
346 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
347 #[serde(default)]
356 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
357 #[serde(default)]
358 pub marginal_baseline: Option<f64>,
359 #[serde(default)]
360 pub logslope_baseline: Option<f64>,
361 #[serde(default)]
362 pub logslope_baselines: Option<Vec<f64>>,
363 #[serde(default)]
364 pub score_warp_runtime: Option<SavedCompiledFlexBlock>,
365 #[serde(default)]
366 pub link_deviation_runtime: Option<SavedCompiledFlexBlock>,
367 #[serde(default)]
372 pub influence_absorber_width: Option<usize>,
373 #[serde(default)]
374 pub survival_entry: Option<String>,
375 #[serde(default)]
376 pub survival_exit: Option<String>,
377 #[serde(default)]
378 pub survival_event: Option<String>,
379 #[serde(default)]
380 pub survivalspec: Option<String>,
381 #[serde(default)]
382 pub survival_cause_count: Option<usize>,
383 #[serde(default)]
384 pub survival_endpoint_names: Option<Vec<String>>,
385 #[serde(default)]
386 pub survival_baseline_target: Option<String>,
387 #[serde(default)]
388 pub survival_baseline_scale: Option<f64>,
389 #[serde(default)]
390 pub survival_baseline_shape: Option<f64>,
391 #[serde(default)]
392 pub survival_baseline_rate: Option<f64>,
393 #[serde(default)]
394 pub survival_baseline_makeham: Option<f64>,
395 #[serde(default)]
396 pub survival_time_basis: Option<String>,
397 #[serde(default)]
398 pub survival_time_degree: Option<usize>,
399 #[serde(default)]
400 pub survival_time_knots: Option<Vec<f64>>,
401 #[serde(default)]
402 pub survival_time_keep_cols: Option<Vec<usize>>,
403 #[serde(default)]
404 pub survival_time_smooth_lambda: Option<f64>,
405 #[serde(default)]
406 pub survival_time_anchor: Option<f64>,
407 #[serde(default)]
408 pub survivalridge_lambda: Option<f64>,
409 #[serde(default)]
410 pub survival_likelihood: Option<String>,
411 #[serde(default)]
412 pub survival_beta_time: Option<Vec<f64>>,
413 #[serde(default)]
414 pub survival_beta_threshold: Option<Vec<f64>>,
415 #[serde(default)]
416 pub survival_beta_log_sigma: Option<Vec<f64>>,
417 #[serde(default)]
418 pub survival_noise_projection: Option<Vec<Vec<f64>>>,
419 #[serde(default)]
420 pub survival_noise_center: Option<Vec<f64>>,
421 #[serde(default)]
422 pub survival_noise_scale: Option<Vec<f64>>,
423 #[serde(default)]
424 pub survival_noise_non_intercept_start: Option<usize>,
425 #[serde(default)]
428 pub survival_noise_projection_ridge_alpha: Option<f64>,
429 #[serde(default)]
430 pub survival_distribution: Option<ResidualDistribution>,
431 #[serde(default)]
432 pub training_headers: Option<Vec<String>>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub training_table_kind: Option<String>,
446 #[serde(default)]
454 pub training_feature_ranges: Option<Vec<(f64, f64)>>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub group_metadata: Option<GroupMetadata>,
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
469 pub deployment_extensions: Vec<SavedDeploymentExtension>,
470 #[serde(default)]
472 pub transformation_response_knots: Option<Vec<f64>>,
473 #[serde(default)]
475 pub transformation_response_transform: Option<Vec<Vec<f64>>>,
476 #[serde(default)]
478 pub transformation_response_degree: Option<usize>,
479 #[serde(default)]
481 pub transformation_response_median: Option<f64>,
482 #[serde(default)]
486 pub transformation_score_calibration: Option<TransformationScoreCalibration>,
487 #[serde(default)]
488 pub resolved_termspec: Option<TermCollectionSpec>,
489 #[serde(default)]
490 pub resolved_termspec_noise: Option<TermCollectionSpec>,
491 #[serde(default)]
492 pub resolved_termspec_logslope: Option<TermCollectionSpec>,
493 #[serde(default)]
494 pub resolved_termspec_logslopes: Option<Vec<TermCollectionSpec>>,
495 #[serde(default)]
496 pub adaptive_regularization_diagnostics: Option<AdaptiveRegularizationDiagnostics>,
497 #[serde(default)]
509 pub gaussian_jackknife_plus:
510 Option<crate::inference::full_conformal::GaussianJackknifePlusStats>,
511 #[serde(default)]
525 pub full_conformal: Option<crate::inference::full_conformal::ExactFullConformalSubstrate>,
526}
527
528#[derive(Clone, Debug, Serialize, Deserialize)]
529pub struct SavedDeploymentExtension {
530 pub name: String,
531 pub kind: String,
532 pub term: String,
533 pub level: JsonValue,
534 pub level_bits: u64,
535 pub coefficient_index: usize,
536 pub coefficient_mean: f64,
537 pub coefficient_variance: f64,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub metadata: Option<JsonValue>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub prior: Option<JsonValue>,
542}
543
544pub fn append_deployment_extension_columns(
561 model: &FittedModelPayload,
562 data: ndarray::ArrayView2<'_, f64>,
563 col_map: &HashMap<String, usize>,
564 training_headers: Option<&Vec<String>>,
565 base_design: Array2<f64>,
566) -> Result<Array2<f64>, FittedModelError> {
567 if model.deployment_extensions.is_empty() {
568 return Ok(base_design);
569 }
570 if base_design.nrows() != data.nrows() {
571 return Err(FittedModelError::SchemaMismatch {
572 reason: format!(
573 "deployment extension design row mismatch: base design has {} rows but data has {}",
574 base_design.nrows(),
575 data.nrows()
576 ),
577 });
578 }
579 let spec = model
580 .resolved_termspec
581 .as_ref()
582 .ok_or_else(|| FittedModelError::MissingField {
583 reason: "deployment extension prediction requires saved resolved_termspec; refit"
584 .to_string(),
585 })?;
586 let n = base_design.nrows();
587 let p_old = base_design.ncols();
588 let mut extensions: Vec<&SavedDeploymentExtension> =
589 model.deployment_extensions.iter().collect();
590 extensions.sort_by_key(|extension| extension.coefficient_index);
591 for (tail_idx, extension) in extensions.iter().enumerate() {
592 let expected = p_old + tail_idx;
593 if extension.coefficient_index != expected {
594 return Err(FittedModelError::SchemaMismatch {
595 reason: format!(
596 "deployment extension '{}' has coefficient index {}, expected append-only index {}",
597 extension.name, extension.coefficient_index, expected
598 ),
599 });
600 }
601 }
602
603 let mut out = Array2::<f64>::zeros((n, p_old + extensions.len()));
604 out.slice_mut(ndarray::s![.., ..p_old]).assign(&base_design);
605 for (tail_idx, extension) in extensions.into_iter().enumerate() {
606 if extension.kind != "random-effect-level" {
607 return Err(FittedModelError::IncompatibleConfig {
608 reason: format!(
609 "unsupported deployment extension kind '{}' for '{}'",
610 extension.kind, extension.name
611 ),
612 });
613 }
614 let term = spec
615 .random_effect_terms
616 .iter()
617 .find(|term| term.name == extension.term)
618 .ok_or_else(|| FittedModelError::MissingField {
619 reason: format!(
620 "deployment extension '{}' references unknown random-effect term '{}'",
621 extension.name, extension.term
622 ),
623 })?;
624 let prediction_col = training_headers
625 .and_then(|headers| headers.get(term.feature_col))
626 .and_then(|name| col_map.get(name))
627 .copied()
628 .unwrap_or(term.feature_col);
629 if prediction_col >= data.ncols() {
630 return Err(FittedModelError::SchemaMismatch {
631 reason: format!(
632 "deployment extension '{}' feature column {} out of bounds for {} prediction columns",
633 extension.name,
634 prediction_col,
635 data.ncols()
636 ),
637 });
638 }
639 let col = p_old + tail_idx;
640 let level_bits = gam_data::canonical_level_bits(f64::from_bits(extension.level_bits));
641 for row in 0..n {
642 if gam_data::canonical_level_bits(data[[row, prediction_col]]) == level_bits {
643 out[[row, col]] = 1.0;
644 }
645 }
646 }
647 Ok(out)
648}
649
650#[derive(Clone, Debug, Serialize, Deserialize)]
651pub struct SavedLatentScoreContract {
652 pub semantics: String,
653 pub source_transform_id: Option<String>,
654 pub normalization_mean: f64,
655 pub normalization_sd: f64,
656 pub clip_eps: Option<f64>,
657 pub conditioning_columns: Vec<String>,
658}
659
660impl FittedModelPayload {
661 pub fn new(
662 version: u32,
663 formula: String,
664 model_kind: ModelKind,
665 family_state: FittedFamily,
666 family: String,
667 ) -> Self {
668 Self {
669 version,
670 formula,
671 model_kind,
672 family_state,
673 family,
674 inference_notes: Vec::new(),
675 used_device: false,
676 fit_result: None,
677 unified: None,
678 spline_scan: None,
679 residual_cascade: None,
680 data_schema: None,
681 link: None,
682 mixture_link_param_covariance: None,
683 sas_param_covariance: None,
684 formula_noise: None,
685 formula_logslope: None,
686 formula_logslopes: None,
687 offset_column: None,
688 noise_offset_column: None,
689 weight_column: None,
690 beta_noise: None,
691 noise_projection: None,
692 noise_center: None,
693 noise_scale: None,
694 noise_non_intercept_start: None,
695 noise_projection_ridge_alpha: None,
696 gaussian_response_scale: None,
697 linkwiggle_knots: None,
698 linkwiggle_degree: None,
699 beta_link_wiggle: None,
700 baseline_timewiggle_knots: None,
701 baseline_timewiggle_degree: None,
702 baseline_timewiggle_penalty_orders: None,
703 baseline_timewiggle_double_penalty: None,
704 beta_baseline_timewiggle: None,
705 beta_baseline_timewiggle_by_cause: None,
706 z_column: None,
707 z_columns: None,
708 latent_z_normalization: None,
709 latent_score_contract: None,
710 latent_measure: None,
711 latent_z_rank_int_calibration: None,
712 latent_z_conditional_calibration: None,
713 marginal_baseline: None,
714 logslope_baseline: None,
715 logslope_baselines: None,
716 score_warp_runtime: None,
717 link_deviation_runtime: None,
718 influence_absorber_width: None,
719 survival_entry: None,
720 survival_exit: None,
721 survival_event: None,
722 survivalspec: None,
723 survival_cause_count: None,
724 survival_endpoint_names: None,
725 survival_baseline_target: None,
726 survival_baseline_scale: None,
727 survival_baseline_shape: None,
728 survival_baseline_rate: None,
729 survival_baseline_makeham: None,
730 survival_time_basis: None,
731 survival_time_degree: None,
732 survival_time_knots: None,
733 survival_time_keep_cols: None,
734 survival_time_smooth_lambda: None,
735 survival_time_anchor: None,
736 survivalridge_lambda: None,
737 survival_likelihood: None,
738 survival_beta_time: None,
739 survival_beta_threshold: None,
740 survival_beta_log_sigma: None,
741 survival_noise_projection: None,
742 survival_noise_center: None,
743 survival_noise_scale: None,
744 survival_noise_non_intercept_start: None,
745 survival_noise_projection_ridge_alpha: None,
746 survival_distribution: None,
747 training_headers: None,
748 training_table_kind: None,
749 training_feature_ranges: None,
750 group_metadata: None,
751 deployment_extensions: Vec::new(),
752 transformation_response_knots: None,
753 transformation_response_transform: None,
754 transformation_response_degree: None,
755 transformation_response_median: None,
756 transformation_score_calibration: None,
757 resolved_termspec: None,
758 resolved_termspec_noise: None,
759 resolved_termspec_logslope: None,
760 resolved_termspec_logslopes: None,
761 adaptive_regularization_diagnostics: None,
762 gaussian_jackknife_plus: None,
763 full_conformal: None,
764 }
765 }
766
767 pub fn set_training_feature_metadata(
768 &mut self,
769 headers: Vec<String>,
770 feature_ranges: Vec<(f64, f64)>,
771 ) {
772 self.training_headers = Some(headers);
773 self.training_feature_ranges = Some(feature_ranges);
774 }
775
776 fn synchronize_empty_feature_contract(&mut self) {
777 if self.fit_result.is_none() {
778 return;
779 }
780 let Some(schema) = self.data_schema.as_ref() else {
781 return;
782 };
783 if !schema.columns.is_empty() {
784 return;
785 }
786 self.training_headers.get_or_insert_with(Vec::new);
787 self.resolved_termspec
788 .get_or_insert_with(|| TermCollectionSpec {
789 linear_terms: Vec::new(),
790 smooth_terms: Vec::new(),
791 random_effect_terms: Vec::new(),
792 });
793 }
794
795 pub fn apply_survival_time_basis(
803 &mut self,
804 snapshot: &crate::survival::construction::SavedSurvivalTimeBasis,
805 ) {
806 self.survival_time_basis = Some(snapshot.basisname.clone());
807 self.survival_time_degree = snapshot.degree;
808 self.survival_time_knots = snapshot.knots.clone();
809 self.survival_time_keep_cols = snapshot.keep_cols.clone();
810 self.survival_time_smooth_lambda = snapshot.smooth_lambda;
811 self.survival_time_anchor = Some(snapshot.anchor);
812 }
813
814 fn validate_payload_version(&self) -> Result<(), FittedModelError> {
815 if self.version != MODEL_PAYLOAD_VERSION {
816 return Err(FittedModelError::SchemaMismatch {
817 reason: format!(
818 "saved model payload schema mismatch: file has version={}, \
819 this binary expects MODEL_PAYLOAD_VERSION={}. \
820 Refit with the current CLI, or rebuild the reader at the same \
821 version the model was written with.",
822 self.version, MODEL_PAYLOAD_VERSION
823 ),
824 });
825 }
826 Ok(())
827 }
828}
829
830#[derive(Clone, Serialize, Deserialize)]
831#[serde(tag = "model_type", rename_all = "kebab-case")]
832pub enum FittedModel {
833 Standard { payload: FittedModelPayload },
834 LocationScale { payload: FittedModelPayload },
835 MarginalSlope { payload: FittedModelPayload },
836 Survival { payload: FittedModelPayload },
837 TransformationNormal { payload: FittedModelPayload },
838}
839
840#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
841#[serde(rename_all = "kebab-case")]
842pub enum ModelKind {
843 Standard,
844 LocationScale,
845 MarginalSlope,
846 Survival,
847 TransformationNormal,
848}
849
850#[derive(Clone, Debug, Serialize, Deserialize)]
851#[serde(tag = "family_kind", rename_all = "kebab-case")]
852pub enum FittedFamily {
853 Standard {
854 likelihood: LikelihoodSpec,
855 #[serde(default)]
856 link: Option<StandardLink>,
857 #[serde(default)]
858 latent_cloglog_state: Option<LatentCLogLogState>,
859 #[serde(default)]
860 mixture_state: Option<MixtureLinkState>,
861 #[serde(default)]
862 sas_state: Option<SasLinkState>,
863 },
864 LocationScale {
865 likelihood: LikelihoodSpec,
866 #[serde(default)]
867 base_link: Option<InverseLink>,
868 },
869 MarginalSlope {
870 likelihood: LikelihoodSpec,
871 base_link: InverseLink,
872 frailty: FrailtySpec,
873 },
874 Survival {
875 likelihood: LikelihoodSpec,
876 #[serde(default)]
877 survival_likelihood: Option<String>,
878 #[serde(default)]
879 survival_distribution: Option<ResidualDistribution>,
880 frailty: FrailtySpec,
881 },
882 LatentSurvival {
883 frailty: FrailtySpec,
884 },
885 LatentBinary {
886 frailty: FrailtySpec,
887 },
888 TransformationNormal {
889 likelihood: LikelihoodSpec,
890 },
891}
892
893#[derive(Clone, Copy, Debug, Eq, PartialEq)]
894pub enum PredictModelClass {
895 Standard,
896 GaussianLocationScale,
897 BinomialLocationScale,
898 DispersionLocationScale,
903 BernoulliMarginalSlope,
904 Survival,
905 TransformationNormal,
906}
907
908impl PredictModelClass {
909 #[inline]
910 pub const fn name(self) -> &'static str {
911 match self {
912 Self::Standard => "standard",
913 Self::GaussianLocationScale => "gaussian location-scale",
914 Self::BinomialLocationScale => "binomial location-scale",
915 Self::DispersionLocationScale => "dispersion location-scale",
916 Self::BernoulliMarginalSlope => "bernoulli marginal-slope",
917 Self::Survival => "survival",
918 Self::TransformationNormal => "transformation-normal",
919 }
920 }
921}
922
923#[derive(Clone, Debug)]
924pub struct SavedLinkWiggleRuntime {
925 pub knots: Vec<f64>,
926 pub degree: usize,
927 pub beta: Vec<f64>,
928}
929
930#[derive(Clone, Debug)]
931pub struct SavedBaselineTimeWiggleRuntime {
932 pub knots: Vec<f64>,
933 pub degree: usize,
934 pub penalty_orders: Vec<usize>,
935 pub double_penalty: bool,
936 pub beta: Vec<f64>,
937}
938
939pub use crate::bms::deviation_runtime::ParametricAnchorBlock;
942
943#[derive(Clone, Debug, Serialize, Deserialize)]
944pub struct SavedCompiledFlexBlock {
945 pub kernel: String,
946 pub breakpoints: Vec<f64>,
947 pub basis_dim: usize,
948 pub span_c0: Vec<Vec<f64>>,
949 pub span_c1: Vec<Vec<f64>>,
950 pub span_c2: Vec<Vec<f64>>,
951 pub span_c3: Vec<Vec<f64>>,
952 #[serde(default)]
958 pub anchor_correction: Option<Vec<Vec<f64>>>,
959 #[serde(default)]
963 pub anchor_components: Vec<SavedAnchorComponent>,
964}
965
966#[derive(Clone, Debug, Serialize, Deserialize)]
967pub struct SavedAnchorComponent {
968 pub kind: SavedAnchorKind,
969}
970
971#[derive(Clone, Debug, Serialize, Deserialize)]
972pub enum SavedAnchorKind {
973 Parametric {
974 block: ParametricAnchorBlock,
975 ncols: usize,
976 },
977 FlexEvaluation { ncols: usize },
982}
983
984#[derive(Clone, Debug)]
985pub struct SavedPredictionRuntime {
986 pub model_class: PredictModelClass,
987 pub likelihood: LikelihoodSpec,
988 pub inverse_link: Option<InverseLink>,
989 pub link_wiggle: Option<SavedLinkWiggleRuntime>,
990 pub baseline_time_wiggle: Option<SavedBaselineTimeWiggleRuntime>,
991 pub score_warp: Option<SavedCompiledFlexBlock>,
992 pub link_deviation: Option<SavedCompiledFlexBlock>,
993 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
997 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
1003 pub influence_absorber_width: Option<usize>,
1013}
1014
1015pub fn gaussian_location_scale_mean_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1016 fit.block_by_role(BlockRole::Location)
1017 .or_else(|| fit.block_by_role(BlockRole::Mean))
1018 .map(|block| block.beta.clone())
1019}
1020
1021pub fn binomial_location_scale_threshold_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1022 fit.block_by_role(BlockRole::Threshold)
1023 .or_else(|| fit.block_by_role(BlockRole::Location))
1024 .or_else(|| fit.block_by_role(BlockRole::Mean))
1025 .map(|block| block.beta.clone())
1026}
1027
1028pub fn location_scale_noise_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1029 fit.block_by_role(BlockRole::Scale)
1030 .map(|block| block.beta.clone())
1031}
1032
1033fn is_dispersion_location_scale_response(response: &gam_problem::types::ResponseFamily) -> bool {
1042 use gam_problem::types::ResponseFamily;
1043 matches!(
1044 response,
1045 ResponseFamily::NegativeBinomial { .. }
1046 | ResponseFamily::Gamma
1047 | ResponseFamily::Beta { .. }
1048 | ResponseFamily::Tweedie { .. }
1049 )
1050}
1051
1052fn validate_location_scale_saved_fit(
1053 fit: &UnifiedFitResult,
1054 model_class: PredictModelClass,
1055 link_wiggle: Option<&SavedLinkWiggleRuntime>,
1056) -> Result<(), FittedModelError> {
1057 let primary = match model_class {
1058 PredictModelClass::GaussianLocationScale | PredictModelClass::DispersionLocationScale => {
1062 gaussian_location_scale_mean_beta(fit)
1063 }
1064 PredictModelClass::BinomialLocationScale => binomial_location_scale_threshold_beta(fit),
1065 _ => None,
1066 }
1067 .ok_or_else(|| FittedModelError::MissingField {
1068 reason: match model_class {
1069 PredictModelClass::GaussianLocationScale => {
1070 "gaussian-location-scale saved fit is missing mean/location block".to_string()
1071 }
1072 PredictModelClass::DispersionLocationScale => {
1073 "dispersion-location-scale saved fit is missing mean/location block".to_string()
1074 }
1075 PredictModelClass::BinomialLocationScale => {
1076 "binomial-location-scale saved fit is missing threshold/location block".to_string()
1077 }
1078 _ => "location-scale saved fit is missing primary block".to_string(),
1079 },
1080 })?;
1081
1082 let scale = location_scale_noise_beta(fit).ok_or_else(|| FittedModelError::MissingField {
1083 reason: "location-scale saved fit is missing scale block".to_string(),
1084 })?;
1085 let expected =
1086 primary.len() + scale.len() + link_wiggle.map_or(0, |runtime| runtime.beta.len());
1087
1088 if let Some(cov) = fit.beta_covariance()
1089 && (cov.nrows() != expected || cov.ncols() != expected)
1090 {
1091 return Err(FittedModelError::SchemaMismatch {
1092 reason: format!(
1093 "location-scale saved conditional covariance shape mismatch: got {}x{}, expected {}x{}",
1094 cov.nrows(),
1095 cov.ncols(),
1096 expected,
1097 expected
1098 ),
1099 });
1100 }
1101 if let Some(cov) = fit.beta_covariance_corrected()
1102 && (cov.nrows() != expected || cov.ncols() != expected)
1103 {
1104 return Err(FittedModelError::SchemaMismatch {
1105 reason: format!(
1106 "location-scale saved corrected covariance shape mismatch: got {}x{}, expected {}x{}",
1107 cov.nrows(),
1108 cov.ncols(),
1109 expected,
1110 expected
1111 ),
1112 });
1113 }
1114 Ok(())
1115}
1116
1117fn validate_survival_saved_block_matches_payload(
1118 fit: &UnifiedFitResult,
1119 role: BlockRole,
1120 payload_beta: Option<&Vec<f64>>,
1121 label: &str,
1122) -> Result<usize, FittedModelError> {
1123 let block = fit
1124 .block_by_role(role)
1125 .ok_or_else(|| FittedModelError::MissingField {
1126 reason: format!("location-scale survival saved fit is missing {label} block"),
1127 })?;
1128 if let Some(saved) = payload_beta
1129 && block.beta.to_vec() != *saved
1130 {
1131 return Err(FittedModelError::SchemaMismatch {
1132 reason: format!(
1133 "location-scale survival saved {label} coefficients disagree with fit_result"
1134 ),
1135 });
1136 }
1137 Ok(block.beta.len())
1138}
1139
1140fn validate_survival_location_scale_saved_fit(
1141 payload: &FittedModelPayload,
1142 link_wiggle: Option<&SavedLinkWiggleRuntime>,
1143) -> Result<(), FittedModelError> {
1144 let fit = payload
1145 .fit_result
1146 .as_ref()
1147 .ok_or_else(|| FittedModelError::MissingField {
1148 reason: "location-scale survival model is missing canonical fit_result payload"
1149 .to_string(),
1150 })?;
1151 let p_time = validate_survival_saved_block_matches_payload(
1152 fit,
1153 BlockRole::Time,
1154 payload.survival_beta_time.as_ref(),
1155 "time",
1156 )?;
1157 let p_threshold = validate_survival_saved_block_matches_payload(
1158 fit,
1159 BlockRole::Threshold,
1160 payload.survival_beta_threshold.as_ref(),
1161 "threshold",
1162 )?;
1163 let p_log_sigma = validate_survival_saved_block_matches_payload(
1164 fit,
1165 BlockRole::Scale,
1166 payload.survival_beta_log_sigma.as_ref(),
1167 "log-sigma",
1168 )?;
1169 let p_wiggle = match link_wiggle {
1170 Some(runtime) => {
1171 let block = fit.block_by_role(BlockRole::LinkWiggle).ok_or_else(|| {
1172 FittedModelError::MissingField {
1173 reason: "location-scale survival saved fit is missing link-wiggle block"
1174 .to_string(),
1175 }
1176 })?;
1177 if block.beta.to_vec() != runtime.beta {
1178 return Err(FittedModelError::SchemaMismatch {
1179 reason:
1180 "location-scale survival saved link-wiggle coefficients disagree with fit_result"
1181 .to_string(),
1182 });
1183 }
1184 runtime.beta.len()
1185 }
1186 None => {
1187 if fit.block_by_role(BlockRole::LinkWiggle).is_some() {
1188 return Err(FittedModelError::SchemaMismatch {
1189 reason:
1190 "location-scale survival saved fit has a LinkWiggle block without payload metadata"
1191 .to_string(),
1192 });
1193 }
1194 0
1195 }
1196 };
1197 let expected = p_time + p_threshold + p_log_sigma + p_wiggle;
1198
1199 if let Some(cov) = fit.beta_covariance()
1200 && (cov.nrows() != expected || cov.ncols() != expected)
1201 {
1202 return Err(FittedModelError::SchemaMismatch {
1203 reason: format!(
1204 "location-scale survival saved conditional covariance shape mismatch: got {}x{}, expected {}x{}",
1205 cov.nrows(),
1206 cov.ncols(),
1207 expected,
1208 expected
1209 ),
1210 });
1211 }
1212 if let Some(cov) = fit.beta_covariance_corrected()
1213 && (cov.nrows() != expected || cov.ncols() != expected)
1214 {
1215 return Err(FittedModelError::SchemaMismatch {
1216 reason: format!(
1217 "location-scale survival saved corrected covariance shape mismatch: got {}x{}, expected {}x{}",
1218 cov.nrows(),
1219 cov.ncols(),
1220 expected,
1221 expected
1222 ),
1223 });
1224 }
1225 Ok(())
1226}
1227
1228fn validate_marginal_slope_saved_fit(
1229 fit: &UnifiedFitResult,
1230 score_warp: Option<&SavedCompiledFlexBlock>,
1231 link_deviation: Option<&SavedCompiledFlexBlock>,
1232 fit_label: &str,
1233) -> Result<(), FittedModelError> {
1234 validate_marginal_slope_saved_fit_impl(
1235 fit,
1236 score_warp,
1237 link_deviation,
1238 fit_label,
1239 "bernoulli",
1240 2,
1241 "marginal, logslope",
1242 )
1243}
1244
1245fn validate_survival_marginal_slope_saved_fit(
1246 fit: &UnifiedFitResult,
1247 score_warp: Option<&SavedCompiledFlexBlock>,
1248 link_deviation: Option<&SavedCompiledFlexBlock>,
1249 fit_label: &str,
1250) -> Result<(), FittedModelError> {
1251 validate_marginal_slope_saved_fit_impl(
1252 fit,
1253 score_warp,
1254 link_deviation,
1255 fit_label,
1256 "survival",
1257 3,
1258 "time, marginal, slope",
1259 )
1260}
1261
1262fn validate_marginal_slope_saved_fit_impl(
1270 fit: &UnifiedFitResult,
1271 score_warp: Option<&SavedCompiledFlexBlock>,
1272 link_deviation: Option<&SavedCompiledFlexBlock>,
1273 fit_label: &str,
1274 family_kind: &str,
1275 base_block_count: usize,
1276 base_block_role_list: &str,
1277) -> Result<(), FittedModelError> {
1278 let expected_blocks = base_block_count
1279 + usize::from(score_warp.is_some())
1280 + usize::from(link_deviation.is_some());
1281 if fit.blocks.len() != expected_blocks {
1282 let score_warp_suffix = if score_warp.is_some() {
1283 ", score-warp"
1284 } else {
1285 ""
1286 };
1287 let link_deviation_suffix = if link_deviation.is_some() {
1288 ", link-deviation"
1289 } else {
1290 ""
1291 };
1292 return Err(FittedModelError::SchemaMismatch {
1293 reason: format!(
1294 "{family_kind} marginal-slope saved {fit_label} requires {expected_blocks} blocks [{base_block_role_list}{score_warp_suffix}{link_deviation_suffix}], got {}",
1295 fit.blocks.len(),
1296 ),
1297 });
1298 }
1299 if let Some(runtime) = score_warp {
1300 let beta = &fit.blocks[base_block_count].beta;
1301 if beta.len() != runtime.basis_dim {
1302 return Err(FittedModelError::SchemaMismatch {
1303 reason: format!(
1304 "{family_kind} marginal-slope saved {fit_label} score-warp coefficient mismatch: beta has {} entries but runtime expects {}",
1305 beta.len(),
1306 runtime.basis_dim
1307 ),
1308 });
1309 }
1310 }
1311 if let Some(runtime) = link_deviation {
1312 let idx = base_block_count + usize::from(score_warp.is_some());
1313 let beta = &fit.blocks[idx].beta;
1314 if beta.len() != runtime.basis_dim {
1315 return Err(FittedModelError::SchemaMismatch {
1316 reason: format!(
1317 "{family_kind} marginal-slope saved {fit_label} link-deviation coefficient mismatch: beta has {} entries but runtime expects {}",
1318 beta.len(),
1319 runtime.basis_dim
1320 ),
1321 });
1322 }
1323 }
1324 Ok(())
1325}
1326
1327impl SavedLinkWiggleRuntime {
1328 fn validate_monotone_derivative(
1329 &self,
1330 q0: &Array1<f64>,
1331 ) -> Result<Array1<f64>, FittedModelError> {
1332 let d_constrained = self.constrained_basis(q0, BasisOptions::first_derivative())?;
1339 let beta_link_wiggle = Array1::from_vec(self.beta.clone());
1340 let dq_dq0 = d_constrained.dot(&beta_link_wiggle) + 1.0;
1341 if let Some((idx, value)) = dq_dq0.iter().copied().enumerate().find(|(_, v)| *v <= 0.0) {
1342 return Err(FittedModelError::PayloadCorrupt {
1343 reason: format!(
1344 "saved link-wiggle is not monotone at row {idx}: dq/dq0={value:.3e} <= 0"
1345 ),
1346 });
1347 }
1348 Ok(dq_dq0)
1349 }
1350
1351 pub fn constrained_basis(
1352 &self,
1353 q0: &Array1<f64>,
1354 basis_options: BasisOptions,
1355 ) -> Result<Array2<f64>, FittedModelError> {
1356 let knot_arr = Array1::from_vec(self.knots.clone());
1357 let constrained = monotone_wiggle_basis_with_derivative_order(
1358 q0.view(),
1359 &knot_arr,
1360 self.degree,
1361 basis_options.derivative_order,
1362 )
1363 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
1364 if constrained.ncols() != self.beta.len() {
1365 return Err(FittedModelError::SchemaMismatch {
1366 reason: format!(
1367 "saved link-wiggle dimension mismatch: coefficients have {} entries but basis has {} columns",
1368 self.beta.len(),
1369 constrained.ncols()
1370 ),
1371 });
1372 }
1373 Ok(constrained)
1374 }
1375
1376 pub fn design(&self, q0: &Array1<f64>) -> Result<Array2<f64>, FittedModelError> {
1377 self.validate_monotone_derivative(q0)?;
1378 self.constrained_basis(q0, BasisOptions::value())
1379 }
1380
1381 pub fn basis_row_scalar(&self, q0: f64) -> Result<Array1<f64>, FittedModelError> {
1382 let q = Array1::from_vec(vec![q0]);
1383 let x = self.design(&q)?;
1384 if x.nrows() != 1 {
1385 return Err(FittedModelError::SchemaMismatch {
1386 reason: format!(
1387 "saved link-wiggle scalar evaluation expected 1 row, got {}",
1388 x.nrows()
1389 ),
1390 });
1391 }
1392 Ok(x.row(0).to_owned())
1393 }
1394
1395 pub fn apply(&self, q0: &Array1<f64>) -> Result<Array1<f64>, FittedModelError> {
1396 self.validate_monotone_derivative(q0)?;
1397 let xwiggle = self.constrained_basis(q0, BasisOptions::value())?;
1398 let beta_link_wiggle = Array1::from_vec(self.beta.clone());
1399 Ok(q0 + &xwiggle.dot(&beta_link_wiggle))
1400 }
1401
1402 pub fn derivative_q0(&self, q0: &Array1<f64>) -> Result<Array1<f64>, FittedModelError> {
1403 self.validate_monotone_derivative(q0)
1404 }
1405}
1406
1407impl SavedBaselineTimeWiggleRuntime {
1408 pub fn validate_global_monotonicity(&self) -> Result<(), FittedModelError> {
1409 validate_monotone_wiggle_beta_nonnegative(&self.beta, "saved baseline-timewiggle")
1410 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })
1411 }
1412}
1413
1414impl SavedCompiledFlexBlock {
1415 pub(crate) fn validate_exact_replay_contract(&self) -> Result<(), FittedModelError> {
1416 if self.kernel.is_empty() {
1417 return Err(FittedModelError::SchemaMismatch {
1418 reason: "saved anchored deviation runtime is missing the exact kernel marker"
1419 .to_string(),
1420 });
1421 }
1422 if self.kernel != crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL {
1423 return Err(FittedModelError::IncompatibleConfig {
1424 reason: format!(
1425 "saved anchored deviation runtime uses unsupported kernel '{}'; expected {}",
1426 self.kernel,
1427 crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL
1428 ),
1429 });
1430 }
1431 if self.basis_dim == 0 {
1432 return Err(FittedModelError::SchemaMismatch {
1433 reason: format!(
1434 "saved anchored deviation runtime basis_dim must be positive, got {}",
1435 self.basis_dim
1436 ),
1437 });
1438 }
1439 if self.breakpoints.len() < 2 {
1440 return Err(FittedModelError::SchemaMismatch {
1441 reason: format!(
1442 "saved anchored deviation runtime requires at least two breakpoints, got {}",
1443 self.breakpoints.len()
1444 ),
1445 });
1446 }
1447 for window in self.breakpoints.windows(2) {
1448 let left = window[0];
1449 let right = window[1];
1450 if !left.is_finite() || !right.is_finite() || right <= left {
1451 return Err(FittedModelError::PayloadCorrupt {
1452 reason: format!(
1453 "saved anchored deviation runtime breakpoints must be finite and strictly increasing, got [{left}, {right}]"
1454 ),
1455 });
1456 }
1457 }
1458 let span_count = self.breakpoints.len() - 1;
1459 self.validate_coefficient_matrix(&self.span_c0, "c0", span_count)?;
1460 self.validate_coefficient_matrix(&self.span_c1, "c1", span_count)?;
1461 self.validate_coefficient_matrix(&self.span_c2, "c2", span_count)?;
1462 self.validate_coefficient_matrix(&self.span_c3, "c3", span_count)?;
1463 self.validate_c2_span_continuity()?;
1464 self.validate_anchor_residual_shape()?;
1465 Ok(())
1466 }
1467
1468 fn validate_anchor_residual_shape(&self) -> Result<(), FittedModelError> {
1469 let coeffs = match self.anchor_correction.as_ref() {
1470 Some(c) => c,
1471 None => {
1472 if !self.anchor_components.is_empty() {
1473 return Err(FittedModelError::SchemaMismatch {
1474 reason:
1475 "saved anchored deviation runtime has anchor_components but no anchor_correction"
1476 .to_string(),
1477 });
1478 }
1479 return Ok(());
1480 }
1481 };
1482 let d: usize = self
1483 .anchor_components
1484 .iter()
1485 .map(|c| match &c.kind {
1486 SavedAnchorKind::Parametric { ncols, .. } => *ncols,
1487 SavedAnchorKind::FlexEvaluation { ncols } => *ncols,
1488 })
1489 .sum();
1490 if coeffs.len() != d {
1491 return Err(FittedModelError::SchemaMismatch {
1492 reason: format!(
1493 "saved anchored deviation runtime anchor_correction has {} rows; expected {} (sum of component ncols)",
1494 coeffs.len(),
1495 d,
1496 ),
1497 });
1498 }
1499 for (i, row) in coeffs.iter().enumerate() {
1500 if row.len() != self.basis_dim {
1501 return Err(FittedModelError::SchemaMismatch {
1502 reason: format!(
1503 "saved anchored deviation runtime anchor_correction row {} has width {}, expected basis_dim {}",
1504 i,
1505 row.len(),
1506 self.basis_dim,
1507 ),
1508 });
1509 }
1510 for (j, &v) in row.iter().enumerate() {
1511 if !v.is_finite() {
1512 return Err(FittedModelError::PayloadCorrupt {
1513 reason: format!(
1514 "saved anchored deviation runtime anchor_correction ({i},{j}) is non-finite"
1515 ),
1516 });
1517 }
1518 }
1519 }
1520 Ok(())
1521 }
1522
1523 fn validate_c2_span_continuity(&self) -> Result<(), FittedModelError> {
1524 const TOL: f64 = 1e-8;
1525 for span_idx in 1..self.breakpoints.len() - 1 {
1526 let left_span = span_idx - 1;
1527 let right_span = span_idx;
1528 let width = self.breakpoints[span_idx] - self.breakpoints[left_span];
1529 for basis_idx in 0..self.basis_dim {
1530 let left_value = self.span_c0[left_span][basis_idx]
1531 + self.span_c1[left_span][basis_idx] * width
1532 + self.span_c2[left_span][basis_idx] * width * width
1533 + self.span_c3[left_span][basis_idx] * width * width * width;
1534 let left_d1 = self.span_c1[left_span][basis_idx]
1535 + 2.0 * self.span_c2[left_span][basis_idx] * width
1536 + 3.0 * self.span_c3[left_span][basis_idx] * width * width;
1537 let left_d2 = 2.0 * self.span_c2[left_span][basis_idx]
1538 + 6.0 * self.span_c3[left_span][basis_idx] * width;
1539 let right_value = self.span_c0[right_span][basis_idx];
1540 let right_d1 = self.span_c1[right_span][basis_idx];
1541 let right_d2 = 2.0 * self.span_c2[right_span][basis_idx];
1542 if (left_value - right_value).abs() > TOL
1543 || (left_d1 - right_d1).abs() > TOL
1544 || (left_d2 - right_d2).abs() > TOL
1545 {
1546 return Err(FittedModelError::SchemaMismatch {
1547 reason: format!(
1548 "saved anchored deviation runtime must be C2 cubic at breakpoint {span_idx}, basis {basis_idx}: value jump={:.3e}, d1 jump={:.3e}, d2 jump={:.3e}",
1549 left_value - right_value,
1550 left_d1 - right_d1,
1551 left_d2 - right_d2
1552 ),
1553 });
1554 }
1555 }
1556 }
1557 Ok(())
1558 }
1559
1560 fn validate_coefficient_matrix(
1561 &self,
1562 matrix: &[Vec<f64>],
1563 label: &str,
1564 expected_rows: usize,
1565 ) -> Result<(), FittedModelError> {
1566 if matrix.len() != expected_rows {
1567 return Err(FittedModelError::SchemaMismatch {
1568 reason: format!(
1569 "saved anchored deviation runtime {label} row count mismatch: got {}, expected {}",
1570 matrix.len(),
1571 expected_rows
1572 ),
1573 });
1574 }
1575 for (row_idx, row) in matrix.iter().enumerate() {
1576 if row.len() != self.basis_dim {
1577 return Err(FittedModelError::SchemaMismatch {
1578 reason: format!(
1579 "saved anchored deviation runtime {label} row {} has width {}, expected {}",
1580 row_idx,
1581 row.len(),
1582 self.basis_dim
1583 ),
1584 });
1585 }
1586 for (j, &value) in row.iter().enumerate() {
1587 if !value.is_finite() {
1588 return Err(FittedModelError::PayloadCorrupt {
1589 reason: format!(
1590 "saved anchored deviation runtime {label} entry ({row_idx},{j}) is non-finite"
1591 ),
1592 });
1593 }
1594 }
1595 }
1596 Ok(())
1597 }
1598
1599 fn right_boundary_basis_value(&self, basis_idx: usize) -> f64 {
1600 let last_span = self.breakpoints.len() - 2;
1601 let width = self.breakpoints[last_span + 1] - self.breakpoints[last_span];
1602 self.span_c0[last_span][basis_idx]
1603 + self.span_c1[last_span][basis_idx] * width
1604 + self.span_c2[last_span][basis_idx] * width * width
1605 + self.span_c3[last_span][basis_idx] * width * width * width
1606 }
1607
1608 fn evaluate_span_polynomial_design(
1609 &self,
1610 values: &Array1<f64>,
1611 derivative_order: usize,
1612 ) -> Result<Array2<f64>, FittedModelError> {
1613 self.validate_exact_replay_contract()?;
1614 let (left_ep, right_ep) = self.support_interval()?;
1615 let mut out = Array2::<f64>::zeros((values.len(), self.basis_dim));
1616 for (row_idx, &value) in values.iter().enumerate() {
1617 if !value.is_finite() {
1618 return Err(FittedModelError::PayloadCorrupt {
1619 reason: format!(
1620 "saved anchored deviation runtime design value at row {row_idx} is non-finite ({value})"
1621 ),
1622 });
1623 }
1624 if value < left_ep {
1625 if derivative_order == 0 {
1626 for basis_idx in 0..self.basis_dim {
1627 out[[row_idx, basis_idx]] = self.span_c0[0][basis_idx];
1628 }
1629 }
1630 continue;
1631 }
1632 if value > right_ep {
1633 if derivative_order == 0 {
1634 for basis_idx in 0..self.basis_dim {
1635 out[[row_idx, basis_idx]] = self.right_boundary_basis_value(basis_idx);
1636 }
1637 }
1638 continue;
1639 }
1640 let span_idx = self.left_biased_span_index_for(value)?;
1641 let t = value - self.breakpoints[span_idx];
1642 for basis_idx in 0..self.basis_dim {
1643 let c0 = self.span_c0[span_idx][basis_idx];
1644 let c1 = self.span_c1[span_idx][basis_idx];
1645 let c2 = self.span_c2[span_idx][basis_idx];
1646 let c3 = self.span_c3[span_idx][basis_idx];
1647 out[[row_idx, basis_idx]] = match derivative_order {
1648 0 => c0 + c1 * t + c2 * t * t + c3 * t * t * t,
1649 1 => c1 + 2.0 * c2 * t + 3.0 * c3 * t * t,
1650 2 => 2.0 * c2 + 6.0 * c3 * t,
1651 3 => 6.0 * c3,
1652 4 => 0.0,
1653 other => {
1654 return Err(FittedModelError::IncompatibleConfig {
1655 reason: format!(
1656 "saved anchored deviation runtime only supports derivative orders up to 4, got {other}"
1657 ),
1658 });
1659 }
1660 };
1661 }
1662 }
1663 Ok(out)
1664 }
1665
1666 pub fn breakpoints(&self) -> Result<Vec<f64>, FittedModelError> {
1667 self.validate_exact_replay_contract()?;
1668 Ok(self.breakpoints.clone())
1669 }
1670
1671 pub fn span_count(&self) -> Result<usize, FittedModelError> {
1672 Ok(self.breakpoints()?.windows(2).count())
1673 }
1674
1675 pub fn span_index_for(&self, value: f64) -> Result<usize, FittedModelError> {
1676 let points = self.breakpoints()?;
1677 span_index_for_breakpoints(&points, value, "saved anchored deviation span lookup")
1678 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })
1679 }
1680
1681 fn left_biased_span_index_for(&self, value: f64) -> Result<usize, FittedModelError> {
1682 let mut span_idx = span_index_for_breakpoints(
1683 &self.breakpoints,
1684 value,
1685 "saved anchored deviation span lookup",
1686 )
1687 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
1688 if span_idx > 0 && value == self.breakpoints[span_idx] {
1691 span_idx -= 1;
1692 }
1693 Ok(span_idx)
1694 }
1695
1696 pub fn local_cubic_on_span(
1697 &self,
1698 beta: &Array1<f64>,
1699 span_idx: usize,
1700 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1701 self.validate_exact_replay_contract()?;
1702 if beta.len() != self.basis_dim {
1703 return Err(FittedModelError::SchemaMismatch {
1704 reason: format!(
1705 "saved anchored deviation coefficient length mismatch: got {}, expected {}",
1706 beta.len(),
1707 self.basis_dim
1708 ),
1709 });
1710 }
1711 self.local_cubic_on_span_validated(beta, span_idx)
1712 }
1713
1714 fn local_cubic_on_span_validated(
1715 &self,
1716 beta: &Array1<f64>,
1717 span_idx: usize,
1718 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1719 let points = &self.breakpoints;
1720 if span_idx + 1 >= points.len() {
1721 return Err(FittedModelError::SchemaMismatch {
1722 reason: format!(
1723 "saved anchored deviation span index {} out of range for {} spans",
1724 span_idx,
1725 points.len() - 1
1726 ),
1727 });
1728 }
1729 let left = points[span_idx];
1730 let right = points[span_idx + 1];
1731 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1732 left,
1733 right,
1734 c0: self.span_c0[span_idx]
1735 .iter()
1736 .zip(beta.iter())
1737 .map(|(coeff, weight)| coeff * weight)
1738 .sum(),
1739 c1: self.span_c1[span_idx]
1740 .iter()
1741 .zip(beta.iter())
1742 .map(|(coeff, weight)| coeff * weight)
1743 .sum(),
1744 c2: self.span_c2[span_idx]
1745 .iter()
1746 .zip(beta.iter())
1747 .map(|(coeff, weight)| coeff * weight)
1748 .sum(),
1749 c3: self.span_c3[span_idx]
1750 .iter()
1751 .zip(beta.iter())
1752 .map(|(coeff, weight)| coeff * weight)
1753 .sum(),
1754 })
1755 }
1756
1757 pub fn basis_span_cubic(
1758 &self,
1759 span_idx: usize,
1760 basis_idx: usize,
1761 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1762 self.validate_exact_replay_contract()?;
1763 if basis_idx >= self.basis_dim {
1764 return Err(FittedModelError::SchemaMismatch {
1765 reason: format!(
1766 "saved anchored deviation basis index {} out of range for {} coefficients",
1767 basis_idx, self.basis_dim
1768 ),
1769 });
1770 }
1771 self.basis_span_cubic_validated(span_idx, basis_idx)
1772 }
1773
1774 fn basis_span_cubic_validated(
1775 &self,
1776 span_idx: usize,
1777 basis_idx: usize,
1778 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1779 let points = &self.breakpoints;
1780 if span_idx + 1 >= points.len() {
1781 return Err(FittedModelError::SchemaMismatch {
1782 reason: format!(
1783 "saved anchored deviation span index {} out of range for {} spans",
1784 span_idx,
1785 points.len() - 1
1786 ),
1787 });
1788 }
1789 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1790 left: points[span_idx],
1791 right: points[span_idx + 1],
1792 c0: self.span_c0[span_idx][basis_idx],
1793 c1: self.span_c1[span_idx][basis_idx],
1794 c2: self.span_c2[span_idx][basis_idx],
1795 c3: self.span_c3[span_idx][basis_idx],
1796 })
1797 }
1798
1799 pub fn basis_cubic_at(
1800 &self,
1801 basis_idx: usize,
1802 value: f64,
1803 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1804 self.validate_exact_replay_contract()?;
1805 if basis_idx >= self.basis_dim {
1806 return Err(FittedModelError::SchemaMismatch {
1807 reason: format!(
1808 "saved anchored deviation basis index {} out of range for {} coefficients",
1809 basis_idx, self.basis_dim
1810 ),
1811 });
1812 }
1813 let (left_ep, right_ep) = self.support_interval()?;
1814 if value < left_ep {
1815 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1816 left: left_ep,
1817 right: left_ep + 1.0,
1818 c0: self.span_c0[0][basis_idx],
1819 c1: 0.0,
1820 c2: 0.0,
1821 c3: 0.0,
1822 });
1823 }
1824 if value > right_ep {
1825 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1826 left: right_ep,
1827 right: right_ep + 1.0,
1828 c0: self.right_boundary_basis_value(basis_idx),
1829 c1: 0.0,
1830 c2: 0.0,
1831 c3: 0.0,
1832 });
1833 }
1834 let span_idx = self.left_biased_span_index_for(value)?;
1835 self.basis_span_cubic_validated(span_idx, basis_idx)
1836 }
1837
1838 pub fn local_cubic_at(
1839 &self,
1840 beta: &Array1<f64>,
1841 value: f64,
1842 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1843 self.validate_exact_replay_contract()?;
1844 if beta.len() != self.basis_dim {
1845 return Err(FittedModelError::SchemaMismatch {
1846 reason: format!(
1847 "saved anchored deviation coefficient length mismatch: got {}, expected {}",
1848 beta.len(),
1849 self.basis_dim
1850 ),
1851 });
1852 }
1853 let (left_ep, right_ep) = self.support_interval()?;
1854 if value < left_ep {
1855 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1856 left: left_ep,
1857 right: left_ep + 1.0,
1858 c0: self.span_c0[0]
1859 .iter()
1860 .zip(beta.iter())
1861 .map(|(coeff, weight)| coeff * weight)
1862 .sum(),
1863 c1: 0.0,
1864 c2: 0.0,
1865 c3: 0.0,
1866 });
1867 }
1868 if value > right_ep {
1869 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1870 left: right_ep,
1871 right: right_ep + 1.0,
1872 c0: (0..self.basis_dim)
1873 .map(|basis_idx| self.right_boundary_basis_value(basis_idx) * beta[basis_idx])
1874 .sum(),
1875 c1: 0.0,
1876 c2: 0.0,
1877 c3: 0.0,
1878 });
1879 }
1880 let span_idx = self.left_biased_span_index_for(value)?;
1881 self.local_cubic_on_span_validated(beta, span_idx)
1882 }
1883
1884 fn support_interval(&self) -> Result<(f64, f64), FittedModelError> {
1885 let points = self.breakpoints()?;
1886 match (points.first(), points.last()) {
1887 (Some(&left), Some(&right)) => Ok((left, right)),
1888 _ => Err(FittedModelError::MissingField {
1889 reason: "saved anchored deviation runtime is missing support breakpoints"
1890 .to_string(),
1891 }),
1892 }
1893 }
1894
1895 pub fn design(&self, values: &Array1<f64>) -> Result<Array2<f64>, FittedModelError> {
1896 self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)
1905 }
1906
1907 pub fn design_uncorrected(
1915 &self,
1916 values: &Array1<f64>,
1917 ) -> Result<Array2<f64>, FittedModelError> {
1918 self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)
1919 }
1920
1921 pub fn design_with_anchor_rows(
1930 &self,
1931 values: &Array1<f64>,
1932 anchor_rows: ndarray::ArrayView2<f64>,
1933 ) -> Result<Array2<f64>, FittedModelError> {
1934 let mut out =
1935 self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)?;
1936 if let Some(m_rows) = self.anchor_correction.as_ref() {
1937 let d = m_rows.len();
1938 if anchor_rows.nrows() != values.len() {
1939 return Err(FittedModelError::SchemaMismatch {
1940 reason: format!(
1941 "design_with_anchor_rows: anchor_rows has {} rows, expected {} (matching values)",
1942 anchor_rows.nrows(),
1943 values.len(),
1944 ),
1945 });
1946 }
1947 if anchor_rows.ncols() != d {
1948 return Err(FittedModelError::SchemaMismatch {
1949 reason: format!(
1950 "design_with_anchor_rows: anchor_rows has {} cols, expected {} (sum of component ncols)",
1951 anchor_rows.ncols(),
1952 d,
1953 ),
1954 });
1955 }
1956 let mut m_dense = Array2::<f64>::zeros((d, self.basis_dim));
1958 for (i, row) in m_rows.iter().enumerate() {
1959 if row.len() != self.basis_dim {
1960 return Err(FittedModelError::SchemaMismatch {
1961 reason: format!(
1962 "design_with_anchor_rows: anchor_correction row {} has length {}, expected basis_dim {}",
1963 i,
1964 row.len(),
1965 self.basis_dim,
1966 ),
1967 });
1968 }
1969 for (j, &v) in row.iter().enumerate() {
1970 m_dense[[i, j]] = v;
1971 }
1972 }
1973 let subtract = anchor_rows.dot(&m_dense);
1976 out = out - subtract;
1977 } else if anchor_rows.ncols() != 0 {
1978 return Err(FittedModelError::SchemaMismatch {
1979 reason: format!(
1980 "design_with_anchor_rows: runtime has no anchor residual but anchor_rows has {} cols",
1981 anchor_rows.ncols(),
1982 ),
1983 });
1984 }
1985 Ok(out)
1986 }
1987
1988 pub fn anchor_correction_matrix(
1996 &self,
1997 n_anchor_rows: ndarray::ArrayView2<f64>,
1998 ) -> Result<Option<Array2<f64>>, FittedModelError> {
1999 let Some(m_rows) = self.anchor_correction.as_ref() else {
2000 return Ok(None);
2001 };
2002 let d = m_rows.len();
2003 if n_anchor_rows.ncols() != d {
2004 return Err(FittedModelError::SchemaMismatch {
2005 reason: format!(
2006 "anchor_correction_matrix: anchor_rows has {} cols, expected {} (sum of component ncols)",
2007 n_anchor_rows.ncols(),
2008 d,
2009 ),
2010 });
2011 }
2012 let mut m_dense = Array2::<f64>::zeros((d, self.basis_dim));
2013 for (i, row) in m_rows.iter().enumerate() {
2014 if row.len() != self.basis_dim {
2015 return Err(FittedModelError::SchemaMismatch {
2016 reason: format!(
2017 "anchor_correction_matrix: M row {} has length {}, expected basis_dim {}",
2018 i,
2019 row.len(),
2020 self.basis_dim,
2021 ),
2022 });
2023 }
2024 for (j, &v) in row.iter().enumerate() {
2025 m_dense[[i, j]] = v;
2026 }
2027 }
2028 Ok(Some(n_anchor_rows.dot(&m_dense)))
2031 }
2032
2033 pub fn first_derivative_design(
2034 &self,
2035 values: &Array1<f64>,
2036 ) -> Result<Array2<f64>, FittedModelError> {
2037 self.evaluate_span_polynomial_design(
2038 values,
2039 BasisOptions::first_derivative().derivative_order,
2040 )
2041 }
2042
2043 pub fn second_derivative_design(
2044 &self,
2045 values: &Array1<f64>,
2046 ) -> Result<Array2<f64>, FittedModelError> {
2047 self.evaluate_span_polynomial_design(
2048 values,
2049 BasisOptions::second_derivative().derivative_order,
2050 )
2051 }
2052}
2053
2054impl FittedFamily {
2055 #[inline]
2056 pub fn likelihood(&self) -> LikelihoodSpec {
2057 let spec = match self {
2058 Self::Standard { likelihood, .. }
2059 | Self::LocationScale { likelihood, .. }
2060 | Self::MarginalSlope { likelihood, .. }
2061 | Self::Survival { likelihood, .. }
2062 | Self::TransformationNormal { likelihood, .. } => likelihood,
2063 Self::LatentSurvival { .. } | Self::LatentBinary { .. } => {
2064 return LikelihoodSpec::royston_parmar();
2065 }
2066 };
2067 spec.clone()
2068 }
2069
2070 #[inline]
2071 pub fn frailty(&self) -> Option<&FrailtySpec> {
2072 match self {
2073 Self::MarginalSlope { frailty, .. }
2074 | Self::Survival { frailty, .. }
2075 | Self::LatentSurvival { frailty }
2076 | Self::LatentBinary { frailty } => Some(frailty),
2077 _ => None,
2078 }
2079 }
2080}
2081
2082fn collect_smooth_extrapolation_axes(
2088 basis: &gam_terms::smooth::SmoothBasisSpec,
2089 n_training_headers: usize,
2090 out: &mut std::collections::HashSet<usize>,
2091) {
2092 use gam_terms::smooth::SmoothBasisSpec;
2093 let push = |col: usize, out: &mut std::collections::HashSet<usize>| {
2094 if col < n_training_headers {
2095 out.insert(col);
2096 }
2097 };
2098 match basis {
2099 SmoothBasisSpec::BSpline1D { feature_col, .. } => push(*feature_col, out),
2101 SmoothBasisSpec::TensorBSpline { feature_cols, .. } => {
2104 for &c in feature_cols {
2105 push(c, out);
2106 }
2107 }
2108 SmoothBasisSpec::ThinPlate { feature_cols, .. }
2115 | SmoothBasisSpec::Matern { feature_cols, .. }
2116 | SmoothBasisSpec::MeasureJet { feature_cols, .. }
2117 | SmoothBasisSpec::Duchon { feature_cols, .. } => {
2118 for &c in feature_cols {
2119 push(c, out);
2120 }
2121 }
2122 SmoothBasisSpec::FactorSmooth { spec } => {
2126 for &c in &spec.continuous_cols {
2127 push(c, out);
2128 }
2129 }
2130 SmoothBasisSpec::ByVariable { inner, .. }
2132 | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
2133 collect_smooth_extrapolation_axes(inner, n_training_headers, out)
2134 }
2135 SmoothBasisSpec::BySmooth { smooth, .. } => {
2136 collect_smooth_extrapolation_axes(smooth, n_training_headers, out)
2137 }
2138 SmoothBasisSpec::Sphere { .. }
2144 | SmoothBasisSpec::ConstantCurvature { .. }
2145 | SmoothBasisSpec::Pca { .. } => {}
2146 }
2147}
2148
2149fn collect_by_variable_numeric_axes(
2168 basis: &gam_terms::smooth::SmoothBasisSpec,
2169 n_training_headers: usize,
2170 out: &mut std::collections::HashSet<usize>,
2171) {
2172 use gam_terms::smooth::{BySmoothKind, ByVarKind, SmoothBasisSpec};
2173 match basis {
2174 SmoothBasisSpec::ByVariable {
2175 inner,
2176 by_col,
2177 kind,
2178 ..
2179 } => {
2180 if matches!(kind, BySmoothKind::Numeric) && *by_col < n_training_headers {
2181 out.insert(*by_col);
2182 }
2183 collect_by_variable_numeric_axes(inner, n_training_headers, out);
2184 }
2185 SmoothBasisSpec::BySmooth { smooth, by_kind } => {
2186 if let ByVarKind::Numeric { feature_col } = by_kind
2187 && *feature_col < n_training_headers
2188 {
2189 out.insert(*feature_col);
2190 }
2191 collect_by_variable_numeric_axes(smooth, n_training_headers, out);
2192 }
2193 SmoothBasisSpec::FactorSumToZero { inner, .. } => {
2194 collect_by_variable_numeric_axes(inner, n_training_headers, out);
2195 }
2196 _ => {}
2197 }
2198}
2199
2200impl FittedModel {
2201 pub fn axis_clip_to_training_ranges(
2210 &self,
2211 data: ndarray::ArrayView2<'_, f64>,
2212 col_map: &std::collections::HashMap<String, usize>,
2213 ) -> Option<ndarray::Array2<f64>> {
2214 let training_headers = self.training_headers.as_ref()?;
2215 let ranges = self.training_feature_ranges.as_ref()?;
2216 if training_headers.len() != ranges.len() {
2217 return None;
2218 }
2219 let mut kind_by_header: std::collections::HashMap<&str, ColumnKindTag> =
2220 std::collections::HashMap::new();
2221 if let Some(schema) = self.data_schema.as_ref() {
2222 for col in &schema.columns {
2223 kind_by_header.insert(col.name.as_str(), col.kind);
2224 }
2225 }
2226 let periodic_axes = self.training_periodic_axes(training_headers);
2232 let linear_axes = self.training_linear_axes(training_headers.len());
2240 let random_effect_axes = self.training_random_effect_axes(training_headers.len());
2245 let smooth_extrapolation_axes =
2253 self.training_smooth_extrapolation_axes(training_headers.len());
2254 let by_variable_axes = self.training_by_variable_numeric_axes(training_headers.len());
2260 let sphere_lat_bounds = self.training_sphere_latitude_bounds(training_headers);
2265 let mut clipped = data.to_owned();
2266 let mut any_clipped = false;
2267 for (col_in_training, (header, &(lo, hi))) in
2268 training_headers.iter().zip(ranges.iter()).enumerate()
2269 {
2270 let (lo, hi) = sphere_lat_bounds
2271 .get(&col_in_training)
2272 .copied()
2273 .unwrap_or((lo, hi));
2274 if !(lo.is_finite() && hi.is_finite()) || hi <= lo {
2275 continue;
2276 }
2277 if !matches!(
2278 kind_by_header.get(header.as_str()).copied(),
2279 Some(ColumnKindTag::Continuous)
2280 ) {
2281 continue;
2282 }
2283 if periodic_axes.contains(&col_in_training) {
2284 continue;
2285 }
2286 if linear_axes.contains(&col_in_training) {
2287 continue;
2288 }
2289 if random_effect_axes.contains(&col_in_training) {
2290 continue;
2291 }
2292 if smooth_extrapolation_axes.contains(&col_in_training) {
2293 continue;
2294 }
2295 if by_variable_axes.contains(&col_in_training) {
2296 continue;
2297 }
2298 let Some(&col_idx) = col_map.get(header) else {
2299 continue;
2300 };
2301 if col_idx >= clipped.ncols() {
2302 continue;
2303 }
2304 let mut col = clipped.column_mut(col_idx);
2305 for v in col.iter_mut() {
2306 if v.is_finite() {
2307 if *v < lo {
2308 *v = lo;
2309 any_clipped = true;
2310 } else if *v > hi {
2311 *v = hi;
2312 any_clipped = true;
2313 }
2314 }
2315 }
2316 }
2317 if any_clipped { Some(clipped) } else { None }
2318 }
2319
2320 fn saved_term_specs(&self) -> Vec<&TermCollectionSpec> {
2321 let mut specs: Vec<&TermCollectionSpec> = [
2322 self.resolved_termspec.as_ref(),
2323 self.resolved_termspec_noise.as_ref(),
2324 self.resolved_termspec_logslope.as_ref(),
2325 ]
2326 .into_iter()
2327 .flatten()
2328 .collect();
2329 if let Some(logslopes) = self.resolved_termspec_logslopes.as_ref() {
2330 specs.extend(logslopes.iter());
2331 }
2332 specs
2333 }
2334
2335 fn training_periodic_axes(
2342 &self,
2343 training_headers: &[String],
2344 ) -> std::collections::HashSet<usize> {
2345 use gam_terms::basis::BSplineKnotSpec;
2346 use gam_terms::smooth::SmoothBasisSpec;
2347 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2348 let Some(spec) = self.resolved_termspec.as_ref() else {
2349 return out;
2350 };
2351 for term in &spec.smooth_terms {
2352 match &term.basis {
2353 SmoothBasisSpec::Sphere { feature_cols, .. } => {
2359 if let Some(&lon_col) = feature_cols.get(1)
2360 && lon_col < training_headers.len()
2361 {
2362 out.insert(lon_col);
2363 }
2364 }
2365 SmoothBasisSpec::BSpline1D { feature_col, spec } => {
2367 if matches!(spec.knotspec, BSplineKnotSpec::PeriodicUniform { .. })
2368 && *feature_col < training_headers.len()
2369 {
2370 out.insert(*feature_col);
2371 }
2372 }
2373 SmoothBasisSpec::TensorBSpline { feature_cols, spec } => {
2376 for (i, marginal) in spec.marginalspecs.iter().enumerate() {
2377 if matches!(marginal.knotspec, BSplineKnotSpec::PeriodicUniform { .. })
2378 && let Some(&col) = feature_cols.get(i)
2379 && col < training_headers.len()
2380 {
2381 out.insert(col);
2382 }
2383 }
2384 }
2385 _ => {}
2386 }
2387 }
2388 out
2389 }
2390
2391 fn training_linear_axes(&self, n_training_headers: usize) -> std::collections::HashSet<usize> {
2402 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2403 for spec in self.saved_term_specs() {
2404 for term in &spec.linear_terms {
2405 for col in term.effective_feature_cols() {
2406 if col < n_training_headers {
2407 out.insert(col);
2408 }
2409 }
2410 }
2411 }
2412 out
2413 }
2414
2415 fn training_random_effect_axes(
2421 &self,
2422 n_training_headers: usize,
2423 ) -> std::collections::HashSet<usize> {
2424 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2425 for spec in self.saved_term_specs() {
2426 for term in &spec.random_effect_terms {
2427 if term.feature_col < n_training_headers {
2428 out.insert(term.feature_col);
2429 }
2430 }
2431 }
2432 out
2433 }
2434
2435 fn training_smooth_extrapolation_axes(
2468 &self,
2469 n_training_headers: usize,
2470 ) -> std::collections::HashSet<usize> {
2471 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2472 for spec in self.saved_term_specs() {
2473 for term in &spec.smooth_terms {
2474 collect_smooth_extrapolation_axes(&term.basis, n_training_headers, &mut out);
2475 }
2476 }
2477 out
2478 }
2479
2480 fn training_by_variable_numeric_axes(
2486 &self,
2487 n_training_headers: usize,
2488 ) -> std::collections::HashSet<usize> {
2489 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2490 for spec in self.saved_term_specs() {
2491 for term in &spec.smooth_terms {
2492 collect_by_variable_numeric_axes(&term.basis, n_training_headers, &mut out);
2493 }
2494 }
2495 out
2496 }
2497
2498 fn training_sphere_latitude_bounds(
2520 &self,
2521 training_headers: &[String],
2522 ) -> std::collections::HashMap<usize, (f64, f64)> {
2523 use gam_terms::smooth::SmoothBasisSpec;
2524 let mut out: std::collections::HashMap<usize, (f64, f64)> =
2525 std::collections::HashMap::new();
2526 let Some(spec) = self.resolved_termspec.as_ref() else {
2527 return out;
2528 };
2529 for term in &spec.smooth_terms {
2530 if let SmoothBasisSpec::Sphere { feature_cols, spec } = &term.basis
2531 && let Some(&lat_col) = feature_cols.first()
2532 && lat_col < training_headers.len()
2533 {
2534 let bound = if spec.radians {
2535 std::f64::consts::FRAC_PI_2
2536 } else {
2537 90.0
2538 };
2539 out.insert(lat_col, (-bound, bound));
2540 }
2541 }
2542 out
2543 }
2544
2545 pub fn from_payload(mut payload: FittedModelPayload) -> Self {
2546 let likelihood = payload.family_state.likelihood();
2547 let class = match payload.model_kind {
2548 ModelKind::Survival => PredictModelClass::Survival,
2549 ModelKind::MarginalSlope => PredictModelClass::BernoulliMarginalSlope,
2550 ModelKind::TransformationNormal => PredictModelClass::TransformationNormal,
2551 ModelKind::LocationScale => {
2552 if likelihood == LikelihoodSpec::gaussian_identity() {
2553 PredictModelClass::GaussianLocationScale
2554 } else if is_dispersion_location_scale_response(&likelihood.response) {
2555 PredictModelClass::DispersionLocationScale
2556 } else {
2557 PredictModelClass::BinomialLocationScale
2558 }
2559 }
2560 ModelKind::Standard => PredictModelClass::Standard,
2561 };
2562 match class {
2563 PredictModelClass::Survival => {
2564 payload.model_kind = ModelKind::Survival;
2565 Self::Survival { payload }
2566 }
2567 PredictModelClass::BernoulliMarginalSlope => {
2568 payload.model_kind = ModelKind::MarginalSlope;
2569 Self::MarginalSlope { payload }
2570 }
2571 PredictModelClass::TransformationNormal => {
2572 payload.model_kind = ModelKind::TransformationNormal;
2573 Self::TransformationNormal { payload }
2574 }
2575 PredictModelClass::GaussianLocationScale
2576 | PredictModelClass::BinomialLocationScale
2577 | PredictModelClass::DispersionLocationScale => {
2578 payload.model_kind = ModelKind::LocationScale;
2579 Self::LocationScale { payload }
2580 }
2581 PredictModelClass::Standard => {
2582 payload.model_kind = ModelKind::Standard;
2583 Self::Standard { payload }
2584 }
2585 }
2586 .with_synchronized_stateful_link_metadata()
2587 }
2588
2589 #[inline]
2590 pub fn payload(&self) -> &FittedModelPayload {
2591 match self {
2592 Self::Standard { payload }
2593 | Self::LocationScale { payload }
2594 | Self::MarginalSlope { payload }
2595 | Self::Survival { payload }
2596 | Self::TransformationNormal { payload } => payload,
2597 }
2598 }
2599
2600 #[inline]
2601 fn payload_mut(&mut self) -> &mut FittedModelPayload {
2602 match self {
2603 Self::Standard { payload }
2604 | Self::LocationScale { payload }
2605 | Self::MarginalSlope { payload }
2606 | Self::Survival { payload }
2607 | Self::TransformationNormal { payload } => payload,
2608 }
2609 }
2610
2611 fn with_synchronized_stateful_link_metadata(mut self) -> Self {
2612 self.synchronize_stateful_link_metadata();
2613 self
2614 }
2615
2616 fn synchronize_stateful_link_metadata(&mut self) {
2617 let payload = self.payload_mut();
2618 payload.used_device = payload
2619 .fit_result
2620 .as_ref()
2621 .or(payload.unified.as_ref())
2622 .is_some_and(|fit| fit.used_device);
2623 payload.synchronize_empty_feature_contract();
2624 let Some(fit) = payload.fit_result.as_ref().or(payload.unified.as_ref()) else {
2625 return;
2626 };
2627 match (&mut payload.family_state, &fit.fitted_link) {
2628 (
2629 FittedFamily::Standard {
2630 likelihood,
2631 latent_cloglog_state,
2632 ..
2633 },
2634 FittedLinkState::LatentCLogLog { state },
2635 ) if likelihood.is_latent_cloglog() => {
2636 *latent_cloglog_state = Some(*state);
2637 }
2638 (
2639 FittedFamily::Standard {
2640 likelihood,
2641 sas_state,
2642 ..
2643 },
2644 FittedLinkState::Sas { state, covariance },
2645 ) if likelihood.is_binomial_sas() => {
2646 *sas_state = Some(*state);
2647 payload.sas_param_covariance = covariance.as_ref().map(array2_to_nestedvec);
2648 }
2649 (
2650 FittedFamily::Standard {
2651 likelihood,
2652 sas_state,
2653 ..
2654 },
2655 FittedLinkState::BetaLogistic { state, covariance },
2656 ) if likelihood.is_binomial_beta_logistic() => {
2657 *sas_state = Some(*state);
2658 payload.sas_param_covariance = covariance.as_ref().map(array2_to_nestedvec);
2659 }
2660 (
2661 FittedFamily::Standard {
2662 likelihood,
2663 mixture_state,
2664 ..
2665 },
2666 FittedLinkState::Mixture { state, covariance },
2667 ) if likelihood.is_binomial_mixture() => {
2668 *mixture_state = Some(state.clone());
2669 payload.mixture_link_param_covariance =
2670 covariance.as_ref().map(array2_to_nestedvec);
2671 }
2672 _ => {}
2673 }
2674 }
2675
2676 #[inline]
2677 pub fn likelihood(&self) -> LikelihoodSpec {
2678 self.payload().family_state.likelihood()
2679 }
2680
2681 pub fn prediction_required_columns(
2699 &self,
2700 ) -> Result<std::collections::BTreeSet<String>, String> {
2701 let payload = self.payload();
2702 let parsed = parse_formula(payload.formula.as_str()).map_err(|e| e.to_string())?;
2703 let mut required = std::collections::BTreeSet::<String>::new();
2704 parsed_term_column_names(&parsed.terms, &mut required);
2705
2706 if let Some((entry, exit, _event)) =
2707 parse_surv_response(parsed.response.as_str()).map_err(|e| e.to_string())?
2708 {
2709 if let Some(entry) = entry {
2710 required.insert(entry);
2711 }
2712 required.insert(exit);
2713 } else if let Some((left, right, _event)) =
2714 parse_surv_interval_response(parsed.response.as_str()).map_err(|e| e.to_string())?
2715 {
2716 required.insert(left);
2717 required.insert(right);
2718 }
2719 if let Some(offset) = payload.offset_column.as_ref() {
2727 required.insert(offset.clone());
2728 }
2729 if let Some(noise_offset) = payload.noise_offset_column.as_ref() {
2730 required.insert(noise_offset.clone());
2731 }
2732 if matches!(
2733 self.predict_model_class(),
2734 PredictModelClass::BernoulliMarginalSlope | PredictModelClass::Survival
2735 ) {
2736 if let Some(z_column) = payload.z_column.as_ref() {
2737 required.remove("z");
2738 required.insert(z_column.clone());
2739 }
2740 }
2741 if let Some(noise_formula) = payload.formula_noise.as_ref() {
2742 self.add_auxiliary_formula_columns(
2743 &mut required,
2744 noise_formula,
2745 parsed.response.as_str(),
2746 )?;
2747 }
2748 if let Some(logslope_formula) = payload.formula_logslope.as_ref() {
2749 if logslope_formula != "same-as-main" {
2750 self.add_auxiliary_formula_columns(
2751 &mut required,
2752 logslope_formula,
2753 parsed.response.as_str(),
2754 )?;
2755 }
2756 }
2757 Ok(required)
2758 }
2759
2760 pub fn diagnostic_extra_columns(&self) -> Result<Vec<String>, String> {
2777 let payload = self.payload();
2778 let parsed = parse_formula(payload.formula.as_str()).map_err(|e| e.to_string())?;
2779 let mut extras: Vec<String> = Vec::new();
2789 if let Some(weight_column) = payload.weight_column.as_ref() {
2790 extras.push(weight_column.clone());
2791 }
2792 if parse_surv_response(parsed.response.as_str())
2795 .map_err(|e| e.to_string())?
2796 .is_some()
2797 || parse_surv_interval_response(parsed.response.as_str())
2798 .map_err(|e| e.to_string())?
2799 .is_some()
2800 {
2801 return Ok(extras);
2802 }
2803 let response = parsed.response.trim();
2804 if response.is_empty() || response.contains('(') {
2807 return Ok(extras);
2808 }
2809 if self.prediction_required_columns()?.contains(response) {
2812 return Ok(extras);
2813 }
2814 extras.push(response.to_string());
2815 Ok(extras)
2816 }
2817
2818 fn add_auxiliary_formula_columns(
2821 &self,
2822 required: &mut std::collections::BTreeSet<String>,
2823 formula_or_rhs: &str,
2824 response: &str,
2825 ) -> Result<(), String> {
2826 let trimmed = formula_or_rhs.trim();
2827 if trimmed.is_empty() || trimmed == "1" {
2828 return Ok(());
2829 }
2830 let formula = if trimmed.contains('~') {
2831 trimmed.to_string()
2832 } else {
2833 format!("{response} ~ {trimmed}")
2834 };
2835 let parsed = parse_formula(formula.as_str()).map_err(|e| e.to_string())?;
2836 parsed_term_column_names(&parsed.terms, required);
2837 Ok(())
2838 }
2839
2840 #[inline]
2841 pub fn predict_model_class(&self) -> PredictModelClass {
2842 match &self.payload().family_state {
2843 FittedFamily::Survival { .. }
2844 | FittedFamily::LatentSurvival { .. }
2845 | FittedFamily::LatentBinary { .. } => PredictModelClass::Survival,
2846 FittedFamily::MarginalSlope { .. } => PredictModelClass::BernoulliMarginalSlope,
2847 FittedFamily::TransformationNormal { .. } => PredictModelClass::TransformationNormal,
2848 FittedFamily::LocationScale { likelihood, .. } if likelihood.is_gaussian_identity() => {
2849 PredictModelClass::GaussianLocationScale
2850 }
2851 FittedFamily::LocationScale { likelihood, .. }
2852 if is_dispersion_location_scale_response(&likelihood.response) =>
2853 {
2854 PredictModelClass::DispersionLocationScale
2855 }
2856 FittedFamily::LocationScale { .. } => PredictModelClass::BinomialLocationScale,
2857 FittedFamily::Standard { .. } => PredictModelClass::Standard,
2858 }
2859 }
2860
2861 pub fn saved_link_wiggle(&self) -> Result<Option<SavedLinkWiggleRuntime>, FittedModelError> {
2862 let payload = self.payload();
2863 let (knots, degree) = match (
2864 payload.linkwiggle_knots.as_ref(),
2865 payload.linkwiggle_degree,
2866 ) {
2867 (None, None) => return Ok(None),
2868 (Some(knots), Some(degree)) => (knots.clone(), degree),
2869 _ => {
2870 return Err(FittedModelError::SchemaMismatch {
2871 reason:
2872 "saved model has partial link-wiggle metadata; expected linkwiggle_knots and linkwiggle_degree together"
2873 .to_string(),
2874 })
2875 }
2876 };
2877 let resolved_link = self.resolved_inverse_link()?;
2878 let saved_link_disallows_wiggle = resolved_link
2879 .as_ref()
2880 .is_some_and(|link| !inverse_link_supports_joint_wiggle(link))
2881 || payload
2882 .link
2883 .as_ref()
2884 .is_some_and(|link| !inverse_link_supports_joint_wiggle(link));
2885 if saved_link_disallows_wiggle {
2886 return Err(FittedModelError::IncompatibleConfig {
2887 reason: joint_wiggle_unsupported_link_message("link wiggle"),
2888 });
2889 }
2890 let beta = match self.predict_model_class() {
2891 PredictModelClass::Standard if payload.beta_link_wiggle.is_some() => {
2899 payload.beta_link_wiggle.clone().expect("checked is_some")
2900 }
2901 PredictModelClass::Standard => {
2902 let fit = payload.fit_result.as_ref().ok_or_else(|| {
2903 FittedModelError::MissingField {
2904 reason:
2905 "standard link-wiggle model is missing canonical fit_result payload"
2906 .to_string(),
2907 }
2908 })?;
2909 if fit.blocks.len() != 2
2910 || fit.blocks[0].role != BlockRole::Mean
2911 || fit.blocks[1].role != BlockRole::LinkWiggle
2912 {
2913 return Err(FittedModelError::SchemaMismatch {
2914 reason:
2915 "standard link-wiggle models must store blocks in [Mean, LinkWiggle] order"
2916 .to_string(),
2917 });
2918 }
2919 fit.block_by_role(BlockRole::LinkWiggle)
2920 .ok_or_else(|| FittedModelError::MissingField {
2921 reason:
2922 "standard link-wiggle model is missing LinkWiggle coefficient block"
2923 .to_string(),
2924 })?
2925 .beta
2926 .to_vec()
2927 }
2928 _ => payload
2929 .beta_link_wiggle
2930 .clone()
2931 .ok_or_else(|| FittedModelError::MissingField {
2932 reason:
2933 "saved model has link-wiggle metadata but is missing payload.beta_link_wiggle"
2934 .to_string(),
2935 })?,
2936 };
2937 Ok(Some(SavedLinkWiggleRuntime {
2938 knots,
2939 degree,
2940 beta,
2941 }))
2942 }
2943
2944 pub fn saved_baseline_time_wiggle(
2945 &self,
2946 ) -> Result<Option<SavedBaselineTimeWiggleRuntime>, FittedModelError> {
2947 let payload = self.payload();
2948 if payload
2949 .survival_cause_count
2950 .is_some_and(|cause_count| cause_count > 1)
2951 && payload.beta_baseline_timewiggle.is_none()
2952 && payload.beta_baseline_timewiggle_by_cause.is_some()
2953 {
2954 return Err(FittedModelError::SchemaMismatch {
2955 reason:
2956 "joint cause-specific survival stores baseline-timewiggle coefficients per cause"
2957 .to_string(),
2958 });
2959 }
2960 match (
2961 payload.baseline_timewiggle_knots.as_ref(),
2962 payload.baseline_timewiggle_degree,
2963 payload.baseline_timewiggle_penalty_orders.as_ref(),
2964 payload.baseline_timewiggle_double_penalty,
2965 payload.beta_baseline_timewiggle.as_ref(),
2966 ) {
2967 (None, None, None, None, None) => Ok(None),
2968 (Some(knots), Some(degree), Some(penalty_orders), Some(double_penalty), Some(beta)) => {
2969 Ok(Some(SavedBaselineTimeWiggleRuntime {
2970 knots: knots.clone(),
2971 degree,
2972 penalty_orders: penalty_orders.clone(),
2973 double_penalty,
2974 beta: beta.clone(),
2975 }))
2976 }
2977 _ => Err(FittedModelError::SchemaMismatch {
2978 reason:
2979 "saved model has partial baseline-timewiggle metadata; expected knots+degree+penalty_order+double_penalty+beta_baseline_timewiggle together"
2980 .to_string(),
2981 }),
2982 }
2983 }
2984
2985 #[inline]
2987 pub fn has_link_wiggle(&self) -> bool {
2988 self.saved_link_wiggle()
2989 .map(|runtime| runtime.is_some())
2990 .unwrap_or(false)
2991 }
2992
2993 #[inline]
2995 pub fn has_baseline_time_wiggle(&self) -> bool {
2996 let payload = self.payload();
2997 if payload
2998 .survival_cause_count
2999 .is_some_and(|cause_count| cause_count > 1)
3000 {
3001 return payload.baseline_timewiggle_knots.is_some()
3002 && payload.baseline_timewiggle_degree.is_some()
3003 && payload.baseline_timewiggle_penalty_orders.is_some()
3004 && payload.baseline_timewiggle_double_penalty.is_some()
3005 && payload.beta_baseline_timewiggle_by_cause.is_some();
3006 }
3007 self.saved_baseline_time_wiggle()
3008 .map(|runtime| runtime.is_some())
3009 .unwrap_or(false)
3010 }
3011
3012 #[inline]
3037 pub fn prediction_uses_posterior_mean(&self) -> bool {
3038 let family = self.likelihood();
3039 let curved_family = match &family.response {
3040 ResponseFamily::Gaussian => false,
3043 ResponseFamily::Poisson
3045 | ResponseFamily::Gamma
3046 | ResponseFamily::Tweedie { .. }
3047 | ResponseFamily::NegativeBinomial { .. } => true,
3048 ResponseFamily::Beta { .. } => true,
3050 ResponseFamily::RoystonParmar => true,
3052 ResponseFamily::Binomial => matches!(
3055 &family.link,
3056 InverseLink::Standard(_)
3057 | InverseLink::Sas(_)
3058 | InverseLink::BetaLogistic(_)
3059 | InverseLink::Mixture(_)
3060 | InverseLink::LatentCLogLog(_)
3061 ),
3062 };
3063 curved_family || self.has_link_wiggle() || self.has_baseline_time_wiggle()
3064 }
3065
3066 pub fn saved_prediction_runtime(&self) -> Result<SavedPredictionRuntime, FittedModelError> {
3067 self.payload().validate_payload_version()?;
3068 if matches!(
3069 self.predict_model_class(),
3070 PredictModelClass::BernoulliMarginalSlope | PredictModelClass::Survival
3071 ) {
3072 if let Some(runtime) = self.payload().score_warp_runtime.as_ref() {
3073 runtime.validate_exact_replay_contract().map_err(|err| {
3074 FittedModelError::PayloadCorrupt {
3075 reason: format!("saved anchored score-warp runtime is invalid: {err}"),
3076 }
3077 })?;
3078 }
3079 if let Some(runtime) = self.payload().link_deviation_runtime.as_ref() {
3080 runtime.validate_exact_replay_contract().map_err(|err| {
3081 FittedModelError::PayloadCorrupt {
3082 reason: format!("saved anchored link-deviation runtime is invalid: {err}"),
3083 }
3084 })?;
3085 }
3086 }
3087 let runtime = SavedPredictionRuntime {
3088 model_class: self.predict_model_class(),
3089 likelihood: self.likelihood(),
3090 inverse_link: self.resolved_inverse_link()?,
3091 link_wiggle: self.saved_link_wiggle()?,
3092 baseline_time_wiggle: self.saved_baseline_time_wiggle()?,
3093 score_warp: self.payload().score_warp_runtime.clone(),
3094 link_deviation: self.payload().link_deviation_runtime.clone(),
3095 latent_z_rank_int_calibration: self.payload().latent_z_rank_int_calibration.clone(),
3096 latent_z_conditional_calibration: self
3097 .payload()
3098 .latent_z_conditional_calibration
3099 .clone(),
3100 influence_absorber_width: self.payload().influence_absorber_width,
3101 };
3102 if matches!(
3103 runtime.model_class,
3104 PredictModelClass::GaussianLocationScale
3105 | PredictModelClass::BinomialLocationScale
3106 | PredictModelClass::DispersionLocationScale
3107 ) {
3108 let fit = self.payload().fit_result.as_ref().ok_or_else(|| {
3109 FittedModelError::MissingField {
3110 reason: "location-scale model is missing canonical fit_result payload"
3111 .to_string(),
3112 }
3113 })?;
3114 validate_location_scale_saved_fit(
3115 fit,
3116 runtime.model_class,
3117 runtime.link_wiggle.as_ref(),
3118 )?;
3119 } else if matches!(runtime.model_class, PredictModelClass::Survival)
3120 && self
3121 .payload()
3122 .survival_likelihood
3123 .as_deref()
3124 .is_some_and(|value| value.eq_ignore_ascii_case("location-scale"))
3125 {
3126 validate_survival_location_scale_saved_fit(
3127 self.payload(),
3128 runtime.link_wiggle.as_ref(),
3129 )?;
3130 } else if matches!(
3131 runtime.model_class,
3132 PredictModelClass::BernoulliMarginalSlope
3133 ) {
3134 let unified =
3135 self.payload()
3136 .unified
3137 .as_ref()
3138 .ok_or_else(|| FittedModelError::MissingField {
3139 reason: "marginal-slope model is missing unified fit payload; refit"
3140 .to_string(),
3141 })?;
3142 validate_marginal_slope_saved_fit(
3143 unified,
3144 runtime.score_warp.as_ref(),
3145 runtime.link_deviation.as_ref(),
3146 "unified",
3147 )?;
3148 } else if matches!(runtime.model_class, PredictModelClass::Survival)
3149 && self
3150 .payload()
3151 .survival_likelihood
3152 .as_deref()
3153 .is_some_and(|value| value.eq_ignore_ascii_case("marginal-slope"))
3154 {
3155 let fit = self.payload().fit_result.as_ref().ok_or_else(|| {
3156 FittedModelError::MissingField {
3157 reason: "survival marginal-slope model is missing canonical fit_result payload"
3158 .to_string(),
3159 }
3160 })?;
3161 validate_survival_marginal_slope_saved_fit(
3162 fit,
3163 runtime.score_warp.as_ref(),
3164 runtime.link_deviation.as_ref(),
3165 "fit_result",
3166 )?;
3167 }
3168 Ok(runtime)
3169 }
3170
3171 pub fn saved_sas_state(&self) -> Result<Option<SasLinkState>, FittedModelError> {
3172 let payload = self.payload();
3173 let raw = match &payload.family_state {
3174 FittedFamily::Standard {
3175 likelihood,
3176 sas_state,
3177 ..
3178 } if likelihood.is_binomial_sas() => {
3179 (*sas_state).ok_or_else(|| FittedModelError::MissingField {
3180 reason: "binomial-sas model is missing state in family_state.sas_state"
3181 .to_string(),
3182 })?
3183 }
3184 FittedFamily::LocationScale {
3185 likelihood,
3186 base_link,
3187 } if likelihood.is_binomial_sas() => match base_link {
3188 Some(InverseLink::Sas(state)) => *state,
3189 _ => {
3190 return Err(FittedModelError::MissingField {
3191 reason: "binomial-sas location-scale model is missing SAS base_link state"
3192 .to_string(),
3193 });
3194 }
3195 },
3196 _ => return Ok(None),
3197 };
3198 state_from_sasspec(SasLinkSpec {
3199 initial_epsilon: raw.epsilon,
3200 initial_log_delta: raw.log_delta,
3201 })
3202 .map(Some)
3203 .map_err(|e| FittedModelError::PayloadCorrupt {
3204 reason: format!("invalid saved SAS link state: {e}"),
3205 })
3206 }
3207
3208 pub fn saved_beta_logistic_state(&self) -> Result<Option<SasLinkState>, FittedModelError> {
3209 let payload = self.payload();
3210 let raw = match &payload.family_state {
3211 FittedFamily::Standard {
3212 likelihood,
3213 sas_state,
3214 ..
3215 } if likelihood.is_binomial_beta_logistic() => {
3216 (*sas_state).ok_or_else(|| FittedModelError::MissingField {
3217 reason:
3218 "binomial-beta-logistic model is missing state in family_state.sas_state"
3219 .to_string(),
3220 })?
3221 }
3222 FittedFamily::LocationScale {
3223 likelihood,
3224 base_link,
3225 } if likelihood.is_binomial_beta_logistic() => match base_link {
3226 Some(InverseLink::BetaLogistic(state)) => *state,
3227 _ => {
3228 return Err(FittedModelError::MissingField {
3229 reason:
3230 "binomial-beta-logistic location-scale model is missing beta-logistic base_link state"
3231 .to_string(),
3232 });
3233 }
3234 },
3235 _ => return Ok(None),
3236 };
3237 state_from_beta_logisticspec(SasLinkSpec {
3238 initial_epsilon: raw.epsilon,
3239 initial_log_delta: raw.log_delta,
3240 })
3241 .map(Some)
3242 .map_err(|e| FittedModelError::PayloadCorrupt {
3243 reason: format!("invalid saved Beta-Logistic link state: {e}"),
3244 })
3245 }
3246
3247 pub fn saved_mixture_state(&self) -> Result<Option<MixtureLinkState>, FittedModelError> {
3248 let payload = self.payload();
3249 match &payload.family_state {
3250 FittedFamily::Standard {
3251 likelihood,
3252 mixture_state,
3253 ..
3254 } if likelihood.is_binomial_mixture() => mixture_state
3255 .clone()
3256 .ok_or_else(|| FittedModelError::MissingField {
3257 reason: "binomial-mixture model is missing state in family_state.mixture_state"
3258 .to_string(),
3259 })
3260 .map(Some),
3261 FittedFamily::LocationScale {
3262 likelihood,
3263 base_link,
3264 } if likelihood.is_binomial_mixture() => match base_link {
3265 Some(InverseLink::Mixture(state)) => Ok(Some(state.clone())),
3266 _ => Err(FittedModelError::MissingField {
3267 reason:
3268 "binomial-mixture location-scale model is missing mixture base_link state"
3269 .to_string(),
3270 }),
3271 },
3272 _ => Ok(None),
3273 }
3274 }
3275
3276 pub fn saved_latent_cloglog_state(
3277 &self,
3278 ) -> Result<Option<LatentCLogLogState>, FittedModelError> {
3279 let payload = self.payload();
3280 match &payload.family_state {
3281 FittedFamily::Standard {
3282 likelihood,
3283 latent_cloglog_state,
3284 ..
3285 } if likelihood.is_latent_cloglog() => latent_cloglog_state
3286 .ok_or_else(|| FittedModelError::MissingField {
3287 reason:
3288 "latent-cloglog-binomial model is missing state in family_state.latent_cloglog_state"
3289 .to_string(),
3290 })
3291 .map(Some),
3292 _ => Ok(None),
3293 }
3294 }
3295
3296 pub fn resolved_inverse_link(&self) -> Result<Option<InverseLink>, FittedModelError> {
3297 let stateful = if let Some(state) = self.saved_mixture_state()? {
3298 Some(InverseLink::Mixture(state))
3299 } else if let Some(state) = self.saved_latent_cloglog_state()? {
3300 Some(InverseLink::LatentCLogLog(state))
3301 } else if let Some(state) = self.saved_beta_logistic_state()? {
3302 Some(InverseLink::BetaLogistic(state))
3303 } else {
3304 self.saved_sas_state()?.map(InverseLink::Sas)
3305 };
3306 match &self.payload().family_state {
3307 FittedFamily::LocationScale { base_link, .. } => Ok(base_link.clone().or(stateful)),
3308 FittedFamily::Standard { link, .. } => {
3309 Ok(stateful.or_else(|| link.map(InverseLink::Standard)))
3310 }
3311 FittedFamily::MarginalSlope { base_link, .. } => Ok(Some(base_link.clone())),
3312 FittedFamily::Survival { .. }
3313 | FittedFamily::LatentSurvival { .. }
3314 | FittedFamily::LatentBinary { .. } => Ok(None),
3315 FittedFamily::TransformationNormal { .. } => Ok(None),
3316 }
3317 }
3318
3319 const MEASURE_JET_COVERAGE_FLOOR: f64 = 0.05;
3328
3329 pub fn measure_jet_extrapolation_variance(
3356 &self,
3357 data: ndarray::ArrayView2<'_, f64>,
3358 col_map: &HashMap<String, usize>,
3359 ) -> Result<Option<Array1<f64>>, FittedModelError> {
3360 use gam_terms::basis::{CenterStrategy, MeasureJetExtrapolationSpectrum, PenaltySource};
3361 use gam_terms::smooth::build_term_collection_design;
3362 use gam_terms::smooth::SmoothBasisSpec;
3363 let Some(saved_spec) = self.resolved_termspec.as_ref() else {
3364 return Ok(None);
3365 };
3366 if data.nrows() == 0
3367 || !saved_spec
3368 .smooth_terms
3369 .iter()
3370 .any(|t| matches!(t.basis, SmoothBasisSpec::MeasureJet { .. }))
3371 {
3372 return Ok(None);
3373 }
3374 let fit = self
3375 .fit_result
3376 .as_ref()
3377 .ok_or_else(|| FittedModelError::MissingField {
3378 reason: "measure-jet extrapolation variance requires the canonical \
3379 fit_result payload; refit"
3380 .to_string(),
3381 })?;
3382 let spec = crate::survival::predict::resolve_termspec_for_prediction(
3383 &self.resolved_termspec,
3384 self.training_headers.as_ref(),
3385 col_map,
3386 "resolved_termspec",
3387 )
3388 .map_err(|e| FittedModelError::SchemaMismatch {
3389 reason: format!("measure-jet extrapolation variance: {e}"),
3390 })?;
3391 let probe = data.slice(ndarray::s![0..1, ..]);
3398 let design = build_term_collection_design(probe, &spec).map_err(|e| {
3399 FittedModelError::SchemaMismatch {
3400 reason: format!(
3401 "measure-jet extrapolation variance: penalty-layout replay failed: {e}"
3402 ),
3403 }
3404 })?;
3405 let lambdas = &fit.lambdas;
3406 let phi_scale = fit.coefficient_covariance_scale();
3411 let mut total = Array1::<f64>::zeros(data.nrows());
3412 let mut contributed = false;
3413 for term in &spec.smooth_terms {
3414 let SmoothBasisSpec::MeasureJet {
3415 feature_cols,
3416 spec: mj,
3417 input_scales,
3418 } = &term.basis
3419 else {
3420 continue;
3421 };
3422 let (Some(frozen), CenterStrategy::UserProvided(centers)) =
3423 (mj.frozen_quadrature.as_ref(), &mj.center_strategy)
3424 else {
3425 log::warn!(
3426 "measure-jet term '{}' is not frozen (UserProvided centers + frozen \
3427 quadrature); skipping its extrapolation variance",
3428 term.name
3429 );
3430 continue;
3431 };
3432 let n_levels = frozen.eps_band.len();
3433 let read_lambda = |global_index: usize| -> Result<f64, FittedModelError> {
3440 lambdas
3441 .get(global_index)
3442 .copied()
3443 .ok_or_else(|| FittedModelError::SchemaMismatch {
3444 reason: format!(
3445 "measure-jet term '{}': penalty global index {global_index} out \
3446 of bounds for {} fitted lambdas",
3447 term.name,
3448 lambdas.len()
3449 ),
3450 })
3451 };
3452 let mut per_scale: Vec<(usize, f64)> = Vec::new();
3453 let mut fused: Option<f64> = None;
3454 for info in &design.penaltyinfo {
3455 if info.termname.as_deref() != Some(term.name.as_str()) {
3456 continue;
3457 }
3458 match &info.penalty.source {
3459 PenaltySource::Other(label) => {
3460 if let Some(level_txt) = label.strip_prefix("measure_jet_scale_") {
3461 let level: usize = level_txt.parse().map_err(|_| {
3462 FittedModelError::SchemaMismatch {
3463 reason: format!(
3464 "measure-jet term '{}': unparseable penalty label \
3465 '{label}'",
3466 term.name
3467 ),
3468 }
3469 })?;
3470 per_scale.push((level, read_lambda(info.global_index)?));
3471 }
3472 }
3473 PenaltySource::Primary => {
3474 fused = Some(read_lambda(info.global_index)?);
3475 }
3476 _ => {}
3477 }
3478 }
3479 let mut lambda_phys = Vec::with_capacity(n_levels);
3480 let spectrum = if per_scale.is_empty() {
3481 let Some(lam) = fused else {
3482 log::warn!(
3483 "measure-jet term '{}' has no fitted amplitude in the penalty \
3484 layout; skipping its extrapolation variance",
3485 term.name
3486 );
3487 continue;
3488 };
3489 let Some(c) = frozen.fused_penalty_normalization_scale else {
3490 log::warn!(
3491 "measure-jet term '{}' is missing the fused penalty normalization scale; \
3492 skipping its extrapolation variance",
3493 term.name
3494 );
3495 continue;
3496 };
3497 MeasureJetExtrapolationSpectrum::Fused(lam / c)
3498 } else {
3499 per_scale.sort_by_key(|&(level, _)| level);
3500 let levels_complete = per_scale.len() == n_levels
3501 && per_scale
3502 .iter()
3503 .enumerate()
3504 .all(|(i, &(level, _))| level == i);
3505 if !levels_complete {
3506 log::warn!(
3507 "measure-jet term '{}': {} fitted per-scale amplitudes for {} band \
3508 scales; skipping its extrapolation variance",
3509 term.name,
3510 per_scale.len(),
3511 n_levels
3512 );
3513 continue;
3514 }
3515 if frozen.penalty_normalization_scales.len() != n_levels {
3516 log::warn!(
3517 "measure-jet term '{}': {} frozen penalty normalization scales for {} \
3518 band scales; skipping its extrapolation variance",
3519 term.name,
3520 frozen.penalty_normalization_scales.len(),
3521 n_levels
3522 );
3523 continue;
3524 }
3525 lambda_phys.extend(
3526 per_scale
3527 .iter()
3528 .map(|&(level, lam)| lam / frozen.penalty_normalization_scales[level]),
3529 );
3530 MeasureJetExtrapolationSpectrum::PerLevel(&lambda_phys)
3531 };
3532 let mut queries = Array2::<f64>::zeros((data.nrows(), feature_cols.len()));
3537 for (j, &col) in feature_cols.iter().enumerate() {
3538 if col >= data.ncols() {
3539 return Err(FittedModelError::SchemaMismatch {
3540 reason: format!(
3541 "measure-jet term '{}': prediction column {col} out of bounds \
3542 for {} data columns",
3543 term.name,
3544 data.ncols()
3545 ),
3546 });
3547 }
3548 queries.column_mut(j).assign(&data.column(col));
3549 }
3550 if let Some(scales) = input_scales {
3551 if scales.len() != feature_cols.len() {
3552 return Err(FittedModelError::SchemaMismatch {
3553 reason: format!(
3554 "measure-jet term '{}': {} input scales for {} axes",
3555 term.name,
3556 scales.len(),
3557 feature_cols.len()
3558 ),
3559 });
3560 }
3561 for (j, &scale) in scales.iter().enumerate() {
3562 queries.column_mut(j).mapv_inplace(|v| v / scale);
3563 }
3564 }
3565 let support = gam_terms::basis::measure_jet_support_curve(
3566 queries.view(),
3567 centers.view(),
3568 frozen.masses.view(),
3569 &frozen.eps_band,
3570 )
3571 .map_err(|e| FittedModelError::SchemaMismatch {
3572 reason: format!(
3573 "measure-jet term '{}': support curve failed: {e}",
3574 term.name
3575 ),
3576 })?;
3577 for i in 0..data.nrows() {
3578 let v = gam_terms::basis::measure_jet_extrapolation_variance(
3579 support.row(i),
3580 &frozen.eps_band,
3581 &frozen.support_means,
3582 spectrum,
3583 Self::MEASURE_JET_COVERAGE_FLOOR,
3584 )
3585 .map_err(|e| FittedModelError::SchemaMismatch {
3586 reason: format!(
3587 "measure-jet term '{}': extrapolation variance failed: {e}",
3588 term.name
3589 ),
3590 })?;
3591 total[i] += phi_scale * v;
3592 }
3593 contributed = true;
3594 }
3595 Ok(contributed.then_some(total))
3596 }
3597
3598 pub fn unified(&self) -> Option<&UnifiedFitResult> {
3600 self.payload().unified.as_ref()
3601 }
3602
3603 pub fn load_from_path(path: &Path) -> Result<Self, FittedModelError> {
3604 let payload = fs::read_to_string(path).map_err(|e| FittedModelError::PayloadCorrupt {
3605 reason: format!("failed to read model '{}': {e}", path.display()),
3606 })?;
3607 let model: Self =
3608 serde_json::from_str(&payload).map_err(|e| FittedModelError::PayloadCorrupt {
3609 reason: format!("failed to parse model json: {e}"),
3610 })?;
3611 let model = model.with_synchronized_stateful_link_metadata();
3612 model.validate_for_persistence()?;
3613 model.validate_numeric_finiteness()?;
3614 Ok(model)
3615 }
3616
3617 pub fn save_to_path(&self, path: &Path) -> Result<(), FittedModelError> {
3618 let normalized = self.clone().with_synchronized_stateful_link_metadata();
3619 normalized.validate_for_persistence()?;
3620 normalized.validate_numeric_finiteness()?;
3621 let parent = path.parent().unwrap_or_else(|| Path::new("."));
3628 let file_name = path
3629 .file_name()
3630 .and_then(|s| s.to_str())
3631 .unwrap_or("model.json");
3632 let pid = std::process::id();
3633 let nanos = std::time::SystemTime::now()
3634 .duration_since(std::time::UNIX_EPOCH)
3635 .map(|d| d.as_nanos())
3636 .unwrap_or(0);
3637 let tmp = parent.join(format!(".{file_name}.tmp.{pid}.{nanos:x}"));
3638 let file = fs::File::create(&tmp).map_err(|e| FittedModelError::PayloadCorrupt {
3639 reason: format!("failed to write model '{}': {e}", tmp.display()),
3640 })?;
3641 let mut writer = std::io::BufWriter::new(file);
3642 let ser_result = serde_json::to_writer(&mut writer, &normalized);
3643 if let Err(e) = ser_result {
3644 std::io::Write::flush(&mut writer).ok();
3647 drop(writer);
3648 fs::remove_file(&tmp).ok();
3649 return Err(FittedModelError::PayloadCorrupt {
3650 reason: format!("failed to serialize model: {e}"),
3651 });
3652 }
3653 std::io::Write::flush(&mut writer).map_err(|e| FittedModelError::PayloadCorrupt {
3654 reason: format!("failed to write model '{}': {e}", tmp.display()),
3655 })?;
3656 let inner = writer
3658 .into_inner()
3659 .map_err(|e| FittedModelError::PayloadCorrupt {
3660 reason: format!("failed to flush model '{}': {}", tmp.display(), e.error()),
3661 })?;
3662 inner.sync_all().ok();
3663 drop(inner);
3664 if let Err(e) = fs::rename(&tmp, path) {
3665 fs::remove_file(&tmp).ok();
3666 return Err(FittedModelError::PayloadCorrupt {
3667 reason: format!("failed to publish model '{}': {e}", path.display()),
3668 });
3669 }
3670 if let Ok(d) = fs::File::open(parent) {
3675 d.sync_all().ok();
3676 }
3677 Ok(())
3678 }
3679
3680 pub fn require_data_schema(&self) -> Result<&DataSchema, FittedModelError> {
3681 self.data_schema
3682 .as_ref()
3683 .ok_or_else(|| FittedModelError::MissingField {
3684 reason: "model is missing data_schema; refit".to_string(),
3685 })
3686 }
3687
3688 pub fn saved_spline_scan(
3692 &self,
3693 ) -> Result<Option<(&str, gam_solve::spline_scan::SplineScanFit)>, FittedModelError> {
3694 let Some(saved) = self.spline_scan.as_ref() else {
3695 return Ok(None);
3696 };
3697 let fit = gam_solve::spline_scan::SplineScanFit::from_state(&saved.state)
3698 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3699 Ok(Some((saved.feature_column.as_str(), fit)))
3700 }
3701
3702 pub fn saved_residual_cascade(
3707 &self,
3708 ) -> Result<
3709 Option<(
3710 &[String],
3711 gam_solve::residual_cascade::ResidualCascadeFit,
3712 )>,
3713 FittedModelError,
3714 > {
3715 let Some(saved) = self.residual_cascade.as_ref() else {
3716 return Ok(None);
3717 };
3718 let fit = gam_solve::residual_cascade::ResidualCascadeFit::from_state(&saved.state)
3719 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3720 Ok(Some((saved.feature_columns.as_slice(), fit)))
3721 }
3722
3723 pub fn random_effect_group_columns(&self) -> HashSet<String> {
3738 let Some(training_headers) = self.training_headers.as_ref() else {
3739 return HashSet::new();
3740 };
3741 let mut out = HashSet::<String>::new();
3742 for spec in self.saved_term_specs() {
3743 for term in &spec.random_effect_terms {
3744 if !term.lenient_unseen {
3745 continue;
3746 }
3747 if let Some(name) = training_headers.get(term.feature_col) {
3748 out.insert(name.clone());
3749 }
3750 }
3751 }
3752 out
3753 }
3754
3755 pub fn numeric_fixed_factor_vocabularies(&self) -> Vec<(String, HashSet<u64>)> {
3773 let Some(training_headers) = self.training_headers.as_ref() else {
3774 return Vec::new();
3775 };
3776 let Some(schema) = self.data_schema.as_ref() else {
3777 return Vec::new();
3778 };
3779 let mut out = Vec::<(String, HashSet<u64>)>::new();
3780 for spec in self.saved_term_specs() {
3781 for term in &spec.random_effect_terms {
3782 if term.lenient_unseen || term.drop_first_level {
3783 continue;
3784 }
3785 let Some(levels) = term.frozen_levels.as_ref() else {
3786 continue;
3787 };
3788 let Some(name) = training_headers.get(term.feature_col) else {
3789 continue;
3790 };
3791 let is_numeric = schema
3794 .columns
3795 .iter()
3796 .find(|c| &c.name == name)
3797 .map(|c| {
3798 matches!(
3799 c.kind,
3800 ColumnKindTag::Continuous | ColumnKindTag::Binary
3801 )
3802 })
3803 .unwrap_or(false);
3804 if !is_numeric {
3805 continue;
3806 }
3807 let vocab: HashSet<u64> = levels
3808 .iter()
3809 .map(|&b| gam_data::canonical_level_bits(f64::from_bits(b)))
3810 .collect();
3811 out.push((name.clone(), vocab));
3812 }
3813 }
3814 out
3815 }
3816
3817 pub fn validate_for_persistence(&self) -> Result<(), FittedModelError> {
3818 self.validate_payload_version()?;
3832 if let Some(scan) = self.spline_scan.as_ref() {
3833 if self.fit_result.is_some() || self.unified.is_some() {
3838 return Err(FittedModelError::SchemaMismatch {
3839 reason: "spline-scan model must not also carry a dense fit_result/unified \
3840 payload; the representations are mutually exclusive"
3841 .to_string(),
3842 });
3843 }
3844 if self.model_kind != ModelKind::Standard
3845 || self.family_state.likelihood() != LikelihoodSpec::gaussian_identity()
3846 {
3847 return Err(FittedModelError::SchemaMismatch {
3848 reason: format!(
3849 "spline-scan representation requires a standard Gaussian-identity model; \
3850 got model_kind={:?}, likelihood={:?}",
3851 self.model_kind,
3852 self.family_state.likelihood()
3853 ),
3854 });
3855 }
3856 if scan.feature_column.is_empty() {
3857 return Err(FittedModelError::MissingField {
3858 reason: "spline-scan model is missing its feature column name; refit"
3859 .to_string(),
3860 });
3861 }
3862 gam_solve::spline_scan::SplineScanFit::from_state(&scan.state)
3863 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3864 if self.data_schema.is_none() {
3870 return Err(FittedModelError::MissingField {
3871 reason: "spline-scan model is missing data_schema; refit".to_string(),
3872 });
3873 }
3874 if self.training_headers.is_none() {
3875 return Err(FittedModelError::MissingField {
3876 reason: "spline-scan model is missing training_headers; refit".to_string(),
3877 });
3878 }
3879 return Ok(());
3880 } else if let Some(cascade) = self.residual_cascade.as_ref() {
3881 if self.spline_scan.is_some() || self.fit_result.is_some() || self.unified.is_some() {
3885 return Err(FittedModelError::SchemaMismatch {
3886 reason: "residual-cascade model must not also carry spline_scan / \
3887 fit_result / unified payloads; the representations are \
3888 mutually exclusive"
3889 .to_string(),
3890 });
3891 }
3892 if self.model_kind != ModelKind::Standard
3893 || self.family_state.likelihood() != LikelihoodSpec::gaussian_identity()
3894 {
3895 return Err(FittedModelError::SchemaMismatch {
3896 reason: format!(
3897 "residual-cascade representation requires a standard Gaussian-identity \
3898 model; got model_kind={:?}, likelihood={:?}",
3899 self.model_kind,
3900 self.family_state.likelihood()
3901 ),
3902 });
3903 }
3904 if cascade.feature_columns.is_empty()
3905 || !(2..=3).contains(&cascade.feature_columns.len())
3906 {
3907 return Err(FittedModelError::MissingField {
3908 reason: format!(
3909 "residual-cascade model needs 2 or 3 feature columns; got {}; refit",
3910 cascade.feature_columns.len()
3911 ),
3912 });
3913 }
3914 gam_solve::residual_cascade::ResidualCascadeFit::from_state(&cascade.state)
3915 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3916 if self.data_schema.is_none() {
3917 return Err(FittedModelError::MissingField {
3918 reason: "residual-cascade model is missing data_schema; refit".to_string(),
3919 });
3920 }
3921 if self.training_headers.is_none() {
3922 return Err(FittedModelError::MissingField {
3923 reason: "residual-cascade model is missing training_headers; refit".to_string(),
3924 });
3925 }
3926 return Ok(());
3927 } else if self.fit_result.is_none() {
3928 return Err(FittedModelError::MissingField {
3929 reason: "model is missing canonical fit_result payload; refit".to_string(),
3930 });
3931 }
3932 if self.data_schema.is_none() {
3933 return Err(FittedModelError::MissingField {
3934 reason: "model is missing data_schema; refit".to_string(),
3935 });
3936 }
3937 if self.training_headers.is_none() {
3938 return Err(FittedModelError::MissingField {
3939 reason: "model is missing training_headers; refit to guarantee stable feature mapping at prediction time"
3940 .to_string(),
3941 });
3942 }
3943 let spec = self.resolved_termspec.as_ref().ok_or_else(|| {
3944 FittedModelError::MissingField {
3945 reason: "model is missing resolved_termspec; refit to guarantee train/predict design consistency"
3946 .to_string(),
3947 }
3948 })?;
3949 validate_frozen_term_collectionspec(spec, "resolved_termspec")?;
3950
3951 if self.formula_noise.is_some() && self.resolved_termspec_noise.is_none() {
3952 return Err(FittedModelError::MissingField {
3953 reason: "model defines formula_noise but is missing resolved_termspec_noise; refit"
3954 .to_string(),
3955 });
3956 }
3957 if let Some(spec_noise) = self.resolved_termspec_noise.as_ref() {
3958 validate_frozen_term_collectionspec(spec_noise, "resolved_termspec_noise")?;
3959 }
3960 if matches!(self.family_state, FittedFamily::TransformationNormal { .. }) {
3961 let score = self.transformation_score_calibration.ok_or_else(|| {
3962 FittedModelError::MissingField {
3963 reason: "transformation-normal model is missing transformation_score_calibration; refit"
3964 .to_string(),
3965 }
3966 })?;
3967 score.validate("transformation-normal model")?;
3968 }
3969 if matches!(self.family_state, FittedFamily::MarginalSlope { .. }) {
3970 if self.formula_logslope.is_none() {
3971 return Err(FittedModelError::MissingField {
3972 reason: "marginal-slope model is missing formula_logslope; refit".to_string(),
3973 });
3974 }
3975 if self.z_column.is_none() {
3976 return Err(FittedModelError::MissingField {
3977 reason: "marginal-slope model is missing z_column; refit".to_string(),
3978 });
3979 }
3980 let z_normalization =
3981 self.latent_z_normalization
3982 .ok_or_else(|| FittedModelError::MissingField {
3983 reason: "marginal-slope model is missing latent_z_normalization; refit"
3984 .to_string(),
3985 })?;
3986 z_normalization.validate("marginal-slope model")?;
3987 let latent_measure =
3988 self.latent_measure
3989 .as_ref()
3990 .ok_or_else(|| FittedModelError::MissingField {
3991 reason: "marginal-slope model is missing latent_measure; refit".to_string(),
3992 })?;
3993 latent_measure
3994 .validate("marginal-slope model latent_measure")
3995 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3996 if self.marginal_baseline.is_none() || self.logslope_baseline.is_none() {
3997 return Err(FittedModelError::MissingField {
3998 reason: "marginal-slope model is missing baseline offsets; refit".to_string(),
3999 });
4000 }
4001 if self.resolved_termspec_logslope.as_ref().is_none() {
4002 return Err(FittedModelError::MissingField {
4003 reason: "marginal-slope model is missing resolved_termspec_logslope for the logslope surface"
4004 .to_string(),
4005 });
4006 }
4007 match self.family_state.frailty() {
4008 Some(FrailtySpec::None)
4009 | Some(FrailtySpec::GaussianShift {
4010 sigma_fixed: Some(_),
4011 }) => {}
4012 Some(FrailtySpec::GaussianShift { sigma_fixed: None }) => {
4013 return Err(FittedModelError::IncompatibleConfig {
4014 reason: "marginal-slope model requires a fixed GaussianShift sigma in family_state.frailty"
4015 .to_string(),
4016 });
4017 }
4018 Some(FrailtySpec::HazardMultiplier { .. }) => {
4019 return Err(FittedModelError::IncompatibleConfig {
4020 reason: "marginal-slope model does not support HazardMultiplier frailty"
4021 .to_string(),
4022 });
4023 }
4024 None => {
4025 return Err(FittedModelError::MissingField {
4026 reason: "marginal-slope model is missing family_state.frailty; refit"
4027 .to_string(),
4028 });
4029 }
4030 }
4031 }
4032
4033 if let FittedFamily::Survival {
4034 survival_likelihood,
4035 frailty,
4036 ..
4037 } = &self.family_state
4038 {
4039 if matches!(
4040 survival_likelihood.as_deref(),
4041 Some("latent") | Some("latent-binary")
4042 ) {
4043 return Err(FittedModelError::SchemaMismatch {
4044 reason: "latent hazard-window models must persist explicit family_state metadata, not generic survival metadata"
4045 .to_string(),
4046 });
4047 }
4048 if survival_likelihood.as_deref() == Some("marginal-slope") {
4049 if self.formula_logslope.is_none() {
4050 return Err(FittedModelError::MissingField {
4051 reason: "survival marginal-slope model is missing formula_logslope; refit"
4052 .to_string(),
4053 });
4054 }
4055 if self.z_column.is_none() {
4056 return Err(FittedModelError::MissingField {
4057 reason: "survival marginal-slope model is missing z_column; refit"
4058 .to_string(),
4059 });
4060 }
4061 let z_normalization =
4062 self.latent_z_normalization
4063 .ok_or_else(|| {
4064 FittedModelError::MissingField {
4065 reason:
4066 "survival marginal-slope model is missing latent_z_normalization; refit"
4067 .to_string(),
4068 }
4069 })?;
4070 z_normalization.validate("survival marginal-slope model")?;
4071 let latent_measure =
4072 self.latent_measure
4073 .as_ref()
4074 .ok_or_else(|| FittedModelError::MissingField {
4075 reason:
4076 "survival marginal-slope model is missing latent_measure; refit"
4077 .to_string(),
4078 })?;
4079 latent_measure
4080 .validate("survival marginal-slope model latent_measure")
4081 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
4082 if self.logslope_baseline.is_none() {
4083 return Err(FittedModelError::MissingField {
4084 reason: "survival marginal-slope model is missing logslope_baseline; refit"
4085 .to_string(),
4086 });
4087 }
4088 if self.resolved_termspec_logslope.as_ref().is_none() {
4089 return Err(FittedModelError::MissingField {
4090 reason: "survival marginal-slope model is missing resolved_termspec_logslope for the logslope surface"
4091 .to_string(),
4092 });
4093 }
4094 match frailty {
4095 FrailtySpec::None
4096 | FrailtySpec::GaussianShift {
4097 sigma_fixed: Some(_),
4098 } => {}
4099 FrailtySpec::GaussianShift { sigma_fixed: None } => {
4100 return Err(FittedModelError::IncompatibleConfig {
4101 reason: "survival marginal-slope model requires a fixed GaussianShift sigma in family_state.frailty"
4102 .to_string(),
4103 });
4104 }
4105 FrailtySpec::HazardMultiplier { .. } => {
4106 return Err(FittedModelError::IncompatibleConfig {
4107 reason: "survival marginal-slope model does not support HazardMultiplier frailty"
4108 .to_string(),
4109 });
4110 }
4111 }
4112 } else if !matches!(frailty, FrailtySpec::None) {
4113 return Err(FittedModelError::IncompatibleConfig {
4114 reason:
4115 "non-marginal survival models do not currently persist a frailty modifier"
4116 .to_string(),
4117 });
4118 }
4119 if self.survival_time_basis.is_none() {
4127 return Err(FittedModelError::MissingField {
4128 reason: "survival model is missing survival_time_basis; refit to persist the baseline-time basis configuration".to_string(),
4129 });
4130 }
4131 if self.survival_time_anchor.is_none() {
4132 return Err(FittedModelError::MissingField {
4133 reason: "survival model is missing survival_time_anchor; refit to persist the baseline-time anchor".to_string(),
4134 });
4135 }
4136 }
4137 if let FittedFamily::LatentSurvival { frailty } = &self.family_state {
4138 match frailty {
4139 FrailtySpec::HazardMultiplier {
4140 sigma_fixed: Some(_),
4141 ..
4142 } => {}
4143 FrailtySpec::HazardMultiplier {
4144 sigma_fixed: None, ..
4145 } => {
4146 return Err(FittedModelError::IncompatibleConfig {
4147 reason: "latent survival model requires a fixed HazardMultiplier sigma in family_state.frailty"
4148 .to_string(),
4149 });
4150 }
4151 FrailtySpec::GaussianShift { .. } | FrailtySpec::None => {
4152 return Err(FittedModelError::IncompatibleConfig {
4153 reason: "latent survival model requires a fixed HazardMultiplier frailty specification"
4154 .to_string(),
4155 });
4156 }
4157 }
4158 if self.survival_likelihood.as_deref() != Some("latent") {
4159 return Err(FittedModelError::SchemaMismatch {
4160 reason: "latent survival model must persist survival_likelihood=latent"
4161 .to_string(),
4162 });
4163 }
4164 }
4165 if let FittedFamily::LatentBinary { frailty } = &self.family_state {
4166 match frailty {
4167 FrailtySpec::HazardMultiplier {
4168 sigma_fixed: Some(_),
4169 ..
4170 } => {}
4171 FrailtySpec::HazardMultiplier {
4172 sigma_fixed: None, ..
4173 } => {
4174 return Err(FittedModelError::IncompatibleConfig {
4175 reason: "latent binary model requires a fixed HazardMultiplier sigma in family_state.frailty"
4176 .to_string(),
4177 });
4178 }
4179 FrailtySpec::GaussianShift { .. } | FrailtySpec::None => {
4180 return Err(FittedModelError::IncompatibleConfig {
4181 reason: "latent binary model requires a fixed HazardMultiplier frailty specification"
4182 .to_string(),
4183 });
4184 }
4185 }
4186 if self.survival_likelihood.as_deref() != Some("latent-binary") {
4187 return Err(FittedModelError::SchemaMismatch {
4188 reason: "latent binary model must persist survival_likelihood=latent-binary"
4189 .to_string(),
4190 });
4191 }
4192 }
4193
4194 let family_likelihood = match &self.family_state {
4195 FittedFamily::Standard { likelihood, .. }
4196 | FittedFamily::LocationScale { likelihood, .. }
4197 | FittedFamily::MarginalSlope { likelihood, .. }
4198 | FittedFamily::Survival { likelihood, .. }
4199 | FittedFamily::TransformationNormal { likelihood, .. } => Some(likelihood),
4200 FittedFamily::LatentSurvival { .. } | FittedFamily::LatentBinary { .. } => None,
4201 };
4202 let is_standard_or_location_scale = matches!(
4203 self.family_state,
4204 FittedFamily::Standard { .. } | FittedFamily::LocationScale { .. }
4205 );
4206 if is_standard_or_location_scale
4207 && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_sas)
4208 {
4209 self.saved_sas_state()?;
4210 }
4211 if is_standard_or_location_scale
4212 && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_beta_logistic)
4213 {
4214 self.saved_beta_logistic_state()?;
4215 }
4216 if is_standard_or_location_scale
4217 && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_mixture)
4218 {
4219 self.saved_mixture_state()?;
4220 }
4221 if matches!(self.family_state, FittedFamily::Standard { .. })
4222 && family_likelihood.is_some_and(LikelihoodSpec::is_latent_cloglog)
4223 {
4224 self.saved_latent_cloglog_state()?;
4225 }
4226 if matches!(self.family_state, FittedFamily::LocationScale { .. })
4227 && family_likelihood.is_some_and(LikelihoodSpec::is_latent_cloglog)
4228 {
4229 return Err(FittedModelError::IncompatibleConfig {
4230 reason: "latent-cloglog-binomial is not supported for location-scale saved models"
4231 .to_string(),
4232 });
4233 }
4234 if matches!(self.family_state, FittedFamily::Survival { .. })
4235 && self.survival_likelihood.is_none()
4236 {
4237 return Err(FittedModelError::MissingField {
4238 reason: "saved survival model is missing survival_likelihood metadata; refit"
4239 .to_string(),
4240 });
4241 }
4242 let has_any_saved_link_wiggle = self.linkwiggle_knots.is_some()
4243 || self.linkwiggle_degree.is_some()
4244 || self.beta_link_wiggle.is_some()
4245 || self
4246 .fit_result
4247 .as_ref()
4248 .and_then(|fit| fit.block_by_role(BlockRole::LinkWiggle))
4249 .is_some();
4250 let saved_link_wiggle = self.saved_link_wiggle()?;
4251 if has_any_saved_link_wiggle && saved_link_wiggle.is_none() {
4252 return Err(FittedModelError::SchemaMismatch {
4253 reason: "saved model has incomplete link-wiggle state; expected metadata and coefficients"
4254 .to_string(),
4255 });
4256 }
4257 let has_any_saved_baseline_time_wiggle = self.baseline_timewiggle_knots.is_some()
4258 || self.baseline_timewiggle_degree.is_some()
4259 || self.baseline_timewiggle_penalty_orders.is_some()
4260 || self.baseline_timewiggle_double_penalty.is_some()
4261 || self.beta_baseline_timewiggle.is_some()
4262 || self.beta_baseline_timewiggle_by_cause.is_some();
4263 let is_joint_cause_specific = self
4264 .survival_cause_count
4265 .is_some_and(|cause_count| cause_count > 1);
4266 if has_any_saved_baseline_time_wiggle {
4267 if is_joint_cause_specific {
4268 let complete = self.baseline_timewiggle_knots.is_some()
4269 && self.baseline_timewiggle_degree.is_some()
4270 && self.baseline_timewiggle_penalty_orders.is_some()
4271 && self.baseline_timewiggle_double_penalty.is_some()
4272 && self.beta_baseline_timewiggle_by_cause.is_some();
4273 if !complete {
4274 return Err(FittedModelError::SchemaMismatch {
4275 reason: "saved joint cause-specific survival model has incomplete baseline-timewiggle state; expected metadata and per-cause coefficients"
4276 .to_string(),
4277 });
4278 }
4279 } else if self.saved_baseline_time_wiggle()?.is_none() {
4280 return Err(FittedModelError::SchemaMismatch {
4281 reason: "saved model has incomplete baseline-timewiggle state; expected metadata and coefficients"
4282 .to_string(),
4283 });
4284 }
4285 }
4286 if self
4287 .survival_likelihood
4288 .as_deref()
4289 .is_some_and(|value| value.eq_ignore_ascii_case("location-scale"))
4290 {
4291 validate_survival_location_scale_saved_fit(self.payload(), saved_link_wiggle.as_ref())?;
4292 }
4293
4294 if let Some(runtime) = self.score_warp_runtime.as_ref() {
4304 runtime.validate_exact_replay_contract().map_err(|err| {
4305 FittedModelError::PayloadCorrupt {
4306 reason: format!("saved anchored score-warp runtime is invalid: {err}"),
4307 }
4308 })?;
4309 }
4310 if let Some(runtime) = self.link_deviation_runtime.as_ref() {
4311 runtime.validate_exact_replay_contract().map_err(|err| {
4312 FittedModelError::PayloadCorrupt {
4313 reason: format!("saved anchored link-deviation runtime is invalid: {err}"),
4314 }
4315 })?;
4316 }
4317 if matches!(self.family_state, FittedFamily::MarginalSlope { .. }) {
4318 validate_marginal_slope_saved_fit(
4319 self.fit_result.as_ref().expect("checked above"),
4320 self.score_warp_runtime.as_ref(),
4321 self.link_deviation_runtime.as_ref(),
4322 "fit_result",
4323 )?;
4324 let unified = self
4325 .unified
4326 .as_ref()
4327 .ok_or_else(|| FittedModelError::MissingField {
4328 reason: "marginal-slope model is missing unified fit payload; refit"
4329 .to_string(),
4330 })?;
4331 validate_marginal_slope_saved_fit(
4332 unified,
4333 self.score_warp_runtime.as_ref(),
4334 self.link_deviation_runtime.as_ref(),
4335 "unified",
4336 )?;
4337 }
4338 if self
4339 .survival_likelihood
4340 .as_deref()
4341 .is_some_and(|value| value.eq_ignore_ascii_case("marginal-slope"))
4342 {
4343 validate_survival_marginal_slope_saved_fit(
4344 self.fit_result.as_ref().expect("checked above"),
4345 self.score_warp_runtime.as_ref(),
4346 self.link_deviation_runtime.as_ref(),
4347 "fit_result",
4348 )?;
4349 if let Some(unified) = self.unified.as_ref() {
4350 validate_survival_marginal_slope_saved_fit(
4351 unified,
4352 self.score_warp_runtime.as_ref(),
4353 self.link_deviation_runtime.as_ref(),
4354 "unified",
4355 )?;
4356 }
4357 }
4358
4359 Ok(())
4370 }
4371
4372 pub fn validate_numeric_finiteness(&self) -> Result<(), FittedModelError> {
4373 let corrupt = |reason: String| FittedModelError::PayloadCorrupt { reason };
4374 if let Some(fit) = self.fit_result.as_ref() {
4375 fit.validate_numeric_finiteness()
4376 .map_err(|e| corrupt(e.to_string()))?;
4377 }
4378
4379 for (name, opt) in [
4380 ("survival_baseline_scale", self.survival_baseline_scale),
4381 ("survival_baseline_shape", self.survival_baseline_shape),
4382 ("survival_baseline_rate", self.survival_baseline_rate),
4383 ("survival_baseline_makeham", self.survival_baseline_makeham),
4384 (
4385 "survival_time_smooth_lambda",
4386 self.survival_time_smooth_lambda,
4387 ),
4388 ("survival_time_anchor", self.survival_time_anchor),
4389 ("survivalridge_lambda", self.survivalridge_lambda),
4390 ] {
4391 if let Some(v) = opt {
4392 ensure_finite_scalar(name, v).map_err(corrupt)?;
4393 }
4394 }
4395
4396 if let Some(v) = self.beta_noise.as_ref() {
4397 validate_all_finite("beta_noise", v.iter().copied()).map_err(corrupt)?;
4398 }
4399 if let Some(v) = self.noise_projection.as_ref() {
4400 validate_all_finite("noise_projection", v.iter().flatten().copied())
4401 .map_err(corrupt)?;
4402 if self.noise_projection_ridge_alpha.is_none() {
4403 return Err(FittedModelError::MissingField {
4404 reason:
4405 "model has noise_projection but is missing noise_projection_ridge_alpha; refit"
4406 .to_string(),
4407 });
4408 }
4409 }
4410 if let Some(v) = self.noise_center.as_ref() {
4411 validate_all_finite("noise_center", v.iter().copied()).map_err(corrupt)?;
4412 }
4413 if let Some(v) = self.noise_scale.as_ref() {
4414 validate_all_finite("noise_scale", v.iter().copied()).map_err(corrupt)?;
4415 }
4416 if let Some(v) = self.noise_projection_ridge_alpha {
4417 ensure_finite_scalar("noise_projection_ridge_alpha", v).map_err(corrupt)?;
4418 if v < 0.0 {
4419 return Err(FittedModelError::InvalidInput {
4420 reason: format!("noise_projection_ridge_alpha must be non-negative, got {v}"),
4421 });
4422 }
4423 }
4424 if let Some(v) = self.gaussian_response_scale {
4425 ensure_finite_scalar("gaussian_response_scale", v).map_err(corrupt)?;
4426 }
4427 if let Some(v) = self.beta_link_wiggle.as_ref() {
4428 validate_all_finite("beta_link_wiggle", v.iter().copied()).map_err(corrupt)?;
4429 }
4430 if let Some(v) = self.beta_baseline_timewiggle.as_ref() {
4431 validate_all_finite("beta_baseline_timewiggle", v.iter().copied()).map_err(corrupt)?;
4432 }
4433 if let Some(v) = self.beta_baseline_timewiggle_by_cause.as_ref() {
4434 validate_all_finite(
4435 "beta_baseline_timewiggle_by_cause",
4436 v.iter().flatten().copied(),
4437 )
4438 .map_err(corrupt)?;
4439 }
4440 if let Some(v) = self.latent_z_normalization {
4441 v.validate("latent_z_normalization")?;
4442 }
4443 if let Some(v) = self.latent_measure.as_ref() {
4444 v.validate("latent_measure").map_err(corrupt)?;
4445 }
4446 if let Some(v) = self.survival_beta_time.as_ref() {
4447 validate_all_finite("survival_beta_time", v.iter().copied()).map_err(corrupt)?;
4448 }
4449 if let Some(v) = self.survival_beta_threshold.as_ref() {
4450 validate_all_finite("survival_beta_threshold", v.iter().copied()).map_err(corrupt)?;
4451 }
4452 if let Some(v) = self.survival_beta_log_sigma.as_ref() {
4453 validate_all_finite("survival_beta_log_sigma", v.iter().copied()).map_err(corrupt)?;
4454 }
4455 if let Some(v) = self.survival_noise_projection.as_ref() {
4456 validate_all_finite("survival_noise_projection", v.iter().flatten().copied())
4457 .map_err(corrupt)?;
4458 if self.survival_noise_projection_ridge_alpha.is_none() {
4459 return Err(FittedModelError::MissingField {
4460 reason:
4461 "model has survival_noise_projection but is missing survival_noise_projection_ridge_alpha; refit"
4462 .to_string(),
4463 });
4464 }
4465 }
4466 if let Some(v) = self.survival_noise_center.as_ref() {
4467 validate_all_finite("survival_noise_center", v.iter().copied()).map_err(corrupt)?;
4468 }
4469 if let Some(v) = self.survival_noise_projection_ridge_alpha {
4470 ensure_finite_scalar("survival_noise_projection_ridge_alpha", v).map_err(corrupt)?;
4471 if v < 0.0 {
4472 return Err(FittedModelError::InvalidInput {
4473 reason: format!(
4474 "survival_noise_projection_ridge_alpha must be non-negative, got {v}"
4475 ),
4476 });
4477 }
4478 }
4479 if let Some(v) = self.survival_noise_scale.as_ref() {
4480 validate_all_finite("survival_noise_scale", v.iter().copied()).map_err(corrupt)?;
4481 }
4482 if let Some(v) = self.mixture_link_param_covariance.as_ref() {
4483 validate_all_finite("mixture_link_param_covariance", v.iter().flatten().copied())
4484 .map_err(corrupt)?;
4485 }
4486 if let Some(v) = self.sas_param_covariance.as_ref() {
4487 validate_all_finite("sas_param_covariance", v.iter().flatten().copied())
4488 .map_err(corrupt)?;
4489 }
4490 Ok(())
4491 }
4492}
4493
4494fn array2_to_nestedvec(a: &ndarray::Array2<f64>) -> Vec<Vec<f64>> {
4495 a.rows().into_iter().map(|row| row.to_vec()).collect()
4496}
4497
4498use gam_solve::estimate::{ensure_finite_scalar, validate_all_finite};
4499
4500fn validate_frozen_term_collectionspec(
4501 spec: &TermCollectionSpec,
4502 label: &str,
4503) -> Result<(), FittedModelError> {
4504 spec.validate_frozen(label)
4505 .map_err(|reason| FittedModelError::SchemaMismatch { reason })
4506}
4507
4508impl Deref for FittedModel {
4509 type Target = FittedModelPayload;
4510
4511 fn deref(&self) -> &Self::Target {
4512 self.payload()
4513 }
4514}
4515
4516impl DerefMut for FittedModel {
4517 fn deref_mut(&mut self) -> &mut Self::Target {
4518 self.payload_mut()
4519 }
4520}
4521
4522pub fn survival_baseline_config_from_model(
4527 model: &FittedModel,
4528) -> Result<SurvivalBaselineConfig, FittedModelError> {
4529 let target = model.survival_baseline_target.as_deref().ok_or_else(|| {
4530 FittedModelError::MissingField {
4531 reason: "saved survival model missing survival_baseline_target; refit".to_string(),
4532 }
4533 })?;
4534 parse_survival_baseline_config(
4535 target,
4536 model.survival_baseline_scale,
4537 model.survival_baseline_shape,
4538 model.survival_baseline_rate,
4539 model.survival_baseline_makeham,
4540 )
4541 .map_err(|reason| FittedModelError::IncompatibleConfig { reason })
4542}
4543
4544pub fn load_survival_time_basis_config_from_model(
4545 model: &FittedModel,
4546) -> Result<SurvivalTimeBasisConfig, FittedModelError> {
4547 match model
4548 .survival_time_basis
4549 .as_deref()
4550 .ok_or_else(|| FittedModelError::MissingField {
4551 reason: "saved survival model missing survival_time_basis".to_string(),
4552 })?
4553 .to_ascii_lowercase()
4554 .as_str()
4555 {
4556 "none" => Ok(SurvivalTimeBasisConfig::None),
4557 "linear" => Ok(SurvivalTimeBasisConfig::Linear),
4558 "bspline" => {
4559 let degree =
4560 model
4561 .survival_time_degree
4562 .ok_or_else(|| FittedModelError::MissingField {
4563 reason: "saved survival bspline model missing survival_time_degree"
4564 .to_string(),
4565 })?;
4566 let knots = model.survival_time_knots.clone().ok_or_else(|| {
4567 FittedModelError::MissingField {
4568 reason: "saved survival bspline model missing survival_time_knots".to_string(),
4569 }
4570 })?;
4571 let smooth_lambda = model.survival_time_smooth_lambda.unwrap_or(1e-2);
4572 if degree < 1 || knots.is_empty() {
4573 return Err(FittedModelError::SchemaMismatch {
4574 reason: "saved survival bspline time basis metadata is invalid".to_string(),
4575 });
4576 }
4577 Ok(SurvivalTimeBasisConfig::BSpline {
4578 degree,
4579 knots: Array1::from_vec(knots),
4580 smooth_lambda,
4581 })
4582 }
4583 "ispline" => {
4584 let degree =
4585 model
4586 .survival_time_degree
4587 .ok_or_else(|| FittedModelError::MissingField {
4588 reason: "saved survival ispline model missing survival_time_degree"
4589 .to_string(),
4590 })?;
4591 let knots = model.survival_time_knots.clone().ok_or_else(|| {
4592 FittedModelError::MissingField {
4593 reason: "saved survival ispline model missing survival_time_knots".to_string(),
4594 }
4595 })?;
4596 let keep_cols = model.survival_time_keep_cols.clone().ok_or_else(|| {
4597 FittedModelError::MissingField {
4598 reason: "saved survival ispline model missing survival_time_keep_cols"
4599 .to_string(),
4600 }
4601 })?;
4602 let smooth_lambda = model.survival_time_smooth_lambda.unwrap_or(1e-2);
4603 if degree < 1 || knots.is_empty() || keep_cols.is_empty() {
4604 return Err(FittedModelError::SchemaMismatch {
4605 reason: "saved survival ispline time basis metadata is invalid".to_string(),
4606 });
4607 }
4608 Ok(SurvivalTimeBasisConfig::ISpline {
4609 degree,
4610 knots: Array1::from_vec(knots),
4611 keep_cols,
4612 smooth_lambda,
4613 })
4614 }
4615 other => Err(FittedModelError::IncompatibleConfig {
4616 reason: format!("unsupported saved survival_time_basis '{other}'"),
4617 }),
4618 }
4619}
4620
4621#[cfg(test)]
4622mod tests {
4623 use super::*;
4624 use crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL;
4625 use crate::survival::lognormal_kernel::FrailtySpec;
4626 use gam_solve::pirls::PirlsStatus;
4627 use gam_solve::estimate::{FitArtifacts, FittedBlock, FittedLinkState};
4628 use gam_problem::types::{LikelihoodScaleMetadata, LogLikelihoodNormalization};
4629 use gam_data::SchemaColumn;
4630 use ndarray::{Array1, Array2, array};
4631
4632 fn empty_termspec() -> TermCollectionSpec {
4633 TermCollectionSpec {
4634 linear_terms: vec![],
4635 random_effect_terms: vec![],
4636 smooth_terms: vec![],
4637 }
4638 }
4639
4640 #[test]
4644 fn spline_scan_payload_round_trips_and_validates() {
4645 let x: Vec<f64> = (0..40).map(|i| i as f64 / 39.0).collect();
4646 let y: Vec<f64> = x.iter().map(|&v| (4.0 * v).sin() + 0.1 * v).collect();
4647 let w = vec![1.0_f64; x.len()];
4648 let fit = gam_solve::spline_scan::fit_spline_scan(&x, &y, &w, 2).expect("scan fit");
4649 let make_payload = || {
4650 crate::inference::model_payload_builders::assemble_spline_scan_payload(
4651 "y ~ s(x)".to_string(),
4652 "x".to_string(),
4653 &fit,
4654 DataSchema {
4655 columns: vec![
4656 SchemaColumn {
4657 name: "y".to_string(),
4658 kind: ColumnKindTag::Continuous,
4659 levels: vec![],
4660 },
4661 SchemaColumn {
4662 name: "x".to_string(),
4663 kind: ColumnKindTag::Continuous,
4664 levels: vec![],
4665 },
4666 ],
4667 },
4668 vec!["x".to_string()],
4669 vec![(0.0, 1.0)],
4670 )
4671 };
4672 let model = FittedModel::from_payload(make_payload());
4675 model
4676 .validate_for_persistence()
4677 .expect("scan model validates");
4678 model
4679 .validate_numeric_finiteness()
4680 .expect("scan model is finite");
4681
4682 let json = serde_json::to_string(&model).expect("serialize model");
4683 let restored: FittedModel = serde_json::from_str(&json).expect("parse model");
4684 restored
4685 .validate_for_persistence()
4686 .expect("restored scan model validates");
4687 let (column, replay) = restored
4688 .saved_spline_scan()
4689 .expect("restore scan fit")
4690 .expect("payload carries the scan representation");
4691 assert_eq!(column, "x");
4692 for &xq in &[-0.1, 0.0, 0.31, 0.5, 0.77, 1.0, 1.4] {
4693 let (m0, v0) = fit.predict(xq).expect("predict original");
4694 let (m1, v1) = replay.predict(xq).expect("predict replayed");
4695 assert_eq!(m0.to_bits(), m1.to_bits(), "mean drift at x={xq}");
4696 assert_eq!(v0.to_bits(), v1.to_bits(), "variance drift at x={xq}");
4697 }
4698
4699 let mut dense = make_payload();
4701 dense.spline_scan = None;
4702 let err = FittedModel::from_payload(dense)
4703 .validate_for_persistence()
4704 .expect_err("dense payload without fit_result must be rejected");
4705 assert!(err.to_string().contains("fit_result"));
4706
4707 let mut corrupt = make_payload();
4709 corrupt
4710 .spline_scan
4711 .as_mut()
4712 .expect("scan channel present")
4713 .state
4714 .knots
4715 .truncate(2);
4716 FittedModel::from_payload(corrupt)
4717 .validate_for_persistence()
4718 .expect_err("corrupt scan state must be rejected");
4719 let mut unnamed = make_payload();
4720 unnamed
4721 .spline_scan
4722 .as_mut()
4723 .expect("scan channel present")
4724 .feature_column
4725 .clear();
4726 FittedModel::from_payload(unnamed)
4727 .validate_for_persistence()
4728 .expect_err("missing feature column must be rejected");
4729 }
4730
4731 fn standard_gaussian_payload() -> FittedModelPayload {
4732 FittedModelPayload::new(
4733 MODEL_PAYLOAD_VERSION,
4734 "y ~ 1".to_string(),
4735 ModelKind::Standard,
4736 FittedFamily::Standard {
4737 likelihood: LikelihoodSpec::gaussian_identity(),
4738 link: Some(StandardLink::Identity),
4739 latent_cloglog_state: None,
4740 mixture_state: None,
4741 sas_state: None,
4742 },
4743 "gaussian".to_string(),
4744 )
4745 }
4746
4747 fn anchored_runtime(basis_dim: usize) -> SavedCompiledFlexBlock {
4748 SavedCompiledFlexBlock {
4749 kernel: ANCHORED_DEVIATION_KERNEL.to_string(),
4750 breakpoints: vec![-1.0, 1.0],
4751 basis_dim,
4752 span_c0: vec![vec![0.0; basis_dim]],
4753 span_c1: vec![vec![0.0; basis_dim]],
4754 span_c2: vec![vec![0.0; basis_dim]],
4755 span_c3: vec![vec![0.0; basis_dim]],
4756 anchor_correction: None,
4757 anchor_components: Vec::new(),
4758 }
4759 }
4760
4761 fn saved_fit(blocks: Vec<FittedBlock>) -> UnifiedFitResult {
4762 let beta = Array1::from_vec(
4763 blocks
4764 .iter()
4765 .flat_map(|block| block.beta.iter().copied())
4766 .collect(),
4767 );
4768 let p = beta.len();
4769 UnifiedFitResult {
4770 blocks,
4771 log_lambdas: Array1::zeros(0),
4772 lambdas: Array1::zeros(0),
4773 likelihood_family: Some(LikelihoodSpec::binomial_probit()),
4774 likelihood_scale: LikelihoodScaleMetadata::Unspecified,
4775 log_likelihood_normalization: LogLikelihoodNormalization::Full,
4776 log_likelihood: 0.0,
4777 deviance: 0.0,
4778 reml_score: 0.0,
4779 stable_penalty_term: 0.0,
4780 penalized_objective: 0.0,
4781 used_device: false,
4782 outer_iterations: 0,
4783 outer_cost_evals: 0,
4784 inner_pirls_solves: 0,
4785 outer_converged: true,
4786 outer_gradient_norm: None,
4787 standard_deviation: 1.0,
4788 covariance_conditional: Some(Array2::zeros((p, p))),
4789 covariance_corrected: Some(Array2::zeros((p, p))),
4790 inference: None,
4791 fitted_link: FittedLinkState::Standard(None),
4792 geometry: None,
4793 block_states: vec![],
4794 beta,
4795 pirls_status: PirlsStatus::Converged,
4796 max_abs_eta: 0.0,
4797 constraint_kkt: None,
4798 artifacts: FitArtifacts {
4799 pirls: None,
4800 null_space_logdet: None,
4801 null_space_dim: None,
4802 survival_link_wiggle_knots: None,
4803 survival_link_wiggle_degree: None,
4804 criterion_certificate: None,
4805 rho_posterior_certificate: None,
4806 rho_posterior_escalation: None,
4807 rho_covariance: None,
4808 joint_log_lambdas: None,
4809 },
4810 inner_cycles: 0,
4811 }
4812 }
4813
4814 fn marginal_slope_payload(version: u32, fit: UnifiedFitResult) -> FittedModelPayload {
4815 let mut payload = FittedModelPayload::new(
4816 version,
4817 "y ~ 1".to_string(),
4818 ModelKind::MarginalSlope,
4819 FittedFamily::MarginalSlope {
4820 likelihood: LikelihoodSpec::binomial_probit(),
4821 base_link: InverseLink::Standard(StandardLink::Probit),
4822 frailty: FrailtySpec::None,
4823 },
4824 "bernoulli-marginal-slope".to_string(),
4825 );
4826 payload.fit_result = Some(fit.clone());
4827 payload.unified = Some(fit);
4828 payload.data_schema = Some(DataSchema {
4829 columns: vec![SchemaColumn {
4830 name: "z".to_string(),
4831 kind: ColumnKindTag::Continuous,
4832 levels: vec![],
4833 }],
4834 });
4835 payload.set_training_feature_metadata(vec!["z".to_string()], vec![(0.0, 0.0)]);
4836 payload.resolved_termspec = Some(empty_termspec());
4837 payload.resolved_termspec_logslope = Some(empty_termspec());
4838 payload.formula_logslope = Some("1".to_string());
4839 payload.z_column = Some("z".to_string());
4840 payload.latent_z_normalization = Some(SavedLatentZNormalization { mean: 0.0, sd: 1.0 });
4841 payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
4842 payload.marginal_baseline = Some(0.0);
4843 payload.logslope_baseline = Some(0.0);
4844 payload.link = Some(InverseLink::Standard(StandardLink::Probit));
4845 payload
4846 }
4847
4848 #[test]
4849 fn from_payload_synchronizes_used_device_from_saved_fit() {
4850 let mut fit = saved_fit(vec![
4851 FittedBlock {
4852 beta: Array1::from_vec(vec![0.25]),
4853 role: BlockRole::Mean,
4854 edf: 1.0,
4855 lambdas: Array1::zeros(0),
4856 },
4857 FittedBlock {
4858 beta: Array1::from_vec(vec![0.5]),
4859 role: BlockRole::Scale,
4860 edf: 1.0,
4861 lambdas: Array1::zeros(0),
4862 },
4863 ]);
4864 fit.used_device = true;
4865 let mut payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
4866 payload.used_device = false;
4867
4868 let model = FittedModel::from_payload(payload);
4869
4870 assert!(model.payload().used_device);
4871 }
4872
4873 fn survival_marginal_slope_payload(version: u32, fit: UnifiedFitResult) -> FittedModelPayload {
4874 let mut payload = FittedModelPayload::new(
4875 version,
4876 "Surv(entry, exit, event) ~ 1".to_string(),
4877 ModelKind::Survival,
4878 FittedFamily::Survival {
4879 likelihood: LikelihoodSpec::royston_parmar(),
4880 survival_likelihood: Some("marginal-slope".to_string()),
4881 survival_distribution: Some(ResidualDistribution::Gaussian),
4882 frailty: FrailtySpec::None,
4883 },
4884 "survival".to_string(),
4885 );
4886 payload.fit_result = Some(fit.clone());
4887 payload.unified = Some(fit);
4888 payload.survival_likelihood = Some("marginal-slope".to_string());
4889 payload.survival_distribution = Some(ResidualDistribution::Gaussian);
4890 payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
4891 payload.data_schema = Some(DataSchema {
4892 columns: vec![SchemaColumn {
4893 name: "z".to_string(),
4894 kind: ColumnKindTag::Continuous,
4895 levels: vec![],
4896 }],
4897 });
4898 payload.set_training_feature_metadata(vec!["z".to_string()], vec![(0.0, 0.0)]);
4899 payload.resolved_termspec = Some(empty_termspec());
4900 payload.resolved_termspec_logslope = Some(empty_termspec());
4901 payload.formula_logslope = Some("1".to_string());
4902 payload.z_column = Some("z".to_string());
4903 payload.latent_z_normalization = Some(SavedLatentZNormalization { mean: 0.0, sd: 1.0 });
4904 payload.logslope_baseline = Some(0.0);
4905 payload.link = Some(InverseLink::Standard(StandardLink::Probit));
4906 payload
4907 }
4908
4909 fn gamma_dispersion_location_scale_payload() -> FittedModelPayload {
4910 let mut payload = FittedModelPayload::new(
4916 MODEL_PAYLOAD_VERSION,
4917 "y ~ x".to_string(),
4918 ModelKind::LocationScale,
4919 FittedFamily::LocationScale {
4920 likelihood: LikelihoodSpec::gamma_log(),
4921 base_link: Some(InverseLink::Standard(StandardLink::Log)),
4922 },
4923 "gamma-location-scale".to_string(),
4924 );
4925 payload.data_schema = Some(DataSchema {
4926 columns: vec![
4927 SchemaColumn {
4928 name: "y".to_string(),
4929 kind: ColumnKindTag::Continuous,
4930 levels: vec![],
4931 },
4932 SchemaColumn {
4933 name: "x".to_string(),
4934 kind: ColumnKindTag::Continuous,
4935 levels: vec![],
4936 },
4937 ],
4938 });
4939 payload.set_training_feature_metadata(vec!["x".to_string()], vec![(-1.0, 1.0)]);
4940 payload.resolved_termspec = Some(empty_termspec());
4941 payload.resolved_termspec_noise = Some(empty_termspec());
4942 payload.formula_noise = Some("x".to_string());
4943 payload.beta_noise = Some(vec![0.0]);
4944 payload.link = Some(InverseLink::Standard(StandardLink::Log));
4945 payload
4946 }
4947
4948 #[test]
4955 fn dispersion_location_scale_payload_is_not_classified_binomial() {
4956 let model = FittedModel::from_payload(gamma_dispersion_location_scale_payload());
4957 assert_eq!(
4958 model.predict_model_class(),
4959 PredictModelClass::DispersionLocationScale,
4960 "Gamma dispersion location-scale must route through the dispersion \
4961 predictor, not the binomial threshold-scale class",
4962 );
4963 assert!(
4964 !matches!(
4965 model.predict_model_class(),
4966 PredictModelClass::BinomialLocationScale
4967 ),
4968 "dispersion location-scale must never be classified as binomial",
4969 );
4970
4971 for likelihood in [
4973 LikelihoodSpec::gamma_log(),
4974 LikelihoodSpec::new(
4975 ResponseFamily::NegativeBinomial {
4976 theta: 1.0,
4977 theta_fixed: false,
4978 },
4979 InverseLink::Standard(StandardLink::Log),
4980 ),
4981 LikelihoodSpec::new(
4982 ResponseFamily::Beta { phi: 1.0 },
4983 InverseLink::Standard(StandardLink::Logit),
4984 ),
4985 LikelihoodSpec::new(
4986 ResponseFamily::Tweedie { p: 1.5 },
4987 InverseLink::Standard(StandardLink::Log),
4988 ),
4989 ] {
4990 let mut payload = gamma_dispersion_location_scale_payload();
4991 payload.family_state = FittedFamily::LocationScale {
4992 base_link: Some(likelihood.link.clone()),
4993 likelihood: likelihood.clone(),
4994 };
4995 let model = FittedModel::from_payload(payload);
4996 assert_eq!(
4997 model.predict_model_class(),
4998 PredictModelClass::DispersionLocationScale,
4999 "dispersion family {:?} mis-classified",
5000 likelihood.response,
5001 );
5002 }
5003 }
5004
5005 #[test]
5006 fn axis_clip_leaves_numeric_random_effect_group_axis_unclipped() {
5007 let data = array![[100.0], [-100.0]];
5008 let col_map = HashMap::from([("g".to_string(), 0usize)]);
5009
5010 let mut plain_payload = standard_gaussian_payload();
5011 plain_payload.data_schema = Some(DataSchema {
5012 columns: vec![SchemaColumn {
5013 name: "g".to_string(),
5014 kind: ColumnKindTag::Continuous,
5015 levels: vec![],
5016 }],
5017 });
5018 plain_payload.set_training_feature_metadata(vec!["g".to_string()], vec![(0.0, 7.0)]);
5019 plain_payload.resolved_termspec = Some(empty_termspec());
5020 let plain = FittedModel::from_payload(plain_payload.clone());
5021 let clipped = plain
5022 .axis_clip_to_training_ranges(data.view(), &col_map)
5023 .expect("ordinary continuous axis should clip outside the training range");
5024 assert_eq!(clipped.column(0).to_vec(), vec![7.0, 0.0]);
5025
5026 let mut group_payload = plain_payload;
5027 let mut group_spec = empty_termspec();
5028 group_spec
5029 .random_effect_terms
5030 .push(gam_terms::smooth::RandomEffectTermSpec {
5031 name: "g".to_string(),
5032 feature_col: 0,
5033 drop_first_level: false,
5034 penalized: true,
5035 frozen_levels: Some(vec![0.0_f64.to_bits(), 7.0_f64.to_bits()]),
5036 lenient_unseen: true,
5037 });
5038 group_payload.resolved_termspec = Some(group_spec);
5039 let group_model = FittedModel::from_payload(group_payload);
5040
5041 assert_eq!(
5042 group_model.random_effect_group_columns(),
5043 HashSet::from(["g".to_string()])
5044 );
5045
5046 assert_eq!(
5047 group_model.axis_clip_to_training_ranges(data.view(), &col_map),
5048 None,
5049 "numeric group labels must reach RandomEffectOperator as unseen levels, not be clipped to boundary seen levels"
5050 );
5051 }
5052
5053 #[test]
5067 fn bare_categorical_fixed_factor_unseen_level_rejected_by_predict_encode() {
5068 use csv::StringRecord;
5069 use gam_data::{EncodedDataset, UnseenCategoryPolicy, encode_recordswith_schema};
5070 use gam_runtime::resource::ResourcePolicy;
5071 use gam_terms::inference::formula_dsl::parse_formula;
5072 use gam_terms::term_builder::build_termspec;
5073
5074 let train_schema = DataSchema {
5076 columns: vec![
5077 SchemaColumn {
5078 name: "y".to_string(),
5079 kind: ColumnKindTag::Continuous,
5080 levels: vec![],
5081 },
5082 SchemaColumn {
5083 name: "g".to_string(),
5084 kind: ColumnKindTag::Categorical,
5085 levels: vec!["a".to_string(), "b".to_string(), "c".to_string()],
5086 },
5087 ],
5088 };
5089 let train = EncodedDataset {
5090 headers: vec!["y".to_string(), "g".to_string()],
5091 values: Array2::from_shape_vec(
5092 (6, 2),
5093 vec![0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0],
5094 )
5095 .expect("rectangular training frame"),
5096 schema: train_schema.clone(),
5097 column_kinds: vec![ColumnKindTag::Continuous, ColumnKindTag::Categorical],
5098 };
5099 let build_col_map = train.column_map();
5100
5101 let model_for = |formula: &str| -> FittedModel {
5102 let parsed = parse_formula(formula).expect("formula parses");
5103 let mut notes = Vec::new();
5104 let spec = build_termspec(
5105 &parsed.terms,
5106 &train,
5107 &build_col_map,
5108 &mut notes,
5109 &ResourcePolicy::default_library(),
5110 )
5111 .unwrap_or_else(|err| panic!("`{formula}` must build a term spec, got: {err:?}"));
5112 let mut payload = standard_gaussian_payload();
5113 payload.data_schema = Some(train_schema.clone());
5114 payload.set_training_feature_metadata(
5115 vec!["y".to_string(), "g".to_string()],
5116 vec![(0.0, 2.0), (0.0, 2.0)],
5117 );
5118 payload.resolved_termspec = Some(spec);
5119 FittedModel::from_payload(payload)
5120 };
5121
5122 let g_schema = DataSchema {
5124 columns: vec![SchemaColumn {
5125 name: "g".to_string(),
5126 kind: ColumnKindTag::Categorical,
5127 levels: vec!["a".to_string(), "b".to_string(), "c".to_string()],
5128 }],
5129 };
5130 let encode_level = |model: &FittedModel, level: &str| -> Result<EncodedDataset, String> {
5131 let policy = UnseenCategoryPolicy::encode_unknown_for_columns(
5132 model.random_effect_group_columns(),
5133 );
5134 encode_recordswith_schema(
5135 vec!["g".to_string()],
5136 vec![StringRecord::from(vec![level])],
5137 &g_schema,
5138 policy,
5139 )
5140 };
5141
5142 let bare = model_for("y ~ g");
5145 assert!(
5146 !bare.random_effect_group_columns().contains("g"),
5147 "bare `+ g` is a fixed parametric factor; it must NOT be whitelisted for lenient \
5148 unseen-level encoding (#2102)"
5149 );
5150 encode_level(&bare, "a").expect("a seen level must still encode for the fixed factor");
5151 let err = encode_level(&bare, "TYPO")
5152 .expect_err("an unseen fixed-factor level must raise a schema mismatch (#2102)");
5153 assert!(
5154 err.contains("unseen level"),
5155 "expected an unseen-level schema mismatch naming the level, got: {err}"
5156 );
5157
5158 let factor = model_for("y ~ factor(g)");
5163 assert!(
5164 !factor.random_effect_group_columns().contains("g"),
5165 "factor(g) is a FIXED categorical factor; it must NOT be whitelisted for lenient \
5166 unseen-level encoding (#2137)"
5167 );
5168 encode_level(&factor, "a").expect("a seen level must still encode for factor(g)");
5169 let factor_err = encode_level(&factor, "TYPO")
5170 .expect_err("an unseen factor(g) level must raise a schema mismatch (#2137)");
5171 assert!(
5172 factor_err.contains("unseen level"),
5173 "expected an unseen-level schema mismatch naming the level, got: {factor_err}"
5174 );
5175
5176 for formula in ["y ~ group(g)", "y ~ re(g)", "y ~ s(g, bs=\"re\")"] {
5179 let grouped = model_for(formula);
5180 assert!(
5181 grouped.random_effect_group_columns().contains("g"),
5182 "`{formula}` is a random effect; it must remain lenient on unseen levels \
5183 (held-out-group policy)"
5184 );
5185 encode_level(&grouped, "TYPO")
5186 .unwrap_or_else(|err| panic!("`{formula}` must tolerate an unseen level, got: {err}"));
5187 }
5188 }
5189
5190 #[test]
5191 fn validate_for_persistence_rejects_marginal_slope_score_warp_basis_mismatch() {
5192 let fit = saved_fit(vec![
5193 FittedBlock {
5194 beta: array![0.1],
5195 role: BlockRole::Mean,
5196 edf: 1.0,
5197 lambdas: Array1::zeros(0),
5198 },
5199 FittedBlock {
5200 beta: array![0.2],
5201 role: BlockRole::Scale,
5202 edf: 1.0,
5203 lambdas: Array1::zeros(0),
5204 },
5205 FittedBlock {
5206 beta: array![0.3],
5207 role: BlockRole::Mean,
5208 edf: 1.0,
5209 lambdas: Array1::zeros(0),
5210 },
5211 ]);
5212 let mut payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5213 payload.score_warp_runtime = Some(anchored_runtime(2));
5214
5215 let err = FittedModel::from_payload(payload)
5216 .validate_for_persistence()
5217 .expect_err("marginal-slope score-warp basis mismatch should fail validation");
5218 assert!(err.to_string().contains("score-warp coefficient mismatch"));
5219 }
5220
5221 #[test]
5222 fn saved_prediction_runtime_rejects_survival_marginal_slope_link_basis_mismatch() {
5223 let fit = saved_fit(vec![
5224 FittedBlock {
5225 beta: array![0.1],
5226 role: BlockRole::Time,
5227 edf: 1.0,
5228 lambdas: Array1::zeros(0),
5229 },
5230 FittedBlock {
5231 beta: array![0.2],
5232 role: BlockRole::Mean,
5233 edf: 1.0,
5234 lambdas: Array1::zeros(0),
5235 },
5236 FittedBlock {
5237 beta: array![0.3],
5238 role: BlockRole::Scale,
5239 edf: 1.0,
5240 lambdas: Array1::zeros(0),
5241 },
5242 FittedBlock {
5243 beta: array![0.4],
5244 role: BlockRole::LinkWiggle,
5245 edf: 1.0,
5246 lambdas: Array1::zeros(0),
5247 },
5248 ]);
5249 let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5250 payload.link_deviation_runtime = Some(anchored_runtime(2));
5251
5252 let err = FittedModel::from_payload(payload)
5253 .saved_prediction_runtime()
5254 .expect_err(
5255 "survival marginal-slope link basis mismatch should fail runtime validation",
5256 );
5257 assert!(
5258 err.to_string()
5259 .contains("link-deviation coefficient mismatch")
5260 );
5261 }
5262
5263 #[test]
5264 fn apply_survival_time_basis_writes_all_required_fields() {
5265 use crate::survival::construction::SavedSurvivalTimeBasis;
5266
5267 let fit = saved_fit(vec![
5268 FittedBlock {
5269 beta: array![0.1],
5270 role: BlockRole::Time,
5271 edf: 1.0,
5272 lambdas: Array1::zeros(0),
5273 },
5274 FittedBlock {
5275 beta: array![0.2],
5276 role: BlockRole::Mean,
5277 edf: 1.0,
5278 lambdas: Array1::zeros(0),
5279 },
5280 FittedBlock {
5281 beta: array![0.3],
5282 role: BlockRole::Scale,
5283 edf: 1.0,
5284 lambdas: Array1::zeros(0),
5285 },
5286 ]);
5287 let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5288
5289 let snapshot = SavedSurvivalTimeBasis {
5294 basisname: "royston-parmar".to_string(),
5295 degree: Some(3),
5296 knots: Some(vec![0.0, 1.0, 2.0]),
5297 keep_cols: Some(vec![0, 2]),
5298 smooth_lambda: Some(0.5),
5299 anchor: 0.25,
5300 };
5301 payload.apply_survival_time_basis(&snapshot);
5302
5303 assert_eq!(
5304 payload.survival_time_basis.as_deref(),
5305 Some("royston-parmar")
5306 );
5307 assert_eq!(payload.survival_time_degree, Some(3));
5308 assert_eq!(payload.survival_time_knots, Some(vec![0.0, 1.0, 2.0]));
5309 assert_eq!(payload.survival_time_keep_cols, Some(vec![0, 2]));
5310 assert_eq!(payload.survival_time_smooth_lambda, Some(0.5));
5311 assert_eq!(payload.survival_time_anchor, Some(0.25));
5312 }
5313
5314 #[test]
5315 fn validate_for_persistence_rejects_survival_without_time_anchor_metadata() {
5316 let fit = saved_fit(vec![
5317 FittedBlock {
5318 beta: array![0.1],
5319 role: BlockRole::Time,
5320 edf: 1.0,
5321 lambdas: Array1::zeros(0),
5322 },
5323 FittedBlock {
5324 beta: array![0.2],
5325 role: BlockRole::Mean,
5326 edf: 1.0,
5327 lambdas: Array1::zeros(0),
5328 },
5329 FittedBlock {
5330 beta: array![0.3],
5331 role: BlockRole::Scale,
5332 edf: 1.0,
5333 lambdas: Array1::zeros(0),
5334 },
5335 ]);
5336 let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5337 payload.survival_time_basis = Some("ispline".to_string());
5343
5344 let err = FittedModel::from_payload(payload)
5345 .validate_for_persistence()
5346 .expect_err("survival model without time-anchor metadata should fail validation");
5347 assert!(err.to_string().contains("missing survival_time_anchor"));
5348 }
5349
5350 #[test]
5351 fn validate_for_persistence_rejects_survival_without_time_basis_metadata() {
5352 let fit = saved_fit(vec![
5353 FittedBlock {
5354 beta: array![0.1],
5355 role: BlockRole::Time,
5356 edf: 1.0,
5357 lambdas: Array1::zeros(0),
5358 },
5359 FittedBlock {
5360 beta: array![0.2],
5361 role: BlockRole::Mean,
5362 edf: 1.0,
5363 lambdas: Array1::zeros(0),
5364 },
5365 FittedBlock {
5366 beta: array![0.3],
5367 role: BlockRole::Scale,
5368 edf: 1.0,
5369 lambdas: Array1::zeros(0),
5370 },
5371 ]);
5372 let payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5373
5374 let err = FittedModel::from_payload(payload)
5375 .validate_for_persistence()
5376 .expect_err("survival model without time-basis metadata should fail validation");
5377 assert!(err.to_string().contains("missing survival_time_basis"));
5378 }
5379
5380 #[test]
5381 fn saved_prediction_runtime_rejects_stale_payload_version() {
5382 let fit = saved_fit(vec![
5383 FittedBlock {
5384 beta: array![0.1],
5385 role: BlockRole::Mean,
5386 edf: 1.0,
5387 lambdas: Array1::zeros(0),
5388 },
5389 FittedBlock {
5390 beta: array![0.2],
5391 role: BlockRole::Scale,
5392 edf: 1.0,
5393 lambdas: Array1::zeros(0),
5394 },
5395 ]);
5396 let payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION - 1, fit);
5397
5398 let err = FittedModel::from_payload(payload)
5399 .saved_prediction_runtime()
5400 .expect_err("stale payload version should fail before runtime assembly");
5401 assert!(err.to_string().contains("payload schema mismatch"));
5402 }
5403}