1use crate::bms::{LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration};
2use crate::survival::construction::{
3 SurvivalBaselineConfig, SurvivalTimeBasisConfig, parse_survival_baseline_config,
4};
5use crate::survival::location_scale::{
6 ResidualDistribution, SurvivalCovariateTimeBasis, SurvivalLocationScaleTimeParameterization,
7};
8use crate::survival::lognormal_kernel::{FrailtyScale, FrailtySpec};
9use crate::wiggle::{
10 WigglePenaltyMetadata, canonical_wiggle_function_penalties,
11 monotone_wiggle_basis_with_derivative_order, validate_monotone_wiggle_beta_nonnegative,
12};
13use gam_linalg::faer_ndarray::{FaerCholesky, array2_to_nested_vec};
14use gam_linalg::matrix::DesignMatrix;
15use gam_problem::types::{
16 InverseLink, LatentCLogLogState, LikelihoodSpec, MixtureLinkState, ResponseFamily, SasLinkSpec,
17 SasLinkState, StandardLink,
18};
19use gam_runtime::span::span_index_for_breakpoints;
20use gam_solve::estimate::{BlockRole, FittedLinkState, UnifiedFitResult};
21use gam_solve::mixture_link::{state_from_beta_logisticspec, state_from_sasspec};
22use gam_terms::basis::BasisOptions;
23use gam_terms::inference::formula_dsl::{
24 inverse_link_supports_joint_wiggle, joint_wiggle_unsupported_link_message, parse_formula,
25 parse_surv_interval_response, parse_surv_response, parsed_term_column_names,
26};
27use gam_terms::smooth::{AdaptiveRegularizationDiagnostics, TermCollectionSpec};
28pub use gam_data::{ColumnKindTag, DataSchema, SchemaColumn};
34use ndarray::{Array1, Array2, ArrayView1};
35use serde::{Deserialize, Serialize};
36use serde_json::Value as JsonValue;
37use std::collections::{BTreeMap, HashMap, HashSet};
38use std::fs;
39use std::ops::{Deref, DerefMut};
40use std::path::Path;
41
42pub const MODEL_PAYLOAD_VERSION: u32 = 15;
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum TransformationNormalParameterization {
78 DirectAlpha,
81}
82
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
97pub struct SavedTransformationNormalGeometry {
98 pub parameterization: TransformationNormalParameterization,
100 pub response_degree: usize,
102 pub response_knot_count: usize,
106 pub shape_coordinate_count: usize,
109 pub cone_carrier_covariate_width: usize,
112 pub cone_carrier_row_count: usize,
115 pub certified_response_support: (f64, f64),
119 pub response_median: f64,
121}
122
123impl SavedTransformationNormalGeometry {
124 pub fn validate(&self, context: &str) -> Result<(), FittedModelError> {
128 match self.parameterization {
129 TransformationNormalParameterization::DirectAlpha => {}
130 }
131 if self.response_degree < 1 {
132 return Err(FittedModelError::PayloadCorrupt {
133 reason: format!(
134 "{context} CTN geometry response_degree must be >= 1, got {}",
135 self.response_degree
136 ),
137 });
138 }
139 if self.shape_coordinate_count == 0 {
140 return Err(FittedModelError::PayloadCorrupt {
141 reason: format!("{context} CTN geometry needs at least one shape coordinate"),
142 });
143 }
144 if self.cone_carrier_covariate_width == 0 || self.cone_carrier_row_count == 0 {
145 return Err(FittedModelError::PayloadCorrupt {
146 reason: format!(
147 "{context} CTN geometry cone carrier must be non-empty: {} rows x {} covariate columns",
148 self.cone_carrier_row_count, self.cone_carrier_covariate_width
149 ),
150 });
151 }
152 let (lo, hi) = self.certified_response_support;
153 if !(lo.is_finite() && hi.is_finite() && lo < hi) {
154 return Err(FittedModelError::PayloadCorrupt {
155 reason: format!(
156 "{context} CTN geometry certified response support must be finite and ordered lo < hi, got [{lo}, {hi}]"
157 ),
158 });
159 }
160 if !self.response_median.is_finite() {
161 return Err(FittedModelError::PayloadCorrupt {
162 reason: format!(
163 "{context} CTN geometry response_median must be finite, got {}",
164 self.response_median
165 ),
166 });
167 }
168 Ok::<(), _>(())
169 }
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
176pub struct SavedSurvivalLocationScaleStructure {
177 pub time_parameterization: SurvivalLocationScaleTimeParameterization,
178 pub threshold_time_basis: Option<SurvivalCovariateTimeBasis>,
179 pub log_sigma_time_basis: Option<SurvivalCovariateTimeBasis>,
180}
181
182pub type GroupMetadata = BTreeMap<String, JsonValue>;
189
190#[derive(Clone, Debug, Serialize, Deserialize)]
193pub struct SavedSplineScan {
194 pub feature_column: String,
196 pub state: gam_solve::spline_scan::SplineScanState,
197}
198
199#[derive(Clone, Debug, Serialize, Deserialize)]
204pub struct SavedResidualCascade {
205 pub feature_columns: Vec<String>,
207 pub state: gam_solve::residual_cascade::ResidualCascadeState,
208}
209
210#[derive(Clone, Debug, PartialEq, Eq)]
220pub enum FittedModelError {
221 SchemaMismatch { reason: String },
225 PayloadCorrupt { reason: String },
229 MissingField { reason: String },
232 IncompatibleConfig { reason: String },
236 InvalidInput { reason: String },
239}
240
241impl_reason_error_boilerplate! {
242 FittedModelError {
243 SchemaMismatch,
244 PayloadCorrupt,
245 MissingField,
246 IncompatibleConfig,
247 InvalidInput,
248 }
249}
250
251impl From<FittedModelError> for gam_solve::model_types::EstimationError {
256 fn from(err: FittedModelError) -> Self {
257 gam_solve::model_types::EstimationError::InvalidInput(err.to_string())
258 }
259}
260
261impl From<FittedModelError> for crate::survival::predict::SurvivalPredictError {
262 fn from(err: FittedModelError) -> Self {
263 crate::survival::predict::SurvivalPredictError::ModelPayload {
264 context: "saved-model survival prediction payload",
265 source: err,
266 }
267 }
268}
269
270#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
271pub struct SavedLatentZNormalization {
272 pub mean: f64,
273 pub sd: f64,
274}
275
276impl SavedLatentZNormalization {
277 pub fn validate(&self, context: &str) -> Result<(), FittedModelError> {
278 if !self.mean.is_finite() {
279 return Err(FittedModelError::PayloadCorrupt {
280 reason: format!("{context} latent z mean must be finite"),
281 });
282 }
283 if !(self.sd.is_finite() && self.sd > 1e-12) {
284 return Err(FittedModelError::PayloadCorrupt {
285 reason: format!(
286 "{context} latent z sd must be finite and > 1e-12; got {}",
287 self.sd
288 ),
289 });
290 }
291 Ok::<(), _>(())
292 }
293
294 pub fn apply(&self, z: &Array1<f64>, context: &str) -> Result<Array1<f64>, FittedModelError> {
295 self.validate(context)?;
296 if z.iter().any(|value| !value.is_finite()) {
297 return Err(FittedModelError::PayloadCorrupt {
298 reason: format!("{context} requires finite z values"),
299 });
300 }
301 Ok(z.mapv(|zi| (zi - self.mean) / self.sd))
302 }
303}
304
305pub const TRANSFORMATION_SCORE_PIT_CLIP_EPS: f64 = 1.0e-12;
306
307#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
308#[serde(rename_all = "kebab-case")]
309#[derive(Default)]
310pub enum TransformationScoreKind {
311 #[default]
312 FiniteSupportPit,
313}
314
315#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
316pub struct TransformationScoreCalibration {
317 #[serde(default)]
318 pub score_kind: TransformationScoreKind,
319 #[serde(default = "default_transformation_score_pit_clip_eps")]
320 pub clip_eps: f64,
321}
322
323const fn default_transformation_score_pit_clip_eps() -> f64 {
324 TRANSFORMATION_SCORE_PIT_CLIP_EPS
325}
326
327impl TransformationScoreCalibration {
328 pub fn finite_support_pit() -> Self {
329 Self {
330 score_kind: TransformationScoreKind::FiniteSupportPit,
331 clip_eps: TRANSFORMATION_SCORE_PIT_CLIP_EPS,
332 }
333 }
334
335 pub fn validate(&self, context: &str) -> Result<(), FittedModelError> {
336 if self.score_kind != TransformationScoreKind::FiniteSupportPit {
337 return Err(FittedModelError::IncompatibleConfig {
338 reason: format!("{context} supports only finite-support CTN PIT score semantics"),
339 });
340 }
341 if !(self.clip_eps.is_finite() && self.clip_eps > 0.0 && self.clip_eps < 0.5) {
342 return Err(FittedModelError::IncompatibleConfig {
343 reason: format!(
344 "{context} requires PIT clip_eps in (0, 0.5), got {}",
345 self.clip_eps
346 ),
347 });
348 }
349 Ok::<(), _>(())
350 }
351}
352
353#[derive(Clone, Serialize, Deserialize)]
354pub struct FittedModelPayload {
355 pub version: u32,
356 pub formula: String,
357 pub model_kind: ModelKind,
358 pub family_state: FittedFamily,
359 pub family: String,
360 pub estimator: FittedEstimator,
367 #[serde(default)]
376 pub inference_notes: Vec<String>,
377 #[serde(default)]
392 pub basis_adequacy: Vec<crate::fit_orchestration::drivers::BasisAdequacyRow>,
393 #[serde(default)]
394 pub used_device: bool,
395 #[serde(default)]
396 pub fit_result: Option<UnifiedFitResult>,
397 #[serde(default)]
399 pub unified: Option<UnifiedFitResult>,
400 #[serde(default)]
408 pub spline_scan: Option<SavedSplineScan>,
409 #[serde(default)]
415 pub residual_cascade: Option<SavedResidualCascade>,
416 #[serde(default)]
417 pub data_schema: Option<DataSchema>,
418 pub link: Option<InverseLink>,
419 #[serde(default)]
420 pub mixture_link_param_covariance: Option<Vec<Vec<f64>>>,
421 #[serde(default)]
422 pub sas_param_covariance: Option<Vec<Vec<f64>>>,
423 #[serde(default)]
424 pub formula_noise: Option<String>,
425 #[serde(default)]
426 pub formula_logslope: Option<String>,
427 #[serde(default)]
428 pub formula_logslopes: Option<Vec<String>>,
429 #[serde(default)]
430 pub offset_column: Option<String>,
431 #[serde(default)]
432 pub noise_offset_column: Option<String>,
433 #[serde(default)]
438 pub weight_column: Option<String>,
439 #[serde(default)]
440 pub beta_noise: Option<Vec<f64>>,
441 #[serde(default)]
442 pub noise_projection: Option<Vec<Vec<f64>>>,
443 #[serde(default)]
444 pub noise_center: Option<Vec<f64>>,
445 #[serde(default)]
446 pub noise_scale: Option<Vec<f64>>,
447 #[serde(default)]
448 pub noise_non_intercept_start: Option<usize>,
449 #[serde(default)]
453 pub noise_projection_ridge_alpha: Option<f64>,
454 #[serde(default)]
455 pub gaussian_response_scale: Option<f64>,
456 #[serde(default)]
457 pub linkwiggle_knots: Option<Vec<f64>>,
458 #[serde(default)]
459 pub linkwiggle_degree: Option<usize>,
460 #[serde(default)]
465 pub linkwiggle_penalty_metadata: Option<WigglePenaltyMetadata>,
466 #[serde(default)]
467 pub beta_link_wiggle: Option<Vec<f64>>,
468 #[serde(default)]
476 pub link_wiggle_index_shift: Option<Vec<f64>>,
477 #[serde(default)]
478 pub baseline_timewiggle_knots: Option<Vec<f64>>,
479 #[serde(default)]
480 pub baseline_timewiggle_degree: Option<usize>,
481 #[serde(default)]
482 pub baseline_timewiggle_penalty_orders: Option<Vec<usize>>,
483 #[serde(default)]
484 pub baseline_timewiggle_double_penalty: Option<bool>,
485 #[serde(default)]
486 pub beta_baseline_timewiggle: Option<Vec<f64>>,
487 #[serde(default)]
488 pub beta_baseline_timewiggle_by_cause: Option<Vec<Vec<f64>>>,
489 #[serde(default)]
490 pub z_column: Option<String>,
491 #[serde(default)]
492 pub z_columns: Option<Vec<String>>,
493 #[serde(default)]
494 pub latent_z_normalization: Option<SavedLatentZNormalization>,
495 #[serde(default)]
496 pub latent_score_contract: Option<SavedLatentScoreContract>,
497 #[serde(default)]
498 pub latent_measure: Option<LatentMeasureKind>,
499 #[serde(default)]
506 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
507 #[serde(default)]
516 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
517 #[serde(default)]
518 pub marginal_baseline: Option<f64>,
519 #[serde(default)]
520 pub logslope_baseline: Option<f64>,
521 #[serde(default)]
522 pub logslope_baselines: Option<Vec<f64>>,
523 #[serde(default)]
533 pub logslope_time_basis: Option<SurvivalCovariateTimeBasis>,
534 #[serde(default)]
535 pub score_warp_runtime: Option<SavedCompiledFlexBlock>,
536 #[serde(default)]
537 pub link_deviation_runtime: Option<SavedCompiledFlexBlock>,
538 #[serde(default)]
543 pub influence_absorber_width: Option<usize>,
544 pub influence_absorber_design: Option<Vec<Vec<f64>>>,
549 pub survival_marginal_slope_score_covariance: Option<Vec<Vec<f64>>>,
552 #[serde(default)]
553 pub survival_entry: Option<String>,
554 #[serde(default)]
555 pub survival_exit: Option<String>,
556 #[serde(default)]
557 pub survival_event: Option<String>,
558 #[serde(default)]
559 pub survivalspec: Option<String>,
560 #[serde(default)]
561 pub survival_cause_count: Option<usize>,
562 #[serde(default)]
563 pub survival_endpoint_names: Option<Vec<String>>,
564 #[serde(default)]
565 pub survival_baseline_target: Option<String>,
566 #[serde(default)]
567 pub survival_baseline_scale: Option<f64>,
568 #[serde(default)]
569 pub survival_baseline_shape: Option<f64>,
570 #[serde(default)]
571 pub survival_baseline_rate: Option<f64>,
572 #[serde(default)]
573 pub survival_baseline_makeham: Option<f64>,
574 #[serde(default)]
575 pub survival_time_basis: Option<String>,
576 #[serde(default)]
577 pub survival_time_degree: Option<usize>,
578 #[serde(default)]
579 pub survival_time_knots: Option<Vec<f64>>,
580 #[serde(default)]
581 pub survival_time_keep_cols: Option<Vec<usize>>,
582 #[serde(default)]
583 pub survival_time_smooth_lambda: Option<f64>,
584 #[serde(default)]
585 pub survival_time_anchor: Option<f64>,
586 #[serde(default)]
587 pub survivalridge_lambda: Option<f64>,
588 #[serde(default)]
589 pub survival_likelihood: Option<String>,
590 pub survival_location_scale_structure: Option<SavedSurvivalLocationScaleStructure>,
594 #[serde(default)]
595 pub survival_beta_time: Option<Vec<f64>>,
596 #[serde(default)]
597 pub survival_beta_threshold: Option<Vec<f64>>,
598 #[serde(default)]
599 pub survival_beta_log_sigma: Option<Vec<f64>>,
600 #[serde(default)]
601 pub survival_distribution: Option<ResidualDistribution>,
602 #[serde(default)]
603 pub training_headers: Option<Vec<String>>,
604 pub training_table_kind: String,
610 #[serde(default)]
618 pub training_feature_ranges: Option<Vec<(f64, f64)>>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub group_metadata: Option<GroupMetadata>,
626 #[serde(default, skip_serializing_if = "Vec::is_empty")]
633 pub deployment_extensions: Vec<SavedDeploymentExtension>,
634 #[serde(default)]
636 pub transformation_response_knots: Option<Vec<f64>>,
637 #[serde(default)]
639 pub transformation_response_transform: Option<Vec<Vec<f64>>>,
640 #[serde(default)]
642 pub transformation_response_degree: Option<usize>,
643 #[serde(default)]
645 pub transformation_response_median: Option<f64>,
646 #[serde(default)]
651 pub transformation_geometry: Option<SavedTransformationNormalGeometry>,
652 #[serde(default)]
662 pub transformation_cone_carrier: Option<Vec<f64>>,
663 #[serde(default)]
667 pub transformation_score_calibration: Option<TransformationScoreCalibration>,
668 #[serde(default)]
669 pub resolved_termspec: Option<TermCollectionSpec>,
670 #[serde(default)]
671 pub resolved_termspec_noise: Option<TermCollectionSpec>,
672 #[serde(default)]
673 pub resolved_termspec_logslope: Option<TermCollectionSpec>,
674 #[serde(default)]
675 pub resolved_termspec_logslopes: Option<Vec<TermCollectionSpec>>,
676 #[serde(default)]
677 pub adaptive_regularization_diagnostics: Option<AdaptiveRegularizationDiagnostics>,
678 #[serde(default)]
692 pub gaussian_jackknife_plus:
693 Option<crate::inference::full_conformal::GaussianJackknifePlusStats>,
694 #[serde(default)]
712 pub full_conformal: Option<crate::inference::full_conformal::ExactFullConformalSubstrate>,
713}
714
715#[derive(Clone, Debug, Serialize, Deserialize)]
716pub struct SavedDeploymentExtension {
717 pub name: String,
718 pub kind: String,
719 pub term: String,
720 pub level: JsonValue,
721 pub level_bits: u64,
722 pub coefficient_index: usize,
723 pub coefficient_mean: f64,
724 pub coefficient_variance: f64,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub metadata: Option<JsonValue>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub prior: Option<JsonValue>,
729}
730
731pub fn append_deployment_extension_columns(
748 model: &FittedModelPayload,
749 data: ndarray::ArrayView2<'_, f64>,
750 col_map: &HashMap<String, usize>,
751 training_headers: Option<&Vec<String>>,
752 base_design: Array2<f64>,
753) -> Result<Array2<f64>, FittedModelError> {
754 if model.deployment_extensions.is_empty() {
755 return Ok(base_design);
756 }
757 if base_design.nrows() != data.nrows() {
758 return Err(FittedModelError::SchemaMismatch {
759 reason: format!(
760 "deployment extension design row mismatch: base design has {} rows but data has {}",
761 base_design.nrows(),
762 data.nrows()
763 ),
764 });
765 }
766 let spec = model
767 .resolved_termspec
768 .as_ref()
769 .ok_or_else(|| FittedModelError::MissingField {
770 reason: "deployment extension prediction requires saved resolved_termspec; refit"
771 .to_string(),
772 })?;
773 let n = base_design.nrows();
774 let p_old = base_design.ncols();
775 let mut extensions: Vec<&SavedDeploymentExtension> =
776 model.deployment_extensions.iter().collect();
777 extensions.sort_by_key(|extension| extension.coefficient_index);
778 for (tail_idx, extension) in extensions.iter().enumerate() {
779 let expected = p_old + tail_idx;
780 if extension.coefficient_index != expected {
781 return Err(FittedModelError::SchemaMismatch {
782 reason: format!(
783 "deployment extension '{}' has coefficient index {}, expected append-only index {}",
784 extension.name, extension.coefficient_index, expected
785 ),
786 });
787 }
788 }
789
790 let mut out = Array2::<f64>::zeros((n, p_old + extensions.len()));
791 out.slice_mut(ndarray::s![.., ..p_old]).assign(&base_design);
792 for (tail_idx, extension) in extensions.into_iter().enumerate() {
793 if extension.kind != "random-effect-level" {
794 return Err(FittedModelError::IncompatibleConfig {
795 reason: format!(
796 "unsupported deployment extension kind '{}' for '{}'",
797 extension.kind, extension.name
798 ),
799 });
800 }
801 let term = spec
802 .random_effect_terms
803 .iter()
804 .find(|term| term.name == extension.term)
805 .ok_or_else(|| FittedModelError::MissingField {
806 reason: format!(
807 "deployment extension '{}' references unknown random-effect term '{}'",
808 extension.name, extension.term
809 ),
810 })?;
811 let prediction_col = training_headers
812 .and_then(|headers| headers.get(term.feature_col))
813 .and_then(|name| col_map.get(name))
814 .copied()
815 .unwrap_or(term.feature_col);
816 if prediction_col >= data.ncols() {
817 return Err(FittedModelError::SchemaMismatch {
818 reason: format!(
819 "deployment extension '{}' feature column {} out of bounds for {} prediction columns",
820 extension.name,
821 prediction_col,
822 data.ncols()
823 ),
824 });
825 }
826 let col = p_old + tail_idx;
827 let level_bits = gam_data::canonical_level_bits(f64::from_bits(extension.level_bits));
828 for row in 0..n {
829 if gam_data::canonical_level_bits(data[[row, prediction_col]]) == level_bits {
830 out[[row, col]] = 1.0;
831 }
832 }
833 }
834 Ok(out)
835}
836
837#[derive(Clone, Debug, Serialize, Deserialize)]
838pub struct SavedLatentScoreContract {
839 pub semantics: String,
840 pub source_transform_id: Option<String>,
841 pub normalization_mean: f64,
842 pub normalization_sd: f64,
843 pub clip_eps: Option<f64>,
844 pub conditioning_columns: Vec<String>,
845}
846
847impl FittedModelPayload {
848 pub fn new(
849 version: u32,
850 formula: String,
851 model_kind: ModelKind,
852 family_state: FittedFamily,
853 family: String,
854 ) -> Self {
855 Self {
856 version,
857 formula,
858 model_kind,
859 family_state,
860 family,
861 estimator: FittedEstimator::Likelihood,
862 inference_notes: Vec::new(),
863 basis_adequacy: Vec::new(),
864 used_device: false,
865 fit_result: None,
866 unified: None,
867 spline_scan: None,
868 residual_cascade: None,
869 data_schema: None,
870 link: None,
871 mixture_link_param_covariance: None,
872 sas_param_covariance: None,
873 formula_noise: None,
874 formula_logslope: None,
875 formula_logslopes: None,
876 offset_column: None,
877 noise_offset_column: None,
878 weight_column: None,
879 beta_noise: None,
880 noise_projection: None,
881 noise_center: None,
882 noise_scale: None,
883 noise_non_intercept_start: None,
884 noise_projection_ridge_alpha: None,
885 gaussian_response_scale: None,
886 linkwiggle_knots: None,
887 linkwiggle_degree: None,
888 linkwiggle_penalty_metadata: None,
889 beta_link_wiggle: None,
890 link_wiggle_index_shift: None,
891 baseline_timewiggle_knots: None,
892 baseline_timewiggle_degree: None,
893 baseline_timewiggle_penalty_orders: None,
894 baseline_timewiggle_double_penalty: None,
895 beta_baseline_timewiggle: None,
896 beta_baseline_timewiggle_by_cause: None,
897 z_column: None,
898 z_columns: None,
899 latent_z_normalization: None,
900 latent_score_contract: None,
901 latent_measure: None,
902 latent_z_rank_int_calibration: None,
903 latent_z_conditional_calibration: None,
904 marginal_baseline: None,
905 logslope_baseline: None,
906 logslope_baselines: None,
907 logslope_time_basis: None,
908 score_warp_runtime: None,
909 link_deviation_runtime: None,
910 influence_absorber_width: None,
911 influence_absorber_design: None,
912 survival_marginal_slope_score_covariance: None,
913 survival_entry: None,
914 survival_exit: None,
915 survival_event: None,
916 survivalspec: None,
917 survival_cause_count: None,
918 survival_endpoint_names: None,
919 survival_baseline_target: None,
920 survival_baseline_scale: None,
921 survival_baseline_shape: None,
922 survival_baseline_rate: None,
923 survival_baseline_makeham: None,
924 survival_time_basis: None,
925 survival_time_degree: None,
926 survival_time_knots: None,
927 survival_time_keep_cols: None,
928 survival_time_smooth_lambda: None,
929 survival_time_anchor: None,
930 survivalridge_lambda: None,
931 survival_likelihood: None,
932 survival_location_scale_structure: None,
933 survival_beta_time: None,
934 survival_beta_threshold: None,
935 survival_beta_log_sigma: None,
936 survival_distribution: None,
937 training_headers: None,
938 training_table_kind: "unknown".to_string(),
939 training_feature_ranges: None,
940 group_metadata: None,
941 deployment_extensions: Vec::new(),
942 transformation_response_knots: None,
943 transformation_response_transform: None,
944 transformation_response_degree: None,
945 transformation_response_median: None,
946 transformation_geometry: None,
947 transformation_cone_carrier: None,
948 transformation_score_calibration: None,
949 resolved_termspec: None,
950 resolved_termspec_noise: None,
951 resolved_termspec_logslope: None,
952 resolved_termspec_logslopes: None,
953 adaptive_regularization_diagnostics: None,
954 gaussian_jackknife_plus: None,
955 full_conformal: None,
956 }
957 }
958
959 pub fn set_training_feature_metadata(
960 &mut self,
961 headers: Vec<String>,
962 feature_ranges: Vec<(f64, f64)>,
963 ) {
964 self.training_headers = Some(headers);
965 self.training_feature_ranges = Some(feature_ranges);
966 }
967
968 fn synchronize_empty_feature_contract(&mut self) {
969 if self.fit_result.is_none() {
970 return;
971 }
972 let Some(schema) = self.data_schema.as_ref() else {
973 return;
974 };
975 if !schema.columns.is_empty() {
976 return;
977 }
978 self.training_headers.get_or_insert_with(Vec::new);
979 self.resolved_termspec
980 .get_or_insert_with(|| TermCollectionSpec {
981 linear_terms: Vec::new(),
982 smooth_terms: Vec::new(),
983 random_effect_terms: Vec::new(),
984 });
985 }
986
987 pub fn apply_survival_time_basis(
995 &mut self,
996 snapshot: &crate::survival::construction::SavedSurvivalTimeBasis,
997 ) {
998 self.survival_time_basis = Some(snapshot.basisname.clone());
999 self.survival_time_degree = snapshot.degree;
1000 self.survival_time_knots = snapshot.knots.clone();
1001 self.survival_time_keep_cols = snapshot.keep_cols.clone();
1002 self.survival_time_smooth_lambda = snapshot.smooth_lambda;
1003 self.survival_time_anchor = Some(snapshot.anchor);
1004 }
1005
1006 fn validate_payload_version(&self) -> Result<(), FittedModelError> {
1007 if self.version != MODEL_PAYLOAD_VERSION {
1008 return Err(FittedModelError::SchemaMismatch {
1009 reason: format!(
1010 "saved model payload schema mismatch: file has version={}, \
1011 this binary expects MODEL_PAYLOAD_VERSION={}. \
1012 Refit with the current CLI, or rebuild the reader at the same \
1013 version the model was written with.",
1014 self.version, MODEL_PAYLOAD_VERSION
1015 ),
1016 });
1017 }
1018 Ok(())
1019 }
1020}
1021
1022#[derive(Clone, Serialize, Deserialize)]
1023#[serde(tag = "model_type", rename_all = "kebab-case")]
1024pub enum FittedModel {
1025 Standard { payload: FittedModelPayload },
1026 LocationScale { payload: FittedModelPayload },
1027 MarginalSlope { payload: FittedModelPayload },
1028 Survival { payload: FittedModelPayload },
1029 TransformationNormal { payload: FittedModelPayload },
1030}
1031
1032#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
1033#[serde(rename_all = "kebab-case")]
1034pub enum ModelKind {
1035 Standard,
1036 LocationScale,
1037 MarginalSlope,
1038 Survival,
1039 TransformationNormal,
1040}
1041
1042#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
1048#[serde(tag = "estimator_kind", rename_all = "kebab-case")]
1049pub enum FittedEstimator {
1050 Likelihood,
1051 Expectile { tau: f64 },
1052}
1053
1054#[derive(Clone, Debug, Serialize, Deserialize)]
1055#[serde(tag = "family_kind", rename_all = "kebab-case")]
1056pub enum FittedFamily {
1057 Standard {
1058 likelihood: LikelihoodSpec,
1059 #[serde(default)]
1060 link: Option<StandardLink>,
1061 #[serde(default)]
1062 latent_cloglog_state: Option<LatentCLogLogState>,
1063 #[serde(default)]
1064 mixture_state: Option<MixtureLinkState>,
1065 #[serde(default)]
1066 sas_state: Option<SasLinkState>,
1067 },
1068 LocationScale {
1069 likelihood: LikelihoodSpec,
1070 #[serde(default)]
1071 base_link: Option<InverseLink>,
1072 },
1073 MarginalSlope {
1074 likelihood: LikelihoodSpec,
1075 base_link: InverseLink,
1076 frailty: FrailtySpec,
1077 },
1078 Survival {
1079 likelihood: LikelihoodSpec,
1080 #[serde(default)]
1081 survival_likelihood: Option<String>,
1082 #[serde(default)]
1083 survival_distribution: Option<ResidualDistribution>,
1084 frailty: FrailtySpec,
1085 },
1086 LatentSurvival {
1087 frailty: FrailtySpec,
1088 },
1089 LatentBinary {
1090 frailty: FrailtySpec,
1091 },
1092 TransformationNormal {
1093 likelihood: LikelihoodSpec,
1094 },
1095}
1096
1097#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1098pub enum PredictModelClass {
1099 Standard,
1100 GaussianLocationScale,
1101 BinomialLocationScale,
1102 DispersionLocationScale,
1107 BernoulliMarginalSlope,
1108 Survival,
1109 TransformationNormal,
1110}
1111
1112impl PredictModelClass {
1113 #[inline]
1114 pub const fn name(self) -> &'static str {
1115 match self {
1116 Self::Standard => "standard",
1117 Self::GaussianLocationScale => "gaussian location-scale",
1118 Self::BinomialLocationScale => "binomial location-scale",
1119 Self::DispersionLocationScale => "dispersion location-scale",
1120 Self::BernoulliMarginalSlope => "bernoulli marginal-slope",
1121 Self::Survival => "survival",
1122 Self::TransformationNormal => "transformation-normal",
1123 }
1124 }
1125}
1126
1127#[derive(Clone, Debug)]
1128pub struct SavedLinkWiggleRuntime {
1129 pub knots: Vec<f64>,
1130 pub degree: usize,
1131 pub penalty_metadata: Option<WigglePenaltyMetadata>,
1135 pub beta: Vec<f64>,
1136 pub index_shift: Option<Vec<f64>>,
1144}
1145
1146#[derive(Clone, Debug)]
1147pub struct SavedBaselineTimeWiggleRuntime {
1148 pub knots: Vec<f64>,
1149 pub degree: usize,
1150 pub penalty_orders: Vec<usize>,
1151 pub double_penalty: bool,
1152 pub beta: Vec<f64>,
1153}
1154
1155pub use crate::bms::deviation_runtime::ParametricAnchorBlock;
1158
1159#[derive(Clone, Debug, Serialize, Deserialize)]
1160pub struct SavedCompiledFlexBlock {
1161 pub kernel: String,
1162 pub breakpoints: Vec<f64>,
1163 pub basis_dim: usize,
1164 pub span_c0: Vec<Vec<f64>>,
1165 pub span_c1: Vec<Vec<f64>>,
1166 pub span_c2: Vec<Vec<f64>>,
1167 pub span_c3: Vec<Vec<f64>>,
1168 #[serde(default)]
1174 pub anchor_correction: Option<Vec<Vec<f64>>>,
1175 #[serde(default)]
1179 pub anchor_components: Vec<SavedAnchorComponent>,
1180}
1181
1182#[derive(Clone, Debug, Serialize, Deserialize)]
1183pub struct SavedAnchorComponent {
1184 pub kind: SavedAnchorKind,
1185}
1186
1187#[derive(Clone, Debug, Serialize, Deserialize)]
1188pub enum SavedAnchorKind {
1189 Parametric {
1190 block: ParametricAnchorBlock,
1191 ncols: usize,
1192 },
1193 FlexEvaluation { ncols: usize },
1198}
1199
1200#[derive(Clone, Debug)]
1201pub struct SavedPredictionRuntime {
1202 pub model_class: PredictModelClass,
1203 pub likelihood: LikelihoodSpec,
1204 pub inverse_link: Option<InverseLink>,
1205 pub link_wiggle: Option<SavedLinkWiggleRuntime>,
1206 pub baseline_time_wiggle: Option<SavedBaselineTimeWiggleRuntime>,
1207 pub score_warp: Option<SavedCompiledFlexBlock>,
1208 pub link_deviation: Option<SavedCompiledFlexBlock>,
1209 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
1213 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
1219 pub influence_absorber_width: Option<usize>,
1229}
1230
1231pub fn gaussian_location_scale_mean_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1232 fit.block_by_role(BlockRole::Location)
1233 .or_else(|| fit.block_by_role(BlockRole::Mean))
1234 .map(|block| block.beta.clone())
1235}
1236
1237pub fn binomial_location_scale_threshold_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1238 fit.block_by_role(BlockRole::Threshold)
1239 .or_else(|| fit.block_by_role(BlockRole::Location))
1240 .or_else(|| fit.block_by_role(BlockRole::Mean))
1241 .map(|block| block.beta.clone())
1242}
1243
1244pub fn location_scale_noise_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1245 fit.block_by_role(BlockRole::Scale)
1246 .map(|block| block.beta.clone())
1247}
1248
1249fn is_dispersion_location_scale_response(response: &gam_problem::types::ResponseFamily) -> bool {
1258 use gam_problem::types::ResponseFamily;
1259 matches!(
1260 response,
1261 ResponseFamily::NegativeBinomial { .. }
1262 | ResponseFamily::Gamma
1263 | ResponseFamily::Beta { .. }
1264 | ResponseFamily::Tweedie { .. }
1265 )
1266}
1267
1268fn validate_location_scale_saved_fit(
1269 fit: &UnifiedFitResult,
1270 model_class: PredictModelClass,
1271 link_wiggle: Option<&SavedLinkWiggleRuntime>,
1272) -> Result<(), FittedModelError> {
1273 let primary = match model_class {
1274 PredictModelClass::GaussianLocationScale | PredictModelClass::DispersionLocationScale => {
1278 gaussian_location_scale_mean_beta(fit)
1279 }
1280 PredictModelClass::BinomialLocationScale => binomial_location_scale_threshold_beta(fit),
1281 _ => None,
1282 }
1283 .ok_or_else(|| FittedModelError::MissingField {
1284 reason: match model_class {
1285 PredictModelClass::GaussianLocationScale => {
1286 "gaussian-location-scale saved fit is missing mean/location block".to_string()
1287 }
1288 PredictModelClass::DispersionLocationScale => {
1289 "dispersion-location-scale saved fit is missing mean/location block".to_string()
1290 }
1291 PredictModelClass::BinomialLocationScale => {
1292 "binomial-location-scale saved fit is missing threshold/location block".to_string()
1293 }
1294 _ => "location-scale saved fit is missing primary block".to_string(),
1295 },
1296 })?;
1297
1298 let scale = location_scale_noise_beta(fit).ok_or_else(|| FittedModelError::MissingField {
1299 reason: "location-scale saved fit is missing scale block".to_string(),
1300 })?;
1301 let expected =
1302 primary.len() + scale.len() + link_wiggle.map_or(0, |runtime| runtime.beta.len());
1303
1304 if let Some(cov) = fit.beta_covariance()
1305 && (cov.nrows() != expected || cov.ncols() != expected)
1306 {
1307 return Err(FittedModelError::SchemaMismatch {
1308 reason: format!(
1309 "location-scale saved conditional covariance shape mismatch: got {}x{}, expected {}x{}",
1310 cov.nrows(),
1311 cov.ncols(),
1312 expected,
1313 expected
1314 ),
1315 });
1316 }
1317 if let Some(cov) = fit.beta_covariance_corrected()
1318 && (cov.nrows() != expected || cov.ncols() != expected)
1319 {
1320 return Err(FittedModelError::SchemaMismatch {
1321 reason: format!(
1322 "location-scale saved corrected covariance shape mismatch: got {}x{}, expected {}x{}",
1323 cov.nrows(),
1324 cov.ncols(),
1325 expected,
1326 expected
1327 ),
1328 });
1329 }
1330 Ok(())
1331}
1332
1333fn validate_survival_saved_block_matches_payload(
1334 fit: &UnifiedFitResult,
1335 role: BlockRole,
1336 payload_beta: Option<&Vec<f64>>,
1337 label: &str,
1338) -> Result<usize, FittedModelError> {
1339 let block = fit
1340 .block_by_role(role)
1341 .ok_or_else(|| FittedModelError::MissingField {
1342 reason: format!("location-scale survival saved fit is missing {label} block"),
1343 })?;
1344 if let Some(saved) = payload_beta
1345 && block.beta.to_vec() != *saved
1346 {
1347 return Err(FittedModelError::SchemaMismatch {
1348 reason: format!(
1349 "location-scale survival saved {label} coefficients disagree with fit_result"
1350 ),
1351 });
1352 }
1353 Ok(block.beta.len())
1354}
1355
1356fn validate_survival_covariate_time_basis(
1357 basis: &SurvivalCovariateTimeBasis,
1358 label: &str,
1359) -> Result<usize, FittedModelError> {
1360 let minimum_knots =
1361 basis
1362 .degree
1363 .checked_add(2)
1364 .ok_or_else(|| FittedModelError::SchemaMismatch {
1365 reason: format!("saved {label} degree overflows"),
1366 })?;
1367 if basis.knots.len() < minimum_knots {
1368 return Err(FittedModelError::SchemaMismatch {
1369 reason: format!(
1370 "saved {label} knot vector has length {}, but degree {} requires at least {minimum_knots}",
1371 basis.knots.len(),
1372 basis.degree
1373 ),
1374 });
1375 }
1376 if basis.knots.iter().any(|value| !value.is_finite())
1377 || basis.knots.windows(2).any(|pair| pair[1] < pair[0])
1378 || basis.knots.first() == basis.knots.last()
1379 {
1380 return Err(FittedModelError::SchemaMismatch {
1381 reason: format!(
1382 "saved {label} knots must be finite, nondecreasing, and span a nonzero interval"
1383 ),
1384 });
1385 }
1386 Ok(basis.knots.len() - basis.degree - 1)
1387}
1388
1389fn validate_survival_location_scale_saved_fit(
1390 payload: &FittedModelPayload,
1391 link_wiggle: Option<&SavedLinkWiggleRuntime>,
1392) -> Result<(), FittedModelError> {
1393 let structure = payload
1394 .survival_location_scale_structure
1395 .as_ref()
1396 .ok_or_else(|| FittedModelError::MissingField {
1397 reason: "location-scale survival model is missing exact replay structure".to_string(),
1398 })?;
1399 let fit = payload
1400 .fit_result
1401 .as_ref()
1402 .ok_or_else(|| FittedModelError::MissingField {
1403 reason: "location-scale survival model is missing canonical fit_result payload"
1404 .to_string(),
1405 })?;
1406 let p_time = validate_survival_saved_block_matches_payload(
1407 fit,
1408 BlockRole::Time,
1409 payload.survival_beta_time.as_ref(),
1410 "time",
1411 )?;
1412 let p_threshold = validate_survival_saved_block_matches_payload(
1413 fit,
1414 BlockRole::Threshold,
1415 payload.survival_beta_threshold.as_ref(),
1416 "threshold",
1417 )?;
1418 let p_log_sigma = validate_survival_saved_block_matches_payload(
1419 fit,
1420 BlockRole::Scale,
1421 payload.survival_beta_log_sigma.as_ref(),
1422 "log-sigma",
1423 )?;
1424 if let Some(basis) = structure.threshold_time_basis.as_ref() {
1425 let width =
1426 validate_survival_covariate_time_basis(basis, "location-scale survival threshold time basis")?;
1427 if p_threshold % width != 0 {
1428 return Err(FittedModelError::SchemaMismatch {
1429 reason: format!(
1430 "location-scale survival threshold width {p_threshold} is not divisible by its saved time-basis width {width}"
1431 ),
1432 });
1433 }
1434 }
1435 if let Some(basis) = structure.log_sigma_time_basis.as_ref() {
1436 let width =
1437 validate_survival_covariate_time_basis(basis, "location-scale survival log-sigma time basis")?;
1438 if p_log_sigma % width != 0 {
1439 return Err(FittedModelError::SchemaMismatch {
1440 reason: format!(
1441 "location-scale survival log-sigma width {p_log_sigma} is not divisible by its saved time-basis width {width}"
1442 ),
1443 });
1444 }
1445 }
1446 let p_wiggle = match link_wiggle {
1447 Some(runtime) => {
1448 let block = fit.block_by_role(BlockRole::LinkWiggle).ok_or_else(|| {
1449 FittedModelError::MissingField {
1450 reason: "location-scale survival saved fit is missing link-wiggle block"
1451 .to_string(),
1452 }
1453 })?;
1454 if block.beta.to_vec() != runtime.beta {
1455 return Err(FittedModelError::SchemaMismatch {
1456 reason:
1457 "location-scale survival saved link-wiggle coefficients disagree with fit_result"
1458 .to_string(),
1459 });
1460 }
1461 runtime.beta.len()
1462 }
1463 None => {
1464 if fit.block_by_role(BlockRole::LinkWiggle).is_some() {
1465 return Err(FittedModelError::SchemaMismatch {
1466 reason:
1467 "location-scale survival saved fit has a LinkWiggle block without payload metadata"
1468 .to_string(),
1469 });
1470 }
1471 0
1472 }
1473 };
1474 let expected = p_time + p_threshold + p_log_sigma + p_wiggle;
1475
1476 match structure.time_parameterization {
1477 SurvivalLocationScaleTimeParameterization::MonotoneWarp => {}
1478 SurvivalLocationScaleTimeParameterization::ReducedParametricAft => {
1479 if payload.beta_baseline_timewiggle.is_some() || link_wiggle.is_some() {
1480 return Err(FittedModelError::SchemaMismatch {
1481 reason: "reduced parametric-AFT location-scale survival cannot carry a time or link wiggle"
1482 .to_string(),
1483 });
1484 }
1485 let time = fit
1486 .block_by_role(BlockRole::Time)
1487 .expect("time block was validated above");
1488 if time.beta.iter().any(|value| *value != 0.0) {
1489 return Err(FittedModelError::SchemaMismatch {
1490 reason: "reduced parametric-AFT location-scale survival time block must be the exact zero affine lift"
1491 .to_string(),
1492 });
1493 }
1494 }
1495 }
1496 if let Some(timewiggle_beta) = payload.beta_baseline_timewiggle.as_ref() {
1497 let time = fit
1498 .block_by_role(BlockRole::Time)
1499 .expect("time block was validated above");
1500 if timewiggle_beta.len() > time.beta.len()
1501 || time
1502 .beta
1503 .slice(ndarray::s![time.beta.len() - timewiggle_beta.len()..])
1504 .to_vec()
1505 != *timewiggle_beta
1506 {
1507 return Err(FittedModelError::SchemaMismatch {
1508 reason: "location-scale survival baseline-timewiggle coefficients must equal the protected tail of the time block"
1509 .to_string(),
1510 });
1511 }
1512 }
1513
1514 if let Some(cov) = fit.beta_covariance()
1515 && (cov.nrows() != expected || cov.ncols() != expected)
1516 {
1517 return Err(FittedModelError::SchemaMismatch {
1518 reason: format!(
1519 "location-scale survival saved conditional covariance shape mismatch: got {}x{}, expected {}x{}",
1520 cov.nrows(),
1521 cov.ncols(),
1522 expected,
1523 expected
1524 ),
1525 });
1526 }
1527 if let Some(cov) = fit.beta_covariance_corrected()
1528 && (cov.nrows() != expected || cov.ncols() != expected)
1529 {
1530 return Err(FittedModelError::SchemaMismatch {
1531 reason: format!(
1532 "location-scale survival saved corrected covariance shape mismatch: got {}x{}, expected {}x{}",
1533 cov.nrows(),
1534 cov.ncols(),
1535 expected,
1536 expected
1537 ),
1538 });
1539 }
1540 Ok(())
1541}
1542
1543fn validate_marginal_slope_saved_fit(
1544 fit: &UnifiedFitResult,
1545 score_warp: Option<&SavedCompiledFlexBlock>,
1546 link_deviation: Option<&SavedCompiledFlexBlock>,
1547 fit_label: &str,
1548) -> Result<(), FittedModelError> {
1549 validate_marginal_slope_saved_fit_impl(
1550 fit,
1551 score_warp,
1552 link_deviation,
1553 fit_label,
1554 "bernoulli",
1555 2,
1556 "marginal, logslope",
1557 None,
1558 )
1559}
1560
1561fn validate_survival_marginal_slope_saved_fit(
1562 payload: &FittedModelPayload,
1563 fit: &UnifiedFitResult,
1564 fit_label: &str,
1565) -> Result<(), FittedModelError> {
1566 validate_marginal_slope_saved_fit_impl(
1567 fit,
1568 payload.score_warp_runtime.as_ref(),
1569 payload.link_deviation_runtime.as_ref(),
1570 fit_label,
1571 "survival",
1572 3,
1573 "time, marginal, slope",
1574 payload.influence_absorber_width,
1575 )
1576 .and_then(|()| validate_survival_marginal_slope_replay_state(payload, fit, fit_label))
1577}
1578
1579fn validate_marginal_slope_saved_fit_impl(
1587 fit: &UnifiedFitResult,
1588 score_warp: Option<&SavedCompiledFlexBlock>,
1589 link_deviation: Option<&SavedCompiledFlexBlock>,
1590 fit_label: &str,
1591 family_kind: &str,
1592 base_block_count: usize,
1593 base_block_role_list: &str,
1594 influence_absorber_width: Option<usize>,
1595) -> Result<(), FittedModelError> {
1596 let expected_blocks = base_block_count
1597 + usize::from(score_warp.is_some())
1598 + usize::from(link_deviation.is_some())
1599 + usize::from(influence_absorber_width.is_some());
1600 if fit.blocks.len() != expected_blocks {
1601 let score_warp_suffix = if score_warp.is_some() {
1602 ", score-warp"
1603 } else {
1604 ""
1605 };
1606 let link_deviation_suffix = if link_deviation.is_some() {
1607 ", link-deviation"
1608 } else {
1609 ""
1610 };
1611 let influence_suffix = if influence_absorber_width.is_some() {
1612 ", influence-absorber"
1613 } else {
1614 ""
1615 };
1616 return Err(FittedModelError::SchemaMismatch {
1617 reason: format!(
1618 "{family_kind} marginal-slope saved {fit_label} requires {expected_blocks} blocks [{base_block_role_list}{score_warp_suffix}{link_deviation_suffix}{influence_suffix}], got {}",
1619 fit.blocks.len(),
1620 ),
1621 });
1622 }
1623 if let Some(runtime) = score_warp {
1624 let beta = &fit.blocks[base_block_count].beta;
1625 if beta.len() != runtime.basis_dim {
1626 return Err(FittedModelError::SchemaMismatch {
1627 reason: format!(
1628 "{family_kind} marginal-slope saved {fit_label} score-warp coefficient mismatch: beta has {} entries but runtime expects {}",
1629 beta.len(),
1630 runtime.basis_dim
1631 ),
1632 });
1633 }
1634 }
1635 if let Some(runtime) = link_deviation {
1636 let idx = base_block_count + usize::from(score_warp.is_some());
1637 let beta = &fit.blocks[idx].beta;
1638 if beta.len() != runtime.basis_dim {
1639 return Err(FittedModelError::SchemaMismatch {
1640 reason: format!(
1641 "{family_kind} marginal-slope saved {fit_label} link-deviation coefficient mismatch: beta has {} entries but runtime expects {}",
1642 beta.len(),
1643 runtime.basis_dim
1644 ),
1645 });
1646 }
1647 }
1648 if let Some(width) = influence_absorber_width {
1649 let idx = base_block_count
1650 + usize::from(score_warp.is_some())
1651 + usize::from(link_deviation.is_some());
1652 if width == 0 || fit.blocks[idx].beta.len() != width {
1653 return Err(FittedModelError::SchemaMismatch {
1654 reason: format!(
1655 "{family_kind} marginal-slope saved {fit_label} influence absorber width is {width}, but its fitted block has {} coefficients",
1656 fit.blocks[idx].beta.len(),
1657 ),
1658 });
1659 }
1660 }
1661 Ok(())
1662}
1663
1664fn validate_survival_marginal_slope_replay_state(
1665 payload: &FittedModelPayload,
1666 fit: &UnifiedFitResult,
1667 fit_label: &str,
1668) -> Result<(), FittedModelError> {
1669 let score_covariance = payload
1670 .survival_marginal_slope_score_covariance
1671 .as_ref()
1672 .ok_or_else(|| FittedModelError::MissingField {
1673 reason: format!(
1674 "survival marginal-slope saved {fit_label} is missing its exact latent-score covariance"
1675 ),
1676 })?;
1677 if score_covariance.len() != 1
1678 || score_covariance[0].len() != 1
1679 || !score_covariance[0][0].is_finite()
1680 || score_covariance[0][0] < 0.0
1681 {
1682 return Err(FittedModelError::SchemaMismatch {
1683 reason: format!(
1684 "survival marginal-slope saved {fit_label} scalar latent-score covariance must be a finite non-negative 1x1 matrix"
1685 ),
1686 });
1687 }
1688 if let Some(basis) = payload.logslope_time_basis.as_ref() {
1696 let width = validate_survival_covariate_time_basis(
1697 basis,
1698 &format!("survival marginal-slope {fit_label} log-slope time basis"),
1699 )?;
1700 let p_logslope = fit.blocks[2].beta.len();
1701 if width == 0 || p_logslope % width != 0 {
1702 return Err(FittedModelError::SchemaMismatch {
1703 reason: format!(
1704 "survival marginal-slope saved {fit_label} log-slope width {p_logslope} is not divisible by its saved time-margin width {width}"
1705 ),
1706 });
1707 }
1708 }
1709 match (
1710 payload.influence_absorber_width,
1711 payload.influence_absorber_design.as_ref(),
1712 ) {
1713 (None, None) => {}
1714 (Some(width), Some(rows)) => {
1715 if rows.is_empty()
1716 || rows
1717 .iter()
1718 .any(|row| row.len() != width || row.iter().any(|value| !value.is_finite()))
1719 {
1720 return Err(FittedModelError::SchemaMismatch {
1721 reason: format!(
1722 "survival marginal-slope saved {fit_label} influence absorber must be a non-empty finite rectangular matrix with width {width}"
1723 ),
1724 });
1725 }
1726 }
1727 _ => {
1728 return Err(FittedModelError::SchemaMismatch {
1729 reason: format!(
1730 "survival marginal-slope saved {fit_label} influence absorber width and exact training-row design must be present together"
1731 ),
1732 });
1733 }
1734 }
1735
1736 let timewiggle_metadata = (
1737 payload.baseline_timewiggle_knots.as_ref(),
1738 payload.baseline_timewiggle_degree,
1739 payload.beta_baseline_timewiggle.as_ref(),
1740 );
1741 match timewiggle_metadata {
1742 (None, None, None) => {}
1743 (Some(knots), Some(degree), Some(beta)) => {
1744 if beta.is_empty()
1745 || knots.len() < degree.saturating_add(2)
1746 || knots.iter().chain(beta).any(|value| !value.is_finite())
1747 {
1748 return Err(FittedModelError::SchemaMismatch {
1749 reason: format!(
1750 "survival marginal-slope saved {fit_label} has invalid exact baseline-timewiggle authority"
1751 ),
1752 });
1753 }
1754 let time_beta = &fit.blocks[0].beta;
1755 if time_beta.len() < beta.len()
1756 || time_beta
1757 .slice(ndarray::s![time_beta.len() - beta.len()..])
1758 .to_vec()
1759 != *beta
1760 {
1761 return Err(FittedModelError::SchemaMismatch {
1762 reason: format!(
1763 "survival marginal-slope saved {fit_label} baseline-timewiggle beta does not equal the protected tail of the fitted time block"
1764 ),
1765 });
1766 }
1767 }
1768 _ => {
1769 return Err(FittedModelError::SchemaMismatch {
1770 reason: format!(
1771 "survival marginal-slope saved {fit_label} baseline-timewiggle knots, degree, and beta must be present together"
1772 ),
1773 });
1774 }
1775 }
1776 if payload.beta_baseline_timewiggle_by_cause.is_some() {
1777 return Err(FittedModelError::SchemaMismatch {
1778 reason: "survival marginal-slope saved fit cannot carry cause-specific timewiggle coefficients"
1779 .to_string(),
1780 });
1781 }
1782 Ok(())
1783}
1784
1785impl SavedLinkWiggleRuntime {
1786 fn validate_monotone_derivative(
1787 &self,
1788 q0: &Array1<f64>,
1789 ) -> Result<Array1<f64>, FittedModelError> {
1790 let d_constrained = self.constrained_basis(q0, BasisOptions::first_derivative())?;
1797 let beta_link_wiggle = Array1::from_vec(self.beta.clone());
1798 let dq_dq0 = d_constrained.dot(&beta_link_wiggle) + 1.0;
1799 if let Some((idx, value)) = dq_dq0.iter().copied().enumerate().find(|(_, v)| *v <= 0.0) {
1800 return Err(FittedModelError::PayloadCorrupt {
1801 reason: format!(
1802 "saved link-wiggle is not monotone at row {idx}: dq/dq0={value:.3e} <= 0"
1803 ),
1804 });
1805 }
1806 Ok(dq_dq0)
1807 }
1808
1809 pub fn constrained_basis(
1810 &self,
1811 q0: &Array1<f64>,
1812 basis_options: BasisOptions,
1813 ) -> Result<Array2<f64>, FittedModelError> {
1814 let knot_arr = Array1::from_vec(self.knots.clone());
1815 let constrained = monotone_wiggle_basis_with_derivative_order(
1816 q0.view(),
1817 &knot_arr,
1818 self.degree,
1819 basis_options.derivative_order,
1820 )
1821 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
1822 if constrained.ncols() != self.beta.len() {
1823 return Err(FittedModelError::SchemaMismatch {
1824 reason: format!(
1825 "saved link-wiggle dimension mismatch: coefficients have {} entries but basis has {} columns",
1826 self.beta.len(),
1827 constrained.ncols()
1828 ),
1829 });
1830 }
1831 Ok(constrained)
1832 }
1833
1834 pub fn design(&self, q0: &Array1<f64>) -> Result<Array2<f64>, FittedModelError> {
1835 self.validate_monotone_derivative(q0)?;
1836 self.constrained_basis(q0, BasisOptions::value())
1837 }
1838
1839 pub fn warp_index(
1848 &self,
1849 base: &Array1<f64>,
1850 mean_design: &DesignMatrix,
1851 ) -> Result<Array1<f64>, FittedModelError> {
1852 if mean_design.nrows() != base.len() {
1853 return Err(FittedModelError::SchemaMismatch {
1854 reason: format!(
1855 "link-wiggle base predictor has {} rows but mean design has {}",
1856 base.len(),
1857 mean_design.nrows()
1858 ),
1859 });
1860 }
1861 if let Some((row, value)) = base
1862 .iter()
1863 .copied()
1864 .enumerate()
1865 .find(|(_, value)| !value.is_finite())
1866 {
1867 return Err(FittedModelError::InvalidInput {
1868 reason: format!("link-wiggle base predictor is non-finite at row {row}: {value}"),
1869 });
1870 }
1871 let Some(shift) = self.index_shift.as_ref() else {
1872 return Ok(base.clone());
1873 };
1874 if shift.len() != mean_design.ncols() {
1875 return Err(FittedModelError::SchemaMismatch {
1876 reason: format!(
1877 "link-wiggle frozen-index shift has {} entries but the mean design has {} columns",
1878 shift.len(),
1879 mean_design.ncols()
1880 ),
1881 });
1882 }
1883 if let Some((column, value)) = shift
1884 .iter()
1885 .copied()
1886 .enumerate()
1887 .find(|(_, value)| !value.is_finite())
1888 {
1889 return Err(FittedModelError::PayloadCorrupt {
1890 reason: format!(
1891 "link-wiggle frozen-index shift is non-finite at column {column}: {value}"
1892 ),
1893 });
1894 }
1895 let shift = Array1::from_vec(shift.clone());
1896 Ok(base + &mean_design.dot(&shift))
1897 }
1898
1899 pub fn basis_row_scalar(&self, q0: f64) -> Result<Array1<f64>, FittedModelError> {
1900 let q = Array1::from_vec(vec![q0]);
1901 let x = self.design(&q)?;
1902 if x.nrows() != 1 {
1903 return Err(FittedModelError::SchemaMismatch {
1904 reason: format!(
1905 "saved link-wiggle scalar evaluation expected 1 row, got {}",
1906 x.nrows()
1907 ),
1908 });
1909 }
1910 Ok(x.row(0).to_owned())
1911 }
1912
1913 pub fn apply(&self, q0: &Array1<f64>) -> Result<Array1<f64>, FittedModelError> {
1914 self.apply_with_index(q0, q0)
1915 }
1916
1917 pub fn apply_with_index(
1927 &self,
1928 base: &Array1<f64>,
1929 warp_index: &Array1<f64>,
1930 ) -> Result<Array1<f64>, FittedModelError> {
1931 if base.len() != warp_index.len() {
1932 return Err(FittedModelError::SchemaMismatch {
1933 reason: format!(
1934 "link-wiggle base predictor has {} rows but warp index has {}",
1935 base.len(),
1936 warp_index.len()
1937 ),
1938 });
1939 }
1940 self.validate_monotone_derivative(warp_index)?;
1941 let xwiggle = self.constrained_basis(warp_index, BasisOptions::value())?;
1942 let beta_link_wiggle = Array1::from_vec(self.beta.clone());
1943 Ok(base + &xwiggle.dot(&beta_link_wiggle))
1944 }
1945
1946 pub fn derivative_q0(&self, q0: &Array1<f64>) -> Result<Array1<f64>, FittedModelError> {
1947 self.validate_monotone_derivative(q0)
1948 }
1949}
1950
1951impl SavedBaselineTimeWiggleRuntime {
1952 pub fn validate_global_monotonicity(&self) -> Result<(), FittedModelError> {
1953 validate_monotone_wiggle_beta_nonnegative(&self.beta, "saved baseline-timewiggle")
1954 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })
1955 }
1956}
1957
1958impl SavedCompiledFlexBlock {
1959 pub(crate) fn validate_exact_replay_contract(&self) -> Result<(), FittedModelError> {
1960 if self.kernel.is_empty() {
1961 return Err(FittedModelError::SchemaMismatch {
1962 reason: "saved anchored deviation runtime is missing the exact kernel marker"
1963 .to_string(),
1964 });
1965 }
1966 if self.kernel != crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL {
1967 return Err(FittedModelError::IncompatibleConfig {
1968 reason: format!(
1969 "saved anchored deviation runtime uses unsupported kernel '{}'; expected {}",
1970 self.kernel,
1971 crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL
1972 ),
1973 });
1974 }
1975 if self.basis_dim == 0 {
1976 return Err(FittedModelError::SchemaMismatch {
1977 reason: format!(
1978 "saved anchored deviation runtime basis_dim must be positive, got {}",
1979 self.basis_dim
1980 ),
1981 });
1982 }
1983 if self.breakpoints.len() < 2 {
1984 return Err(FittedModelError::SchemaMismatch {
1985 reason: format!(
1986 "saved anchored deviation runtime requires at least two breakpoints, got {}",
1987 self.breakpoints.len()
1988 ),
1989 });
1990 }
1991 for window in self.breakpoints.windows(2) {
1992 let left = window[0];
1993 let right = window[1];
1994 if !left.is_finite() || !right.is_finite() || right <= left {
1995 return Err(FittedModelError::PayloadCorrupt {
1996 reason: format!(
1997 "saved anchored deviation runtime breakpoints must be finite and strictly increasing, got [{left}, {right}]"
1998 ),
1999 });
2000 }
2001 }
2002 let span_count = self.breakpoints.len() - 1;
2003 self.validate_coefficient_matrix(&self.span_c0, "c0", span_count)?;
2004 self.validate_coefficient_matrix(&self.span_c1, "c1", span_count)?;
2005 self.validate_coefficient_matrix(&self.span_c2, "c2", span_count)?;
2006 self.validate_coefficient_matrix(&self.span_c3, "c3", span_count)?;
2007 self.validate_c2_span_continuity()?;
2008 self.validate_anchor_residual_shape()?;
2009 Ok(())
2010 }
2011
2012 fn validate_anchor_residual_shape(&self) -> Result<(), FittedModelError> {
2013 let coeffs = match self.anchor_correction.as_ref() {
2014 Some(c) => c,
2015 None => {
2016 if !self.anchor_components.is_empty() {
2017 return Err(FittedModelError::SchemaMismatch {
2018 reason:
2019 "saved anchored deviation runtime has anchor_components but no anchor_correction"
2020 .to_string(),
2021 });
2022 }
2023 return Ok(());
2024 }
2025 };
2026 let d: usize = self
2027 .anchor_components
2028 .iter()
2029 .map(|c| match &c.kind {
2030 SavedAnchorKind::Parametric { ncols, .. } => *ncols,
2031 SavedAnchorKind::FlexEvaluation { ncols } => *ncols,
2032 })
2033 .sum();
2034 if coeffs.len() != d {
2035 return Err(FittedModelError::SchemaMismatch {
2036 reason: format!(
2037 "saved anchored deviation runtime anchor_correction has {} rows; expected {} (sum of component ncols)",
2038 coeffs.len(),
2039 d,
2040 ),
2041 });
2042 }
2043 for (i, row) in coeffs.iter().enumerate() {
2044 if row.len() != self.basis_dim {
2045 return Err(FittedModelError::SchemaMismatch {
2046 reason: format!(
2047 "saved anchored deviation runtime anchor_correction row {} has width {}, expected basis_dim {}",
2048 i,
2049 row.len(),
2050 self.basis_dim,
2051 ),
2052 });
2053 }
2054 for (j, &v) in row.iter().enumerate() {
2055 if !v.is_finite() {
2056 return Err(FittedModelError::PayloadCorrupt {
2057 reason: format!(
2058 "saved anchored deviation runtime anchor_correction ({i},{j}) is non-finite"
2059 ),
2060 });
2061 }
2062 }
2063 }
2064 Ok(())
2065 }
2066
2067 fn validate_c2_span_continuity(&self) -> Result<(), FittedModelError> {
2068 const TOL: f64 = 1e-8;
2069 for span_idx in 1..self.breakpoints.len() - 1 {
2070 let left_span = span_idx - 1;
2071 let right_span = span_idx;
2072 let width = self.breakpoints[span_idx] - self.breakpoints[left_span];
2073 for basis_idx in 0..self.basis_dim {
2074 let left_value = self.span_c0[left_span][basis_idx]
2075 + self.span_c1[left_span][basis_idx] * width
2076 + self.span_c2[left_span][basis_idx] * width * width
2077 + self.span_c3[left_span][basis_idx] * width * width * width;
2078 let left_d1 = self.span_c1[left_span][basis_idx]
2079 + 2.0 * self.span_c2[left_span][basis_idx] * width
2080 + 3.0 * self.span_c3[left_span][basis_idx] * width * width;
2081 let left_d2 = 2.0 * self.span_c2[left_span][basis_idx]
2082 + 6.0 * self.span_c3[left_span][basis_idx] * width;
2083 let right_value = self.span_c0[right_span][basis_idx];
2084 let right_d1 = self.span_c1[right_span][basis_idx];
2085 let right_d2 = 2.0 * self.span_c2[right_span][basis_idx];
2086 if (left_value - right_value).abs() > TOL
2087 || (left_d1 - right_d1).abs() > TOL
2088 || (left_d2 - right_d2).abs() > TOL
2089 {
2090 return Err(FittedModelError::SchemaMismatch {
2091 reason: format!(
2092 "saved anchored deviation runtime must be C2 cubic at breakpoint {span_idx}, basis {basis_idx}: value jump={:.3e}, d1 jump={:.3e}, d2 jump={:.3e}",
2093 left_value - right_value,
2094 left_d1 - right_d1,
2095 left_d2 - right_d2
2096 ),
2097 });
2098 }
2099 }
2100 }
2101 Ok(())
2102 }
2103
2104 fn validate_coefficient_matrix(
2105 &self,
2106 matrix: &[Vec<f64>],
2107 label: &str,
2108 expected_rows: usize,
2109 ) -> Result<(), FittedModelError> {
2110 if matrix.len() != expected_rows {
2111 return Err(FittedModelError::SchemaMismatch {
2112 reason: format!(
2113 "saved anchored deviation runtime {label} row count mismatch: got {}, expected {}",
2114 matrix.len(),
2115 expected_rows
2116 ),
2117 });
2118 }
2119 for (row_idx, row) in matrix.iter().enumerate() {
2120 if row.len() != self.basis_dim {
2121 return Err(FittedModelError::SchemaMismatch {
2122 reason: format!(
2123 "saved anchored deviation runtime {label} row {} has width {}, expected {}",
2124 row_idx,
2125 row.len(),
2126 self.basis_dim
2127 ),
2128 });
2129 }
2130 for (j, &value) in row.iter().enumerate() {
2131 if !value.is_finite() {
2132 return Err(FittedModelError::PayloadCorrupt {
2133 reason: format!(
2134 "saved anchored deviation runtime {label} entry ({row_idx},{j}) is non-finite"
2135 ),
2136 });
2137 }
2138 }
2139 }
2140 Ok(())
2141 }
2142
2143 fn right_boundary_basis_value(&self, basis_idx: usize) -> f64 {
2144 let last_span = self.breakpoints.len() - 2;
2145 let width = self.breakpoints[last_span + 1] - self.breakpoints[last_span];
2146 self.span_c0[last_span][basis_idx]
2147 + self.span_c1[last_span][basis_idx] * width
2148 + self.span_c2[last_span][basis_idx] * width * width
2149 + self.span_c3[last_span][basis_idx] * width * width * width
2150 }
2151
2152 fn evaluate_span_polynomial_design(
2153 &self,
2154 values: &Array1<f64>,
2155 derivative_order: usize,
2156 ) -> Result<Array2<f64>, FittedModelError> {
2157 self.validate_exact_replay_contract()?;
2158 let (left_ep, right_ep) = self.support_interval()?;
2159 let mut out = Array2::<f64>::zeros((values.len(), self.basis_dim));
2160 for (row_idx, &value) in values.iter().enumerate() {
2161 if !value.is_finite() {
2162 return Err(FittedModelError::PayloadCorrupt {
2163 reason: format!(
2164 "saved anchored deviation runtime design value at row {row_idx} is non-finite ({value})"
2165 ),
2166 });
2167 }
2168 if value < left_ep {
2169 if derivative_order == 0 {
2170 for basis_idx in 0..self.basis_dim {
2171 out[[row_idx, basis_idx]] = self.span_c0[0][basis_idx];
2172 }
2173 }
2174 continue;
2175 }
2176 if value > right_ep {
2177 if derivative_order == 0 {
2178 for basis_idx in 0..self.basis_dim {
2179 out[[row_idx, basis_idx]] = self.right_boundary_basis_value(basis_idx);
2180 }
2181 }
2182 continue;
2183 }
2184 let span_idx = self.left_biased_span_index_for(value)?;
2185 let t = value - self.breakpoints[span_idx];
2186 for basis_idx in 0..self.basis_dim {
2187 let c0 = self.span_c0[span_idx][basis_idx];
2188 let c1 = self.span_c1[span_idx][basis_idx];
2189 let c2 = self.span_c2[span_idx][basis_idx];
2190 let c3 = self.span_c3[span_idx][basis_idx];
2191 out[[row_idx, basis_idx]] = match derivative_order {
2192 0 => c0 + c1 * t + c2 * t * t + c3 * t * t * t,
2193 1 => c1 + 2.0 * c2 * t + 3.0 * c3 * t * t,
2194 2 => 2.0 * c2 + 6.0 * c3 * t,
2195 3 => 6.0 * c3,
2196 4 => 0.0,
2197 other => {
2198 return Err(FittedModelError::IncompatibleConfig {
2199 reason: format!(
2200 "saved anchored deviation runtime only supports derivative orders up to 4, got {other}"
2201 ),
2202 });
2203 }
2204 };
2205 }
2206 }
2207 Ok(out)
2208 }
2209
2210 pub fn breakpoints(&self) -> Result<Vec<f64>, FittedModelError> {
2211 self.validate_exact_replay_contract()?;
2212 Ok(self.breakpoints.clone())
2213 }
2214
2215 pub fn span_count(&self) -> Result<usize, FittedModelError> {
2216 Ok(self.breakpoints()?.windows(2).count())
2217 }
2218
2219 pub fn span_index_for(&self, value: f64) -> Result<usize, FittedModelError> {
2220 let points = self.breakpoints()?;
2221 span_index_for_breakpoints(&points, value, "saved anchored deviation span lookup")
2222 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })
2223 }
2224
2225 fn left_biased_span_index_for(&self, value: f64) -> Result<usize, FittedModelError> {
2226 let mut span_idx = span_index_for_breakpoints(
2227 &self.breakpoints,
2228 value,
2229 "saved anchored deviation span lookup",
2230 )
2231 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
2232 if span_idx > 0 && value == self.breakpoints[span_idx] {
2235 span_idx -= 1;
2236 }
2237 Ok(span_idx)
2238 }
2239
2240 pub fn local_cubic_on_span(
2241 &self,
2242 beta: ArrayView1<'_, f64>,
2243 span_idx: usize,
2244 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
2245 self.validate_exact_replay_contract()?;
2246 if beta.len() != self.basis_dim {
2247 return Err(FittedModelError::SchemaMismatch {
2248 reason: format!(
2249 "saved anchored deviation coefficient length mismatch: got {}, expected {}",
2250 beta.len(),
2251 self.basis_dim
2252 ),
2253 });
2254 }
2255 self.local_cubic_on_span_validated(beta, span_idx)
2256 }
2257
2258 fn local_cubic_on_span_validated(
2259 &self,
2260 beta: ArrayView1<'_, f64>,
2261 span_idx: usize,
2262 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
2263 let points = &self.breakpoints;
2264 if span_idx + 1 >= points.len() {
2265 return Err(FittedModelError::SchemaMismatch {
2266 reason: format!(
2267 "saved anchored deviation span index {} out of range for {} spans",
2268 span_idx,
2269 points.len() - 1
2270 ),
2271 });
2272 }
2273 let left = points[span_idx];
2274 let right = points[span_idx + 1];
2275 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
2276 left,
2277 right,
2278 c0: self.span_c0[span_idx]
2279 .iter()
2280 .zip(beta.iter())
2281 .map(|(coeff, weight)| coeff * weight)
2282 .sum(),
2283 c1: self.span_c1[span_idx]
2284 .iter()
2285 .zip(beta.iter())
2286 .map(|(coeff, weight)| coeff * weight)
2287 .sum(),
2288 c2: self.span_c2[span_idx]
2289 .iter()
2290 .zip(beta.iter())
2291 .map(|(coeff, weight)| coeff * weight)
2292 .sum(),
2293 c3: self.span_c3[span_idx]
2294 .iter()
2295 .zip(beta.iter())
2296 .map(|(coeff, weight)| coeff * weight)
2297 .sum(),
2298 })
2299 }
2300
2301 pub fn basis_span_cubic(
2302 &self,
2303 span_idx: usize,
2304 basis_idx: usize,
2305 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
2306 self.validate_exact_replay_contract()?;
2307 if basis_idx >= self.basis_dim {
2308 return Err(FittedModelError::SchemaMismatch {
2309 reason: format!(
2310 "saved anchored deviation basis index {} out of range for {} coefficients",
2311 basis_idx, self.basis_dim
2312 ),
2313 });
2314 }
2315 self.basis_span_cubic_validated(span_idx, basis_idx)
2316 }
2317
2318 fn basis_span_cubic_validated(
2319 &self,
2320 span_idx: usize,
2321 basis_idx: usize,
2322 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
2323 let points = &self.breakpoints;
2324 if span_idx + 1 >= points.len() {
2325 return Err(FittedModelError::SchemaMismatch {
2326 reason: format!(
2327 "saved anchored deviation span index {} out of range for {} spans",
2328 span_idx,
2329 points.len() - 1
2330 ),
2331 });
2332 }
2333 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
2334 left: points[span_idx],
2335 right: points[span_idx + 1],
2336 c0: self.span_c0[span_idx][basis_idx],
2337 c1: self.span_c1[span_idx][basis_idx],
2338 c2: self.span_c2[span_idx][basis_idx],
2339 c3: self.span_c3[span_idx][basis_idx],
2340 })
2341 }
2342
2343 pub fn basis_cubic_at(
2344 &self,
2345 basis_idx: usize,
2346 value: f64,
2347 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
2348 self.validate_exact_replay_contract()?;
2349 if basis_idx >= self.basis_dim {
2350 return Err(FittedModelError::SchemaMismatch {
2351 reason: format!(
2352 "saved anchored deviation basis index {} out of range for {} coefficients",
2353 basis_idx, self.basis_dim
2354 ),
2355 });
2356 }
2357 let (left_ep, right_ep) = self.support_interval()?;
2358 if value < left_ep {
2359 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
2360 left: left_ep,
2361 right: left_ep + 1.0,
2362 c0: self.span_c0[0][basis_idx],
2363 c1: 0.0,
2364 c2: 0.0,
2365 c3: 0.0,
2366 });
2367 }
2368 if value > right_ep {
2369 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
2370 left: right_ep,
2371 right: right_ep + 1.0,
2372 c0: self.right_boundary_basis_value(basis_idx),
2373 c1: 0.0,
2374 c2: 0.0,
2375 c3: 0.0,
2376 });
2377 }
2378 let span_idx = self.left_biased_span_index_for(value)?;
2379 self.basis_span_cubic_validated(span_idx, basis_idx)
2380 }
2381
2382 pub fn local_cubic_at(
2383 &self,
2384 beta: ArrayView1<'_, f64>,
2385 value: f64,
2386 ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
2387 self.validate_exact_replay_contract()?;
2388 if beta.len() != self.basis_dim {
2389 return Err(FittedModelError::SchemaMismatch {
2390 reason: format!(
2391 "saved anchored deviation coefficient length mismatch: got {}, expected {}",
2392 beta.len(),
2393 self.basis_dim
2394 ),
2395 });
2396 }
2397 let (left_ep, right_ep) = self.support_interval()?;
2398 if value < left_ep {
2399 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
2400 left: left_ep,
2401 right: left_ep + 1.0,
2402 c0: self.span_c0[0]
2403 .iter()
2404 .zip(beta.iter())
2405 .map(|(coeff, weight)| coeff * weight)
2406 .sum(),
2407 c1: 0.0,
2408 c2: 0.0,
2409 c3: 0.0,
2410 });
2411 }
2412 if value > right_ep {
2413 return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
2414 left: right_ep,
2415 right: right_ep + 1.0,
2416 c0: (0..self.basis_dim)
2417 .map(|basis_idx| self.right_boundary_basis_value(basis_idx) * beta[basis_idx])
2418 .sum(),
2419 c1: 0.0,
2420 c2: 0.0,
2421 c3: 0.0,
2422 });
2423 }
2424 let span_idx = self.left_biased_span_index_for(value)?;
2425 self.local_cubic_on_span_validated(beta, span_idx)
2426 }
2427
2428 fn support_interval(&self) -> Result<(f64, f64), FittedModelError> {
2429 let points = self.breakpoints()?;
2430 match (points.first(), points.last()) {
2431 (Some(&left), Some(&right)) => Ok((left, right)),
2432 _ => Err(FittedModelError::MissingField {
2433 reason: "saved anchored deviation runtime is missing support breakpoints"
2434 .to_string(),
2435 }),
2436 }
2437 }
2438
2439 pub fn design(&self, values: &Array1<f64>) -> Result<Array2<f64>, FittedModelError> {
2440 self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)
2449 }
2450
2451 pub fn design_uncorrected(
2459 &self,
2460 values: &Array1<f64>,
2461 ) -> Result<Array2<f64>, FittedModelError> {
2462 self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)
2463 }
2464
2465 pub fn design_with_anchor_rows(
2474 &self,
2475 values: &Array1<f64>,
2476 anchor_rows: ndarray::ArrayView2<f64>,
2477 ) -> Result<Array2<f64>, FittedModelError> {
2478 let mut out =
2479 self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)?;
2480 if let Some(m_rows) = self.anchor_correction.as_ref() {
2481 let d = m_rows.len();
2482 if anchor_rows.nrows() != values.len() {
2483 return Err(FittedModelError::SchemaMismatch {
2484 reason: format!(
2485 "design_with_anchor_rows: anchor_rows has {} rows, expected {} (matching values)",
2486 anchor_rows.nrows(),
2487 values.len(),
2488 ),
2489 });
2490 }
2491 if anchor_rows.ncols() != d {
2492 return Err(FittedModelError::SchemaMismatch {
2493 reason: format!(
2494 "design_with_anchor_rows: anchor_rows has {} cols, expected {} (sum of component ncols)",
2495 anchor_rows.ncols(),
2496 d,
2497 ),
2498 });
2499 }
2500 let mut m_dense = Array2::<f64>::zeros((d, self.basis_dim));
2502 for (i, row) in m_rows.iter().enumerate() {
2503 if row.len() != self.basis_dim {
2504 return Err(FittedModelError::SchemaMismatch {
2505 reason: format!(
2506 "design_with_anchor_rows: anchor_correction row {} has length {}, expected basis_dim {}",
2507 i,
2508 row.len(),
2509 self.basis_dim,
2510 ),
2511 });
2512 }
2513 for (j, &v) in row.iter().enumerate() {
2514 m_dense[[i, j]] = v;
2515 }
2516 }
2517 let subtract = anchor_rows.dot(&m_dense);
2520 out = out - subtract;
2521 } else if anchor_rows.ncols() != 0 {
2522 return Err(FittedModelError::SchemaMismatch {
2523 reason: format!(
2524 "design_with_anchor_rows: runtime has no anchor residual but anchor_rows has {} cols",
2525 anchor_rows.ncols(),
2526 ),
2527 });
2528 }
2529 Ok(out)
2530 }
2531
2532 pub fn anchor_correction_matrix(
2540 &self,
2541 n_anchor_rows: ndarray::ArrayView2<f64>,
2542 ) -> Result<Option<Array2<f64>>, FittedModelError> {
2543 let Some(m_rows) = self.anchor_correction.as_ref() else {
2544 return Ok(None);
2545 };
2546 let d = m_rows.len();
2547 if n_anchor_rows.ncols() != d {
2548 return Err(FittedModelError::SchemaMismatch {
2549 reason: format!(
2550 "anchor_correction_matrix: anchor_rows has {} cols, expected {} (sum of component ncols)",
2551 n_anchor_rows.ncols(),
2552 d,
2553 ),
2554 });
2555 }
2556 let mut m_dense = Array2::<f64>::zeros((d, self.basis_dim));
2557 for (i, row) in m_rows.iter().enumerate() {
2558 if row.len() != self.basis_dim {
2559 return Err(FittedModelError::SchemaMismatch {
2560 reason: format!(
2561 "anchor_correction_matrix: M row {} has length {}, expected basis_dim {}",
2562 i,
2563 row.len(),
2564 self.basis_dim,
2565 ),
2566 });
2567 }
2568 for (j, &v) in row.iter().enumerate() {
2569 m_dense[[i, j]] = v;
2570 }
2571 }
2572 Ok(Some(n_anchor_rows.dot(&m_dense)))
2575 }
2576
2577 pub fn first_derivative_design(
2578 &self,
2579 values: &Array1<f64>,
2580 ) -> Result<Array2<f64>, FittedModelError> {
2581 self.evaluate_span_polynomial_design(
2582 values,
2583 BasisOptions::first_derivative().derivative_order,
2584 )
2585 }
2586
2587 pub fn second_derivative_design(
2588 &self,
2589 values: &Array1<f64>,
2590 ) -> Result<Array2<f64>, FittedModelError> {
2591 self.evaluate_span_polynomial_design(
2592 values,
2593 BasisOptions::second_derivative().derivative_order,
2594 )
2595 }
2596}
2597
2598impl FittedFamily {
2599 #[inline]
2600 pub fn likelihood(&self) -> LikelihoodSpec {
2601 let spec = match self {
2602 Self::Standard { likelihood, .. }
2603 | Self::LocationScale { likelihood, .. }
2604 | Self::MarginalSlope { likelihood, .. }
2605 | Self::Survival { likelihood, .. }
2606 | Self::TransformationNormal { likelihood, .. } => likelihood,
2607 Self::LatentSurvival { .. } | Self::LatentBinary { .. } => {
2608 return LikelihoodSpec::royston_parmar();
2609 }
2610 };
2611 spec.clone()
2612 }
2613
2614 #[inline]
2615 pub fn frailty(&self) -> Option<&FrailtySpec> {
2616 match self {
2617 Self::MarginalSlope { frailty, .. }
2618 | Self::Survival { frailty, .. }
2619 | Self::LatentSurvival { frailty }
2620 | Self::LatentBinary { frailty } => Some(frailty),
2621 _ => None,
2622 }
2623 }
2624}
2625
2626fn re_factor_smooth_group_col(basis: &gam_terms::smooth::SmoothBasisSpec) -> Option<usize> {
2633 use gam_terms::smooth::{FactorSmoothFlavour, SmoothBasisSpec};
2634 match basis {
2635 SmoothBasisSpec::FactorSmooth { spec } => {
2636 matches!(spec.flavour, FactorSmoothFlavour::Re).then_some(spec.group_col)
2637 }
2638 SmoothBasisSpec::ByVariable { inner, .. }
2639 | SmoothBasisSpec::FactorSumToZero { inner, .. } => re_factor_smooth_group_col(inner),
2640 SmoothBasisSpec::BySmooth { smooth, .. } => re_factor_smooth_group_col(smooth),
2641 _ => None,
2642 }
2643}
2644
2645fn collect_smooth_extrapolation_axes(
2651 basis: &gam_terms::smooth::SmoothBasisSpec,
2652 n_training_headers: usize,
2653 out: &mut std::collections::HashSet<usize>,
2654) {
2655 use gam_terms::smooth::SmoothBasisSpec;
2656 let push = |col: usize, out: &mut std::collections::HashSet<usize>| {
2657 if col < n_training_headers {
2658 out.insert(col);
2659 }
2660 };
2661 match basis {
2662 SmoothBasisSpec::BSpline1D { feature_col, .. } => push(*feature_col, out),
2664 SmoothBasisSpec::TensorBSpline { feature_cols, .. } => {
2667 for &c in feature_cols {
2668 push(c, out);
2669 }
2670 }
2671 SmoothBasisSpec::ThinPlate { feature_cols, .. }
2678 | SmoothBasisSpec::Matern { feature_cols, .. }
2679 | SmoothBasisSpec::MeasureJet { feature_cols, .. }
2680 | SmoothBasisSpec::Duchon { feature_cols, .. } => {
2681 for &c in feature_cols {
2682 push(c, out);
2683 }
2684 }
2685 SmoothBasisSpec::FactorSmooth { spec } => {
2689 for &c in &spec.continuous_cols {
2690 push(c, out);
2691 }
2692 }
2693 SmoothBasisSpec::ByVariable { inner, .. }
2695 | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
2696 collect_smooth_extrapolation_axes(inner, n_training_headers, out)
2697 }
2698 SmoothBasisSpec::BySmooth { smooth, .. } => {
2699 collect_smooth_extrapolation_axes(smooth, n_training_headers, out)
2700 }
2701 SmoothBasisSpec::Sphere { .. }
2707 | SmoothBasisSpec::ConstantCurvature { .. }
2708 | SmoothBasisSpec::Pca { .. } => {}
2709 }
2710}
2711
2712fn collect_by_variable_numeric_axes(
2731 basis: &gam_terms::smooth::SmoothBasisSpec,
2732 n_training_headers: usize,
2733 out: &mut std::collections::HashSet<usize>,
2734) {
2735 use gam_terms::smooth::{BySmoothKind, ByVarKind, SmoothBasisSpec};
2736 match basis {
2737 SmoothBasisSpec::ByVariable {
2738 inner,
2739 by_col,
2740 kind,
2741 ..
2742 } => {
2743 if matches!(kind, BySmoothKind::Numeric) && *by_col < n_training_headers {
2744 out.insert(*by_col);
2745 }
2746 collect_by_variable_numeric_axes(inner, n_training_headers, out);
2747 }
2748 SmoothBasisSpec::BySmooth { smooth, by_kind } => {
2749 if let ByVarKind::Numeric { feature_col } = by_kind
2750 && *feature_col < n_training_headers
2751 {
2752 out.insert(*feature_col);
2753 }
2754 collect_by_variable_numeric_axes(smooth, n_training_headers, out);
2755 }
2756 SmoothBasisSpec::FactorSumToZero { inner, .. } => {
2757 collect_by_variable_numeric_axes(inner, n_training_headers, out);
2758 }
2759 SmoothBasisSpec::BSpline1D { .. }
2764 | SmoothBasisSpec::FactorSmooth { .. }
2765 | SmoothBasisSpec::ThinPlate { .. }
2766 | SmoothBasisSpec::Sphere { .. }
2767 | SmoothBasisSpec::ConstantCurvature { .. }
2768 | SmoothBasisSpec::Matern { .. }
2769 | SmoothBasisSpec::MeasureJet { .. }
2770 | SmoothBasisSpec::Duchon { .. }
2771 | SmoothBasisSpec::Pca { .. }
2772 | SmoothBasisSpec::TensorBSpline { .. } => {}
2773 }
2774}
2775
2776impl FittedModel {
2777 pub fn axis_clip_to_training_ranges(
2786 &self,
2787 data: ndarray::ArrayView2<'_, f64>,
2788 col_map: &std::collections::HashMap<String, usize>,
2789 ) -> Option<ndarray::Array2<f64>> {
2790 let training_headers = self.training_headers.as_ref()?;
2791 let ranges = self.training_feature_ranges.as_ref()?;
2792 if training_headers.len() != ranges.len() {
2793 return None;
2794 }
2795 let mut kind_by_header: std::collections::HashMap<&str, ColumnKindTag> =
2796 std::collections::HashMap::new();
2797 if let Some(schema) = self.data_schema.as_ref() {
2798 for col in &schema.columns {
2799 kind_by_header.insert(col.name.as_str(), col.kind);
2800 }
2801 }
2802 let periodic_axes = self.training_periodic_axes(training_headers);
2808 let linear_axes = self.training_linear_axes(training_headers.len());
2816 let random_effect_axes = self.training_random_effect_axes(training_headers.len());
2821 let smooth_extrapolation_axes =
2829 self.training_smooth_extrapolation_axes(training_headers.len());
2830 let by_variable_axes = self.training_by_variable_numeric_axes(training_headers.len());
2836 let sphere_lat_bounds = self.training_sphere_latitude_bounds(training_headers);
2841 let mut clipped = data.to_owned();
2842 let mut any_clipped = false;
2843 for (col_in_training, (header, &(lo, hi))) in
2844 training_headers.iter().zip(ranges.iter()).enumerate()
2845 {
2846 let (lo, hi) = sphere_lat_bounds
2847 .get(&col_in_training)
2848 .copied()
2849 .unwrap_or((lo, hi));
2850 if !(lo.is_finite() && hi.is_finite()) || hi <= lo {
2851 continue;
2852 }
2853 if !matches!(
2854 kind_by_header.get(header.as_str()).copied(),
2855 Some(ColumnKindTag::Continuous)
2856 ) {
2857 continue;
2858 }
2859 if periodic_axes.contains(&col_in_training) {
2860 continue;
2861 }
2862 if linear_axes.contains(&col_in_training) {
2863 continue;
2864 }
2865 if random_effect_axes.contains(&col_in_training) {
2866 continue;
2867 }
2868 if smooth_extrapolation_axes.contains(&col_in_training) {
2869 continue;
2870 }
2871 if by_variable_axes.contains(&col_in_training) {
2872 continue;
2873 }
2874 let Some(&col_idx) = col_map.get(header) else {
2875 continue;
2876 };
2877 if col_idx >= clipped.ncols() {
2878 continue;
2879 }
2880 let mut col = clipped.column_mut(col_idx);
2881 for v in col.iter_mut() {
2882 if v.is_finite() {
2883 if *v < lo {
2884 *v = lo;
2885 any_clipped = true;
2886 } else if *v > hi {
2887 *v = hi;
2888 any_clipped = true;
2889 }
2890 }
2891 }
2892 }
2893 if any_clipped { Some(clipped) } else { None }
2894 }
2895
2896 fn saved_term_specs(&self) -> Vec<&TermCollectionSpec> {
2897 let mut specs: Vec<&TermCollectionSpec> = [
2898 self.resolved_termspec.as_ref(),
2899 self.resolved_termspec_noise.as_ref(),
2900 self.resolved_termspec_logslope.as_ref(),
2901 ]
2902 .into_iter()
2903 .flatten()
2904 .collect();
2905 if let Some(logslopes) = self.resolved_termspec_logslopes.as_ref() {
2906 specs.extend(logslopes.iter());
2907 }
2908 specs
2909 }
2910
2911 fn training_periodic_axes(
2918 &self,
2919 training_headers: &[String],
2920 ) -> std::collections::HashSet<usize> {
2921 use gam_terms::basis::BSplineKnotSpec;
2922 use gam_terms::smooth::SmoothBasisSpec;
2923 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2924 let Some(spec) = self.resolved_termspec.as_ref() else {
2925 return out;
2926 };
2927 for term in &spec.smooth_terms {
2928 match &term.basis {
2929 SmoothBasisSpec::Sphere { feature_cols, .. } => {
2935 if let Some(&lon_col) = feature_cols.get(1)
2936 && lon_col < training_headers.len()
2937 {
2938 out.insert(lon_col);
2939 }
2940 }
2941 SmoothBasisSpec::BSpline1D { feature_col, spec } => {
2943 if matches!(spec.knotspec, BSplineKnotSpec::PeriodicUniform { .. })
2944 && *feature_col < training_headers.len()
2945 {
2946 out.insert(*feature_col);
2947 }
2948 }
2949 SmoothBasisSpec::TensorBSpline { feature_cols, spec } => {
2952 for (i, marginal) in spec.marginalspecs.iter().enumerate() {
2953 if matches!(marginal.knotspec, BSplineKnotSpec::PeriodicUniform { .. })
2954 && let Some(&col) = feature_cols.get(i)
2955 && col < training_headers.len()
2956 {
2957 out.insert(col);
2958 }
2959 }
2960 }
2961 SmoothBasisSpec::ByVariable { .. }
2970 | SmoothBasisSpec::BySmooth { .. }
2971 | SmoothBasisSpec::FactorSumToZero { .. }
2972 | SmoothBasisSpec::FactorSmooth { .. }
2973 | SmoothBasisSpec::ThinPlate { .. }
2974 | SmoothBasisSpec::ConstantCurvature { .. }
2975 | SmoothBasisSpec::Matern { .. }
2976 | SmoothBasisSpec::MeasureJet { .. }
2977 | SmoothBasisSpec::Duchon { .. }
2978 | SmoothBasisSpec::Pca { .. } => {}
2979 }
2980 }
2981 out
2982 }
2983
2984 fn training_linear_axes(&self, n_training_headers: usize) -> std::collections::HashSet<usize> {
2995 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2996 for spec in self.saved_term_specs() {
2997 for term in &spec.linear_terms {
2998 for col in term.effective_feature_cols() {
2999 if col < n_training_headers {
3000 out.insert(col);
3001 }
3002 }
3003 }
3004 }
3005 out
3006 }
3007
3008 fn training_random_effect_axes(
3014 &self,
3015 n_training_headers: usize,
3016 ) -> std::collections::HashSet<usize> {
3017 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
3018 for spec in self.saved_term_specs() {
3019 for term in &spec.random_effect_terms {
3020 if term.feature_col < n_training_headers {
3021 out.insert(term.feature_col);
3022 }
3023 }
3024 }
3025 out
3026 }
3027
3028 fn training_smooth_extrapolation_axes(
3061 &self,
3062 n_training_headers: usize,
3063 ) -> std::collections::HashSet<usize> {
3064 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
3065 for spec in self.saved_term_specs() {
3066 for term in &spec.smooth_terms {
3067 collect_smooth_extrapolation_axes(&term.basis, n_training_headers, &mut out);
3068 }
3069 }
3070 out
3071 }
3072
3073 fn training_by_variable_numeric_axes(
3079 &self,
3080 n_training_headers: usize,
3081 ) -> std::collections::HashSet<usize> {
3082 let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
3083 for spec in self.saved_term_specs() {
3084 for term in &spec.smooth_terms {
3085 collect_by_variable_numeric_axes(&term.basis, n_training_headers, &mut out);
3086 }
3087 }
3088 out
3089 }
3090
3091 fn training_sphere_latitude_bounds(
3113 &self,
3114 training_headers: &[String],
3115 ) -> std::collections::HashMap<usize, (f64, f64)> {
3116 use gam_terms::smooth::SmoothBasisSpec;
3117 let mut out: std::collections::HashMap<usize, (f64, f64)> =
3118 std::collections::HashMap::new();
3119 let Some(spec) = self.resolved_termspec.as_ref() else {
3120 return out;
3121 };
3122 for term in &spec.smooth_terms {
3123 if let SmoothBasisSpec::Sphere { feature_cols, spec } = &term.basis
3124 && let Some(&lat_col) = feature_cols.first()
3125 && lat_col < training_headers.len()
3126 {
3127 let bound = if spec.radians {
3128 std::f64::consts::FRAC_PI_2
3129 } else {
3130 90.0
3131 };
3132 out.insert(lat_col, (-bound, bound));
3133 }
3134 }
3135 out
3136 }
3137
3138 pub fn from_payload(mut payload: FittedModelPayload) -> Self {
3139 let likelihood = payload.family_state.likelihood();
3140 let class = match payload.model_kind {
3141 ModelKind::Survival => PredictModelClass::Survival,
3142 ModelKind::MarginalSlope => PredictModelClass::BernoulliMarginalSlope,
3143 ModelKind::TransformationNormal => PredictModelClass::TransformationNormal,
3144 ModelKind::LocationScale => {
3145 if likelihood == LikelihoodSpec::gaussian_identity() {
3146 PredictModelClass::GaussianLocationScale
3147 } else if is_dispersion_location_scale_response(&likelihood.response) {
3148 PredictModelClass::DispersionLocationScale
3149 } else {
3150 PredictModelClass::BinomialLocationScale
3151 }
3152 }
3153 ModelKind::Standard => PredictModelClass::Standard,
3154 };
3155 match class {
3156 PredictModelClass::Survival => {
3157 payload.model_kind = ModelKind::Survival;
3158 Self::Survival { payload }
3159 }
3160 PredictModelClass::BernoulliMarginalSlope => {
3161 payload.model_kind = ModelKind::MarginalSlope;
3162 Self::MarginalSlope { payload }
3163 }
3164 PredictModelClass::TransformationNormal => {
3165 payload.model_kind = ModelKind::TransformationNormal;
3166 Self::TransformationNormal { payload }
3167 }
3168 PredictModelClass::GaussianLocationScale
3169 | PredictModelClass::BinomialLocationScale
3170 | PredictModelClass::DispersionLocationScale => {
3171 payload.model_kind = ModelKind::LocationScale;
3172 Self::LocationScale { payload }
3173 }
3174 PredictModelClass::Standard => {
3175 payload.model_kind = ModelKind::Standard;
3176 Self::Standard { payload }
3177 }
3178 }
3179 .with_synchronized_stateful_link_metadata()
3180 }
3181
3182 #[inline]
3183 pub fn payload(&self) -> &FittedModelPayload {
3184 match self {
3185 Self::Standard { payload }
3186 | Self::LocationScale { payload }
3187 | Self::MarginalSlope { payload }
3188 | Self::Survival { payload }
3189 | Self::TransformationNormal { payload } => payload,
3190 }
3191 }
3192
3193 #[inline]
3194 fn payload_mut(&mut self) -> &mut FittedModelPayload {
3195 match self {
3196 Self::Standard { payload }
3197 | Self::LocationScale { payload }
3198 | Self::MarginalSlope { payload }
3199 | Self::Survival { payload }
3200 | Self::TransformationNormal { payload } => payload,
3201 }
3202 }
3203
3204 fn with_synchronized_stateful_link_metadata(mut self) -> Self {
3205 self.synchronize_stateful_link_metadata();
3206 self
3207 }
3208
3209 fn synchronize_stateful_link_metadata(&mut self) {
3210 let payload = self.payload_mut();
3211 match (payload.fit_result.is_none(), payload.unified.is_none()) {
3223 (true, false) => payload.fit_result = payload.unified.clone(),
3224 (false, true) => payload.unified = payload.fit_result.clone(),
3225 (true, true) | (false, false) => {}
3228 }
3229 payload.used_device = payload
3230 .fit_result
3231 .as_ref()
3232 .or(payload.unified.as_ref())
3233 .is_some_and(|fit| fit.used_device);
3234 payload.synchronize_empty_feature_contract();
3235 let Some(fit) = payload.fit_result.as_ref().or(payload.unified.as_ref()) else {
3236 return;
3237 };
3238 let FittedFamily::Standard {
3242 likelihood,
3243 latent_cloglog_state,
3244 mixture_state,
3245 sas_state,
3246 ..
3247 } = &mut payload.family_state
3248 else {
3249 return;
3250 };
3251 match &fit.fitted_link {
3258 FittedLinkState::Standard(_) => {}
3261 FittedLinkState::LatentCLogLog { state } => {
3262 if likelihood.is_latent_cloglog() {
3263 *latent_cloglog_state = Some(*state);
3264 } else {
3265 log::warn!(
3266 "fitted latent-cloglog link state discarded: likelihood {likelihood:?} \
3267 has no latent-cloglog slot"
3268 );
3269 }
3270 }
3271 FittedLinkState::Sas { state, covariance } => {
3272 if likelihood.is_binomial_sas() {
3273 *sas_state = Some(*state);
3274 payload.sas_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
3275 } else {
3276 log::warn!(
3277 "fitted SAS link state discarded: likelihood {likelihood:?} is not \
3278 binomial-SAS"
3279 );
3280 }
3281 }
3282 FittedLinkState::BetaLogistic { state, covariance } => {
3283 if likelihood.is_binomial_beta_logistic() {
3284 *sas_state = Some(*state);
3285 payload.sas_param_covariance = covariance.as_ref().map(array2_to_nested_vec);
3286 } else {
3287 log::warn!(
3288 "fitted beta-logistic link state discarded: likelihood {likelihood:?} is \
3289 not binomial beta-logistic"
3290 );
3291 }
3292 }
3293 FittedLinkState::Mixture { state, covariance } => {
3294 if likelihood.is_binomial_mixture() {
3295 *mixture_state = Some(state.clone());
3296 payload.mixture_link_param_covariance =
3297 covariance.as_ref().map(array2_to_nested_vec);
3298 } else {
3299 log::warn!(
3300 "fitted mixture link state discarded: likelihood {likelihood:?} is not a \
3301 binomial mixture link"
3302 );
3303 }
3304 }
3305 }
3306 }
3307
3308 #[inline]
3309 pub fn likelihood(&self) -> LikelihoodSpec {
3310 self.payload().family_state.likelihood()
3311 }
3312
3313 pub fn display_family_name(&self) -> String {
3338 let payload = self.payload();
3339 match &payload.family_state {
3340 FittedFamily::LocationScale { .. } if !payload.family.is_empty() => {
3341 payload.family.clone()
3342 }
3343 _ => self.likelihood().pretty_name().to_string(),
3344 }
3345 }
3346
3347 #[inline]
3348 pub fn estimator(&self) -> FittedEstimator {
3349 self.payload().estimator
3350 }
3351
3352 pub fn prediction_required_columns(
3370 &self,
3371 ) -> Result<std::collections::BTreeSet<String>, String> {
3372 let payload = self.payload();
3373 let parsed = parse_formula(payload.formula.as_str()).map_err(|e| e.to_string())?;
3374 let mut required = std::collections::BTreeSet::<String>::new();
3375 parsed_term_column_names(&parsed.terms, &mut required);
3376
3377 if let Some((entry, exit, _event)) =
3378 parse_surv_response(parsed.response.as_str()).map_err(|e| e.to_string())?
3379 {
3380 if let Some(entry) = entry {
3381 required.insert(entry);
3382 }
3383 required.insert(exit);
3384 } else if let Some((left, right, _event)) =
3385 parse_surv_interval_response(parsed.response.as_str()).map_err(|e| e.to_string())?
3386 {
3387 required.insert(left);
3388 required.insert(right);
3389 }
3390 if let Some(offset) = payload.offset_column.as_ref() {
3398 required.insert(offset.clone());
3399 }
3400 if let Some(noise_offset) = payload.noise_offset_column.as_ref() {
3401 required.insert(noise_offset.clone());
3402 }
3403 if matches!(
3404 self.predict_model_class(),
3405 PredictModelClass::BernoulliMarginalSlope | PredictModelClass::Survival
3406 ) {
3407 if let Some(z_column) = payload.z_column.as_ref() {
3408 required.remove("z");
3409 required.insert(z_column.clone());
3410 }
3411 }
3412 if let Some(noise_formula) = payload.formula_noise.as_ref() {
3413 self.add_auxiliary_formula_columns(
3414 &mut required,
3415 noise_formula,
3416 parsed.response.as_str(),
3417 )?;
3418 }
3419 if let Some(logslope_formula) = payload.formula_logslope.as_ref() {
3420 if logslope_formula != "same-as-main" {
3421 self.add_auxiliary_formula_columns(
3422 &mut required,
3423 logslope_formula,
3424 parsed.response.as_str(),
3425 )?;
3426 }
3427 }
3428 Ok(required)
3429 }
3430
3431 pub fn diagnostic_extra_columns(&self) -> Result<Vec<String>, String> {
3448 let payload = self.payload();
3449 let parsed = parse_formula(payload.formula.as_str()).map_err(|e| e.to_string())?;
3450 let mut extras: Vec<String> = Vec::new();
3460 if let Some(weight_column) = payload.weight_column.as_ref() {
3461 extras.push(weight_column.clone());
3462 }
3463 if let Some((_entry, _exit, event)) =
3476 parse_surv_response(parsed.response.as_str()).map_err(|e| e.to_string())?
3477 {
3478 extras.push(payload.survival_event.clone().unwrap_or(event));
3479 return Ok(extras);
3480 }
3481 if let Some((_left, _right, event)) =
3482 parse_surv_interval_response(parsed.response.as_str()).map_err(|e| e.to_string())?
3483 {
3484 extras.push(payload.survival_event.clone().unwrap_or(event));
3485 return Ok(extras);
3486 }
3487 let response = parsed.response.trim();
3488 if response.is_empty() || response.contains('(') {
3491 return Ok(extras);
3492 }
3493 if self.prediction_required_columns()?.contains(response) {
3496 return Ok(extras);
3497 }
3498 extras.push(response.to_string());
3499 Ok(extras)
3500 }
3501
3502 fn add_auxiliary_formula_columns(
3505 &self,
3506 required: &mut std::collections::BTreeSet<String>,
3507 formula_or_rhs: &str,
3508 response: &str,
3509 ) -> Result<(), String> {
3510 let trimmed = formula_or_rhs.trim();
3511 if trimmed.is_empty() || trimmed == "1" {
3512 return Ok(());
3513 }
3514 let formula = if trimmed.contains('~') {
3515 trimmed.to_string()
3516 } else {
3517 format!("{response} ~ {trimmed}")
3518 };
3519 let parsed = parse_formula(formula.as_str()).map_err(|e| e.to_string())?;
3520 parsed_term_column_names(&parsed.terms, required);
3521 Ok(())
3522 }
3523
3524 #[inline]
3525 pub fn predict_model_class(&self) -> PredictModelClass {
3526 match &self.payload().family_state {
3527 FittedFamily::Survival { .. }
3528 | FittedFamily::LatentSurvival { .. }
3529 | FittedFamily::LatentBinary { .. } => PredictModelClass::Survival,
3530 FittedFamily::MarginalSlope { .. } => PredictModelClass::BernoulliMarginalSlope,
3531 FittedFamily::TransformationNormal { .. } => PredictModelClass::TransformationNormal,
3532 FittedFamily::LocationScale { likelihood, .. } if likelihood.is_gaussian_identity() => {
3533 PredictModelClass::GaussianLocationScale
3534 }
3535 FittedFamily::LocationScale { likelihood, .. }
3536 if is_dispersion_location_scale_response(&likelihood.response) =>
3537 {
3538 PredictModelClass::DispersionLocationScale
3539 }
3540 FittedFamily::LocationScale { .. } => PredictModelClass::BinomialLocationScale,
3541 FittedFamily::Standard { .. } => PredictModelClass::Standard,
3542 }
3543 }
3544
3545 pub fn saved_link_wiggle(&self) -> Result<Option<SavedLinkWiggleRuntime>, FittedModelError> {
3546 let payload = self.payload();
3547 let (knots, degree) = match (
3548 payload.linkwiggle_knots.as_ref(),
3549 payload.linkwiggle_degree,
3550 ) {
3551 (None, None) => return Ok(None),
3552 (Some(knots), Some(degree)) => (knots.clone(), degree),
3553 _ => {
3554 return Err(FittedModelError::SchemaMismatch {
3555 reason:
3556 "saved model has partial link-wiggle metadata; expected linkwiggle_knots and linkwiggle_degree together"
3557 .to_string(),
3558 })
3559 }
3560 };
3561 let resolved_link = self.resolved_inverse_link()?;
3562 let saved_link_disallows_wiggle = resolved_link
3563 .as_ref()
3564 .is_some_and(|link| !inverse_link_supports_joint_wiggle(link))
3565 || payload
3566 .link
3567 .as_ref()
3568 .is_some_and(|link| !inverse_link_supports_joint_wiggle(link));
3569 if saved_link_disallows_wiggle {
3570 return Err(FittedModelError::IncompatibleConfig {
3571 reason: joint_wiggle_unsupported_link_message("link wiggle"),
3572 });
3573 }
3574 let model_class = self.predict_model_class();
3575 let beta = match model_class {
3576 PredictModelClass::Standard => {
3585 let fit = payload.fit_result.as_ref().ok_or_else(|| {
3586 FittedModelError::MissingField {
3587 reason:
3588 "standard link-wiggle model is missing canonical fit_result payload"
3589 .to_string(),
3590 }
3591 })?;
3592 if fit.blocks.len() != 2
3593 || fit.blocks[0].role != BlockRole::Mean
3594 || fit.blocks[1].role != BlockRole::LinkWiggle
3595 {
3596 return Err(FittedModelError::SchemaMismatch {
3597 reason:
3598 "standard link-wiggle models must store blocks in [Mean, LinkWiggle] order"
3599 .to_string(),
3600 });
3601 }
3602 let block = fit.block_by_role(BlockRole::LinkWiggle).ok_or_else(|| {
3603 FittedModelError::MissingField {
3604 reason:
3605 "standard link-wiggle model is missing LinkWiggle coefficient block"
3606 .to_string(),
3607 }
3608 })?;
3609 let payload_beta = payload.beta_link_wiggle.as_ref().ok_or_else(|| {
3610 FittedModelError::MissingField {
3611 reason: "standard link-wiggle model is missing its exact saved prediction coefficients; refit"
3612 .to_string(),
3613 }
3614 })?;
3615 if payload_beta.len() != block.beta.len()
3616 || payload_beta
3617 .iter()
3618 .zip(block.beta.iter())
3619 .any(|(saved, fitted)| saved.to_bits() != fitted.to_bits())
3620 {
3621 return Err(FittedModelError::SchemaMismatch {
3622 reason: "standard link-wiggle payload coefficients disagree with the fitted LinkWiggle block"
3623 .to_string(),
3624 });
3625 }
3626 let shift = payload.link_wiggle_index_shift.as_ref().ok_or_else(|| {
3627 FittedModelError::MissingField {
3628 reason: "standard link-wiggle model is missing its frozen-index shift; refit"
3629 .to_string(),
3630 }
3631 })?;
3632 if shift.len() != fit.blocks[0].beta.len() {
3633 return Err(FittedModelError::SchemaMismatch {
3634 reason: format!(
3635 "standard link-wiggle frozen-index shift has {} entries but the Mean block has {} coefficients",
3636 shift.len(),
3637 fit.blocks[0].beta.len(),
3638 ),
3639 });
3640 }
3641 block.beta.to_vec()
3642 }
3643 _ => payload
3644 .beta_link_wiggle
3645 .clone()
3646 .ok_or_else(|| FittedModelError::MissingField {
3647 reason:
3648 "saved model has link-wiggle metadata but is missing payload.beta_link_wiggle"
3649 .to_string(),
3650 })?,
3651 };
3652 let penalty_metadata = payload.linkwiggle_penalty_metadata.clone();
3653 if let Some(metadata) = penalty_metadata.as_ref() {
3654 let canonical = canonical_wiggle_function_penalties(
3655 &Array1::from_vec(knots.clone()),
3656 degree,
3657 &metadata.derivative_orders,
3658 metadata.double_penalty,
3659 )
3660 .map_err(|reason| FittedModelError::PayloadCorrupt {
3661 reason: format!("saved link-wiggle penalty metadata is invalid: {reason}"),
3662 })?;
3663 if canonical.metadata != *metadata {
3664 return Err(FittedModelError::SchemaMismatch {
3675 reason: format!(
3676 "saved link-wiggle penalty topology {:?} disagrees with the topology this \
3677 build assembles from the same knots and orders, {:?}. The saved \
3678 coefficients and log-lambdas index the saved topology, so they cannot be \
3679 replayed against this one; refit the model. (A set gaining a \
3680 `NullspaceShrinkage` coordinate is gam#2647: the warp's linear element is \
3681 a rescale of the index it is composed onto, and leaving it unpenalized \
3682 made the penalized criterion unbounded below.)",
3683 metadata.blocks, canonical.metadata.blocks,
3684 ),
3685 });
3686 }
3687 }
3688 let index_shift = payload.link_wiggle_index_shift.clone();
3693 Ok(Some(SavedLinkWiggleRuntime {
3694 knots,
3695 degree,
3696 penalty_metadata,
3697 beta,
3698 index_shift,
3699 }))
3700 }
3701
3702 pub fn saved_baseline_time_wiggle(
3703 &self,
3704 ) -> Result<Option<SavedBaselineTimeWiggleRuntime>, FittedModelError> {
3705 let payload = self.payload();
3706 if payload
3707 .survival_cause_count
3708 .is_some_and(|cause_count| cause_count > 1)
3709 && payload.beta_baseline_timewiggle.is_none()
3710 && payload.beta_baseline_timewiggle_by_cause.is_some()
3711 {
3712 return Err(FittedModelError::SchemaMismatch {
3713 reason:
3714 "joint cause-specific survival stores baseline-timewiggle coefficients per cause"
3715 .to_string(),
3716 });
3717 }
3718 match (
3719 payload.baseline_timewiggle_knots.as_ref(),
3720 payload.baseline_timewiggle_degree,
3721 payload.baseline_timewiggle_penalty_orders.as_ref(),
3722 payload.baseline_timewiggle_double_penalty,
3723 payload.beta_baseline_timewiggle.as_ref(),
3724 ) {
3725 (None, None, None, None, None) => Ok(None),
3726 (Some(knots), Some(degree), Some(penalty_orders), Some(double_penalty), Some(beta)) => {
3727 Ok(Some(SavedBaselineTimeWiggleRuntime {
3728 knots: knots.clone(),
3729 degree,
3730 penalty_orders: penalty_orders.clone(),
3731 double_penalty,
3732 beta: beta.clone(),
3733 }))
3734 }
3735 _ => Err(FittedModelError::SchemaMismatch {
3736 reason:
3737 "saved model has partial baseline-timewiggle metadata; expected knots+degree+penalty_order+double_penalty+beta_baseline_timewiggle together"
3738 .to_string(),
3739 }),
3740 }
3741 }
3742
3743 #[inline]
3745 pub fn has_link_wiggle(&self) -> bool {
3746 self.saved_link_wiggle()
3747 .map(|runtime| runtime.is_some())
3748 .unwrap_or(false)
3749 }
3750
3751 #[inline]
3753 pub fn has_baseline_time_wiggle(&self) -> bool {
3754 let payload = self.payload();
3755 if payload
3756 .survival_cause_count
3757 .is_some_and(|cause_count| cause_count > 1)
3758 {
3759 return payload.baseline_timewiggle_knots.is_some()
3760 && payload.baseline_timewiggle_degree.is_some()
3761 && payload.baseline_timewiggle_penalty_orders.is_some()
3762 && payload.baseline_timewiggle_double_penalty.is_some()
3763 && payload.beta_baseline_timewiggle_by_cause.is_some();
3764 }
3765 self.saved_baseline_time_wiggle()
3766 .map(|runtime| runtime.is_some())
3767 .unwrap_or(false)
3768 }
3769
3770 #[inline]
3795 pub fn prediction_uses_posterior_mean(&self) -> bool {
3796 let family = self.likelihood();
3797 let curved_family = match &family.response {
3798 ResponseFamily::Gaussian => false,
3801 ResponseFamily::Poisson
3803 | ResponseFamily::Gamma
3804 | ResponseFamily::Tweedie { .. }
3805 | ResponseFamily::NegativeBinomial { .. } => true,
3806 ResponseFamily::Beta { .. } => true,
3808 ResponseFamily::RoystonParmar => true,
3810 ResponseFamily::Binomial => matches!(
3813 &family.link,
3814 InverseLink::Standard(_)
3815 | InverseLink::Sas(_)
3816 | InverseLink::BetaLogistic(_)
3817 | InverseLink::Mixture(_)
3818 | InverseLink::LatentCLogLog(_)
3819 ),
3820 };
3821 curved_family || self.has_link_wiggle() || self.has_baseline_time_wiggle()
3822 }
3823
3824 pub fn saved_prediction_runtime(&self) -> Result<SavedPredictionRuntime, FittedModelError> {
3825 self.payload().validate_payload_version()?;
3826 if matches!(
3827 self.predict_model_class(),
3828 PredictModelClass::BernoulliMarginalSlope | PredictModelClass::Survival
3829 ) {
3830 if let Some(runtime) = self.payload().score_warp_runtime.as_ref() {
3831 runtime.validate_exact_replay_contract().map_err(|err| {
3832 FittedModelError::PayloadCorrupt {
3833 reason: format!("saved anchored score-warp runtime is invalid: {err}"),
3834 }
3835 })?;
3836 }
3837 if let Some(runtime) = self.payload().link_deviation_runtime.as_ref() {
3838 runtime.validate_exact_replay_contract().map_err(|err| {
3839 FittedModelError::PayloadCorrupt {
3840 reason: format!("saved anchored link-deviation runtime is invalid: {err}"),
3841 }
3842 })?;
3843 }
3844 }
3845 let runtime = SavedPredictionRuntime {
3846 model_class: self.predict_model_class(),
3847 likelihood: self.likelihood(),
3848 inverse_link: self.resolved_inverse_link()?,
3849 link_wiggle: self.saved_link_wiggle()?,
3850 baseline_time_wiggle: self.saved_baseline_time_wiggle()?,
3851 score_warp: self.payload().score_warp_runtime.clone(),
3852 link_deviation: self.payload().link_deviation_runtime.clone(),
3853 latent_z_rank_int_calibration: self.payload().latent_z_rank_int_calibration.clone(),
3854 latent_z_conditional_calibration: self
3855 .payload()
3856 .latent_z_conditional_calibration
3857 .clone(),
3858 influence_absorber_width: self.payload().influence_absorber_width,
3859 };
3860 if matches!(
3861 runtime.model_class,
3862 PredictModelClass::GaussianLocationScale
3863 | PredictModelClass::BinomialLocationScale
3864 | PredictModelClass::DispersionLocationScale
3865 ) {
3866 let fit = self.payload().fit_result.as_ref().ok_or_else(|| {
3867 FittedModelError::MissingField {
3868 reason: "location-scale model is missing canonical fit_result payload"
3869 .to_string(),
3870 }
3871 })?;
3872 validate_location_scale_saved_fit(
3873 fit,
3874 runtime.model_class,
3875 runtime.link_wiggle.as_ref(),
3876 )?;
3877 } else if matches!(runtime.model_class, PredictModelClass::Survival)
3878 && self
3879 .payload()
3880 .survival_likelihood
3881 .as_deref()
3882 .is_some_and(|value| value.eq_ignore_ascii_case("location-scale"))
3883 {
3884 validate_survival_location_scale_saved_fit(
3885 self.payload(),
3886 runtime.link_wiggle.as_ref(),
3887 )?;
3888 } else if matches!(
3889 runtime.model_class,
3890 PredictModelClass::BernoulliMarginalSlope
3891 ) {
3892 let unified =
3893 self.payload()
3894 .unified
3895 .as_ref()
3896 .ok_or_else(|| FittedModelError::MissingField {
3897 reason: "marginal-slope model is missing unified fit payload; refit"
3898 .to_string(),
3899 })?;
3900 validate_marginal_slope_saved_fit(
3901 unified,
3902 runtime.score_warp.as_ref(),
3903 runtime.link_deviation.as_ref(),
3904 "unified",
3905 )?;
3906 } else if matches!(runtime.model_class, PredictModelClass::Survival)
3907 && self
3908 .payload()
3909 .survival_likelihood
3910 .as_deref()
3911 .is_some_and(|value| value.eq_ignore_ascii_case("marginal-slope"))
3912 {
3913 let fit = self.payload().fit_result.as_ref().ok_or_else(|| {
3914 FittedModelError::MissingField {
3915 reason: "survival marginal-slope model is missing canonical fit_result payload"
3916 .to_string(),
3917 }
3918 })?;
3919 validate_survival_marginal_slope_saved_fit(self.payload(), fit, "fit_result")?;
3920 }
3921 Ok(runtime)
3922 }
3923
3924 pub fn saved_sas_state(&self) -> Result<Option<SasLinkState>, FittedModelError> {
3925 let payload = self.payload();
3926 let raw = match &payload.family_state {
3927 FittedFamily::Standard {
3928 likelihood,
3929 sas_state,
3930 ..
3931 } if likelihood.is_binomial_sas() => {
3932 (*sas_state).ok_or_else(|| FittedModelError::MissingField {
3933 reason: "binomial-sas model is missing state in family_state.sas_state"
3934 .to_string(),
3935 })?
3936 }
3937 FittedFamily::LocationScale {
3938 likelihood,
3939 base_link,
3940 } if likelihood.is_binomial_sas() => match base_link {
3941 Some(InverseLink::Sas(state)) => *state,
3942 _ => {
3943 return Err(FittedModelError::MissingField {
3944 reason: "binomial-sas location-scale model is missing SAS base_link state"
3945 .to_string(),
3946 });
3947 }
3948 },
3949 _ => return Ok(None),
3950 };
3951 state_from_sasspec(SasLinkSpec {
3952 initial_epsilon: raw.epsilon,
3953 initial_log_delta: raw.log_delta,
3954 })
3955 .map(Some)
3956 .map_err(|e| FittedModelError::PayloadCorrupt {
3957 reason: format!("invalid saved SAS link state: {e}"),
3958 })
3959 }
3960
3961 pub fn saved_beta_logistic_state(&self) -> Result<Option<SasLinkState>, FittedModelError> {
3962 let payload = self.payload();
3963 let raw = match &payload.family_state {
3964 FittedFamily::Standard {
3965 likelihood,
3966 sas_state,
3967 ..
3968 } if likelihood.is_binomial_beta_logistic() => {
3969 (*sas_state).ok_or_else(|| FittedModelError::MissingField {
3970 reason:
3971 "binomial-beta-logistic model is missing state in family_state.sas_state"
3972 .to_string(),
3973 })?
3974 }
3975 FittedFamily::LocationScale {
3976 likelihood,
3977 base_link,
3978 } if likelihood.is_binomial_beta_logistic() => match base_link {
3979 Some(InverseLink::BetaLogistic(state)) => *state,
3980 _ => {
3981 return Err(FittedModelError::MissingField {
3982 reason:
3983 "binomial-beta-logistic location-scale model is missing beta-logistic base_link state"
3984 .to_string(),
3985 });
3986 }
3987 },
3988 _ => return Ok(None),
3989 };
3990 state_from_beta_logisticspec(SasLinkSpec {
3991 initial_epsilon: raw.epsilon,
3992 initial_log_delta: raw.log_delta,
3993 })
3994 .map(Some)
3995 .map_err(|e| FittedModelError::PayloadCorrupt {
3996 reason: format!("invalid saved Beta-Logistic link state: {e}"),
3997 })
3998 }
3999
4000 pub fn saved_mixture_state(&self) -> Result<Option<MixtureLinkState>, FittedModelError> {
4001 let payload = self.payload();
4002 match &payload.family_state {
4003 FittedFamily::Standard {
4004 likelihood,
4005 mixture_state,
4006 ..
4007 } if likelihood.is_binomial_mixture() => mixture_state
4008 .clone()
4009 .ok_or_else(|| FittedModelError::MissingField {
4010 reason: "binomial-mixture model is missing state in family_state.mixture_state"
4011 .to_string(),
4012 })
4013 .map(Some),
4014 FittedFamily::LocationScale {
4015 likelihood,
4016 base_link,
4017 } if likelihood.is_binomial_mixture() => match base_link {
4018 Some(InverseLink::Mixture(state)) => Ok(Some(state.clone())),
4019 _ => Err(FittedModelError::MissingField {
4020 reason:
4021 "binomial-mixture location-scale model is missing mixture base_link state"
4022 .to_string(),
4023 }),
4024 },
4025 _ => Ok(None),
4026 }
4027 }
4028
4029 pub fn saved_latent_cloglog_state(
4030 &self,
4031 ) -> Result<Option<LatentCLogLogState>, FittedModelError> {
4032 let payload = self.payload();
4033 match &payload.family_state {
4034 FittedFamily::Standard {
4035 likelihood,
4036 latent_cloglog_state,
4037 ..
4038 } if likelihood.is_latent_cloglog() => latent_cloglog_state
4039 .ok_or_else(|| FittedModelError::MissingField {
4040 reason:
4041 "latent-cloglog-binomial model is missing state in family_state.latent_cloglog_state"
4042 .to_string(),
4043 })
4044 .map(Some),
4045 _ => Ok(None),
4046 }
4047 }
4048
4049 pub fn resolved_inverse_link(&self) -> Result<Option<InverseLink>, FittedModelError> {
4050 let stateful = if let Some(state) = self.saved_mixture_state()? {
4051 Some(InverseLink::Mixture(state))
4052 } else if let Some(state) = self.saved_latent_cloglog_state()? {
4053 Some(InverseLink::LatentCLogLog(state))
4054 } else if let Some(state) = self.saved_beta_logistic_state()? {
4055 Some(InverseLink::BetaLogistic(state))
4056 } else {
4057 self.saved_sas_state()?.map(InverseLink::Sas)
4058 };
4059 match &self.payload().family_state {
4060 FittedFamily::LocationScale { base_link, .. } => Ok(base_link.clone().or(stateful)),
4061 FittedFamily::Standard { link, .. } => {
4062 Ok(stateful.or_else(|| link.map(InverseLink::Standard)))
4063 }
4064 FittedFamily::MarginalSlope { base_link, .. } => Ok(Some(base_link.clone())),
4065 FittedFamily::Survival { .. }
4066 | FittedFamily::LatentSurvival { .. }
4067 | FittedFamily::LatentBinary { .. } => Ok(None),
4068 FittedFamily::TransformationNormal { .. } => Ok(None),
4069 }
4070 }
4071
4072 const MEASURE_JET_COVERAGE_FLOOR: f64 = 0.05;
4081
4082 pub fn measure_jet_extrapolation_variance(
4109 &self,
4110 data: ndarray::ArrayView2<'_, f64>,
4111 col_map: &HashMap<String, usize>,
4112 ) -> Result<Option<Array1<f64>>, FittedModelError> {
4113 use gam_terms::basis::{
4114 CenterStrategy, MeasureJetExtrapolationSpectrum, MeasureJetIdentifiability,
4115 PenaltySource,
4116 };
4117 use gam_terms::smooth::SmoothBasisSpec;
4118 use gam_terms::smooth::build_term_collection_design;
4119 let Some(saved_spec) = self.resolved_termspec.as_ref() else {
4120 return Ok(None);
4121 };
4122 if data.nrows() == 0
4123 || !saved_spec
4124 .smooth_terms
4125 .iter()
4126 .any(|t| matches!(t.basis, SmoothBasisSpec::MeasureJet { .. }))
4127 {
4128 return Ok(None);
4129 }
4130 let fit = self
4131 .fit_result
4132 .as_ref()
4133 .ok_or_else(|| FittedModelError::MissingField {
4134 reason: "measure-jet extrapolation variance requires the canonical \
4135 fit_result payload; refit"
4136 .to_string(),
4137 })?;
4138 let spec = crate::survival::predict::resolve_termspec_for_prediction(
4139 &self.resolved_termspec,
4140 self.training_headers.as_ref(),
4141 col_map,
4142 "resolved_termspec",
4143 )
4144 .map_err(|e| FittedModelError::SchemaMismatch {
4145 reason: format!("measure-jet extrapolation variance: {e}"),
4146 })?;
4147 let probe = data.slice(ndarray::s![0..1, ..]);
4154 let design = build_term_collection_design(probe, &spec).map_err(|e| {
4155 FittedModelError::SchemaMismatch {
4156 reason: format!(
4157 "measure-jet extrapolation variance: penalty-layout replay failed: {e}"
4158 ),
4159 }
4160 })?;
4161 let lambdas = &fit.lambdas;
4162 let phi_scale = fit.coefficient_covariance_scale().map_err(|err| {
4167 FittedModelError::SchemaMismatch {
4168 reason: format!(
4169 "measure-jet extrapolation variance has no valid coefficient-covariance scale: {err}"
4170 ),
4171 }
4172 })?;
4173 let mut total = Array1::<f64>::zeros(data.nrows());
4174 let mut contributed = false;
4175 for (smooth_idx, term) in spec.smooth_terms.iter().enumerate() {
4176 let SmoothBasisSpec::MeasureJet {
4177 feature_cols,
4178 spec: mj,
4179 input_scale,
4180 } = &term.basis
4181 else {
4182 continue;
4183 };
4184 let (Some(frozen), CenterStrategy::UserProvided(centers)) =
4185 (mj.frozen_quadrature.as_ref(), &mj.center_strategy)
4186 else {
4187 log::warn!(
4188 "measure-jet term '{}' is not frozen (UserProvided centers + frozen \
4189 quadrature); skipping its extrapolation variance",
4190 term.name
4191 );
4192 continue;
4193 };
4194 let n_levels = frozen.eps_band.len();
4195 let read_lambda = |global_index: usize| -> Result<f64, FittedModelError> {
4202 lambdas
4203 .get(global_index)
4204 .copied()
4205 .ok_or_else(|| FittedModelError::SchemaMismatch {
4206 reason: format!(
4207 "measure-jet term '{}': penalty global index {global_index} out \
4208 of bounds for {} fitted lambdas",
4209 term.name,
4210 lambdas.len()
4211 ),
4212 })
4213 };
4214 let mut per_scale: Vec<(usize, f64)> = Vec::new();
4215 let mut fused: Option<f64> = None;
4216 for info in &design.penaltyinfo {
4217 if info.termname.as_deref() != Some(term.name.as_str()) {
4218 continue;
4219 }
4220 match &info.penalty.source {
4221 PenaltySource::Other(label) => {
4222 if let Some(level_txt) = label.strip_prefix("measure_jet_scale_") {
4223 let level: usize = level_txt.parse().map_err(|_| {
4224 FittedModelError::SchemaMismatch {
4225 reason: format!(
4226 "measure-jet term '{}': unparseable penalty label \
4227 '{label}'",
4228 term.name
4229 ),
4230 }
4231 })?;
4232 per_scale.push((level, read_lambda(info.global_index)?));
4233 }
4234 }
4235 PenaltySource::Primary => {
4236 fused = Some(read_lambda(info.global_index)?);
4237 }
4238 PenaltySource::DoublePenaltyNullspace
4245 | PenaltySource::OperatorMass
4246 | PenaltySource::OperatorTension
4247 | PenaltySource::OperatorStiffness
4248 | PenaltySource::OperatorRelevance { .. }
4249 | PenaltySource::TensorMarginal { .. }
4250 | PenaltySource::TensorSeparable { .. }
4251 | PenaltySource::TensorGlobalRidge => {}
4252 }
4253 }
4254 let mut lambda_phys = Vec::with_capacity(n_levels);
4255 let spectrum = if per_scale.is_empty() {
4256 let Some(lam) = fused else {
4257 log::warn!(
4258 "measure-jet term '{}' has no fitted amplitude in the penalty \
4259 layout; skipping its extrapolation variance",
4260 term.name
4261 );
4262 continue;
4263 };
4264 let Some(c) = frozen.fused_penalty_normalization_scale else {
4265 log::warn!(
4266 "measure-jet term '{}' is missing the fused penalty normalization scale; \
4267 skipping its extrapolation variance",
4268 term.name
4269 );
4270 continue;
4271 };
4272 MeasureJetExtrapolationSpectrum::Fused(lam / c)
4273 } else {
4274 per_scale.sort_by_key(|&(level, _)| level);
4275 let levels_complete = per_scale.len() == n_levels
4276 && per_scale
4277 .iter()
4278 .enumerate()
4279 .all(|(i, &(level, _))| level == i);
4280 if !levels_complete {
4281 log::warn!(
4282 "measure-jet term '{}': {} fitted per-scale amplitudes for {} band \
4283 scales; skipping its extrapolation variance",
4284 term.name,
4285 per_scale.len(),
4286 n_levels
4287 );
4288 continue;
4289 }
4290 if frozen.penalty_normalization_scales.len() != n_levels {
4291 log::warn!(
4292 "measure-jet term '{}': {} frozen penalty normalization scales for {} \
4293 band scales; skipping its extrapolation variance",
4294 term.name,
4295 frozen.penalty_normalization_scales.len(),
4296 n_levels
4297 );
4298 continue;
4299 }
4300 lambda_phys.extend(
4301 per_scale
4302 .iter()
4303 .map(|&(level, lam)| lam / frozen.penalty_normalization_scales[level]),
4304 );
4305 MeasureJetExtrapolationSpectrum::PerLevel(&lambda_phys)
4306 };
4307 let mut queries = Array2::<f64>::zeros((data.nrows(), feature_cols.len()));
4312 for (j, &col) in feature_cols.iter().enumerate() {
4313 if col >= data.ncols() {
4314 return Err(FittedModelError::SchemaMismatch {
4315 reason: format!(
4316 "measure-jet term '{}': prediction column {col} out of bounds \
4317 for {} data columns",
4318 term.name,
4319 data.ncols()
4320 ),
4321 });
4322 }
4323 queries.column_mut(j).assign(&data.column(col));
4324 }
4325 let scale = (*input_scale).ok_or_else(|| FittedModelError::SchemaMismatch {
4326 reason: format!(
4327 "measure-jet term '{}' is missing its frozen isotropic input scale",
4328 term.name
4329 ),
4330 })?;
4331 scale.standardize(&mut queries);
4332 let support = gam_terms::basis::measure_jet_support_curve(
4333 queries.view(),
4334 centers.view(),
4335 frozen.masses.view(),
4336 &frozen.eps_band,
4337 )
4338 .map_err(|e| FittedModelError::SchemaMismatch {
4339 reason: format!(
4340 "measure-jet term '{}': support curve failed: {e}",
4341 term.name
4342 ),
4343 })?;
4344 for i in 0..data.nrows() {
4345 let v = gam_terms::basis::measure_jet_extrapolation_variance(
4346 support.row(i),
4347 &frozen.eps_band,
4348 &frozen.support_means,
4349 spectrum,
4350 Self::MEASURE_JET_COVERAGE_FLOOR,
4351 )
4352 .map_err(|e| FittedModelError::SchemaMismatch {
4353 reason: format!(
4354 "measure-jet term '{}': extrapolation variance failed: {e}",
4355 term.name
4356 ),
4357 })?;
4358 total[i] += phi_scale * v;
4359 }
4360 contributed = true;
4361 if let Some(sigma_coord) = frozen.sigma_coord {
4374 'input_var: {
4375 let MeasureJetIdentifiability::FrozenTransform { transform } =
4376 &mj.identifiability
4377 else {
4378 log::warn!(
4379 "measure-jet term '{}': identifiability is not a frozen transform; \
4380 skipping its input-measurement-error variance",
4381 term.name
4382 );
4383 break 'input_var;
4384 };
4385 let full_cols = design.design.ncols();
4386 if fit.beta.len() != full_cols {
4387 log::warn!(
4388 "measure-jet term '{}': joint coefficient vector length {} disagrees \
4389 with the replayed design's {} columns; skipping its \
4390 input-measurement-error variance",
4391 term.name,
4392 fit.beta.len(),
4393 full_cols
4394 );
4395 break 'input_var;
4396 }
4397 if design.smooth.term_designs.len() != spec.smooth_terms.len() {
4398 log::warn!(
4399 "measure-jet term '{}': smooth design/term count mismatch ({} vs {}); \
4400 skipping its input-measurement-error variance",
4401 term.name,
4402 design.smooth.term_designs.len(),
4403 spec.smooth_terms.len()
4404 );
4405 break 'input_var;
4406 }
4407 let m = centers.nrows();
4408 let m_aug = transform.nrows();
4409 let reduced = transform.ncols();
4410 let term_cols = design.smooth.term_designs[smooth_idx].ncols();
4411 if term_cols != reduced {
4412 log::warn!(
4413 "measure-jet term '{}': replayed reduced width {term_cols} disagrees \
4414 with the frozen transform ({m_aug}×{reduced}); skipping its \
4415 input-measurement-error variance",
4416 term.name
4417 );
4418 break 'input_var;
4419 }
4420 let smooth_start = full_cols - design.smooth.total_smooth_cols();
4422 let offset_in_smooth: usize = design.smooth.term_designs[..smooth_idx]
4423 .iter()
4424 .map(|d| d.ncols())
4425 .sum();
4426 let g0 = smooth_start + offset_in_smooth;
4427 let beta_term = fit.beta.slice(ndarray::s![g0..g0 + term_cols]).to_owned();
4428 let z_full = transform.dot(&beta_term);
4430 let head_width = m_aug - m;
4431 let rep = z_full.slice(ndarray::s![..m]).to_owned();
4432 let head_coeffs = z_full.slice(ndarray::s![m..]).to_owned();
4433 let head_t = if head_width > 0 {
4438 Some(gam_terms::basis::measure_jet_affine_head_lift(
4439 centers.view(),
4440 frozen.masses.view(),
4441 ))
4442 } else {
4443 None
4444 };
4445 if let Some(t) = head_t.as_ref() {
4446 if t.ncols() != head_width {
4447 log::warn!(
4448 "measure-jet term '{}': reconstructed head lift width {} disagrees \
4449 with the frozen head block {head_width}; skipping its \
4450 input-measurement-error variance",
4451 term.name,
4452 t.ncols()
4453 );
4454 break 'input_var;
4455 }
4456 }
4457 let sigma2 = sigma_coord * sigma_coord;
4458 let mut input_var = Array1::<f64>::zeros(data.nrows());
4459 let mut ok = true;
4460 for i in 0..data.nrows() {
4461 match gam_terms::basis::measure_jet_ambient_gradient(
4462 queries.row(i),
4463 centers.view(),
4464 rep.view(),
4465 mj.length_scale,
4466 head_t.as_ref().map(|t| t.view()),
4467 head_coeffs.view(),
4468 ) {
4469 Ok(grad) => {
4470 let norm_sq: f64 = grad.iter().map(|g| g * g).sum();
4471 input_var[i] = sigma2 * norm_sq;
4472 }
4473 Err(e) => {
4474 log::warn!(
4475 "measure-jet term '{}': ambient gradient failed ({e}); \
4476 skipping its input-measurement-error variance",
4477 term.name
4478 );
4479 ok = false;
4480 break;
4481 }
4482 }
4483 }
4484 if ok {
4485 total += &input_var;
4486 }
4487 }
4488 }
4489 }
4490 Ok(contributed.then_some(total))
4491 }
4492
4493 pub fn unified(&self) -> Option<&UnifiedFitResult> {
4495 self.payload().unified.as_ref()
4496 }
4497
4498 pub fn load_from_path(path: &Path) -> Result<Self, FittedModelError> {
4499 let payload = fs::read_to_string(path).map_err(|e| FittedModelError::PayloadCorrupt {
4500 reason: format!("failed to read model '{}': {e}", path.display()),
4501 })?;
4502 let model: Self =
4503 serde_json::from_str(&payload).map_err(|e| FittedModelError::PayloadCorrupt {
4504 reason: format!("failed to parse model json: {e}"),
4505 })?;
4506 let model = model.with_synchronized_stateful_link_metadata();
4507 model.validate_for_persistence()?;
4508 model.validate_numeric_finiteness()?;
4509 Ok(model)
4510 }
4511
4512 pub fn save_to_path(&self, path: &Path) -> Result<(), FittedModelError> {
4513 let normalized = self.clone().with_synchronized_stateful_link_metadata();
4514 normalized.validate_for_persistence()?;
4515 normalized.validate_numeric_finiteness()?;
4516 normalized.validate_persisted_form_parses_back()?;
4517 let parent = path.parent().unwrap_or_else(|| Path::new("."));
4524 let file_name = path
4525 .file_name()
4526 .and_then(|s| s.to_str())
4527 .unwrap_or("model.json");
4528 let pid = std::process::id();
4529 let nanos = std::time::SystemTime::now()
4530 .duration_since(std::time::UNIX_EPOCH)
4531 .map(|d| d.as_nanos())
4532 .unwrap_or(0);
4533 let tmp = parent.join(format!(".{file_name}.tmp.{pid}.{nanos:x}"));
4534 let file = fs::File::create(&tmp).map_err(|e| FittedModelError::PayloadCorrupt {
4535 reason: format!("failed to write model '{}': {e}", tmp.display()),
4536 })?;
4537 let mut writer = std::io::BufWriter::new(file);
4538 let ser_result = serde_json::to_writer(&mut writer, &normalized);
4539 if let Err(e) = ser_result {
4540 if let Err(flush_err) = std::io::Write::flush(&mut writer) {
4545 log::debug!(
4546 "model publish: flushing the failed temp '{}' errored: {flush_err}",
4547 tmp.display()
4548 );
4549 }
4550 drop(writer);
4551 if let Err(rm_err) = fs::remove_file(&tmp) {
4552 log::debug!(
4553 "model publish: could not remove the failed temp '{}': {rm_err}",
4554 tmp.display()
4555 );
4556 }
4557 return Err(FittedModelError::PayloadCorrupt {
4558 reason: format!("failed to serialize model: {e}"),
4559 });
4560 }
4561 std::io::Write::flush(&mut writer).map_err(|e| FittedModelError::PayloadCorrupt {
4562 reason: format!("failed to write model '{}': {e}", tmp.display()),
4563 })?;
4564 let inner = writer
4566 .into_inner()
4567 .map_err(|e| FittedModelError::PayloadCorrupt {
4568 reason: format!("failed to flush model '{}': {}", tmp.display(), e.error()),
4569 })?;
4570 if let Err(sync_err) = inner.sync_all() {
4571 log::warn!(
4575 "model publish: fsync of '{}' failed, contents may not survive a crash: {sync_err}",
4576 tmp.display()
4577 );
4578 }
4579 drop(inner);
4580 if let Err(e) = fs::rename(&tmp, path) {
4581 if let Err(rm_err) = fs::remove_file(&tmp) {
4582 log::debug!(
4583 "model publish: could not remove the unpublished temp '{}': {rm_err}",
4584 tmp.display()
4585 );
4586 }
4587 return Err(FittedModelError::PayloadCorrupt {
4588 reason: format!("failed to publish model '{}': {e}", path.display()),
4589 });
4590 }
4591 if let Ok(d) = fs::File::open(parent)
4596 && let Err(sync_err) = d.sync_all()
4597 {
4598 log::debug!(
4602 "model publish: directory fsync of '{}' failed: {sync_err}",
4603 parent.display()
4604 );
4605 }
4606 Ok(())
4607 }
4608
4609 pub fn require_data_schema(&self) -> Result<&DataSchema, FittedModelError> {
4610 self.data_schema
4611 .as_ref()
4612 .ok_or_else(|| FittedModelError::MissingField {
4613 reason: "model is missing data_schema; refit".to_string(),
4614 })
4615 }
4616
4617 pub fn saved_spline_scan(
4621 &self,
4622 ) -> Result<Option<(&str, gam_solve::spline_scan::SplineScanFit)>, FittedModelError> {
4623 let Some(saved) = self.spline_scan.as_ref() else {
4624 return Ok(None);
4625 };
4626 let fit = gam_solve::spline_scan::SplineScanFit::from_state(&saved.state)
4627 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
4628 Ok(Some((saved.feature_column.as_str(), fit)))
4629 }
4630
4631 pub fn saved_residual_cascade(
4636 &self,
4637 ) -> Result<
4638 Option<(&[String], gam_solve::residual_cascade::ResidualCascadeFit)>,
4639 FittedModelError,
4640 > {
4641 let Some(saved) = self.residual_cascade.as_ref() else {
4642 return Ok(None);
4643 };
4644 let fit = gam_solve::residual_cascade::ResidualCascadeFit::from_state(&saved.state)
4645 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
4646 Ok(Some((saved.feature_columns.as_slice(), fit)))
4647 }
4648
4649 pub fn random_effect_group_columns(&self) -> HashSet<String> {
4664 let Some(training_headers) = self.training_headers.as_ref() else {
4665 return HashSet::new();
4666 };
4667 let mut out = HashSet::<String>::new();
4668 for spec in self.saved_term_specs() {
4669 for term in &spec.random_effect_terms {
4670 if !term.lenient_unseen {
4671 continue;
4672 }
4673 if let Some(name) = training_headers.get(term.feature_col) {
4674 out.insert(name.clone());
4675 }
4676 }
4677 for term in &spec.smooth_terms {
4684 if let Some(group_col) = re_factor_smooth_group_col(&term.basis)
4685 && let Some(name) = training_headers.get(group_col)
4686 {
4687 out.insert(name.clone());
4688 }
4689 }
4690 }
4691 out
4692 }
4693
4694 pub fn numeric_fixed_factor_vocabularies(&self) -> Vec<(String, HashSet<u64>)> {
4712 let Some(training_headers) = self.training_headers.as_ref() else {
4713 return Vec::new();
4714 };
4715 let Some(schema) = self.data_schema.as_ref() else {
4716 return Vec::new();
4717 };
4718 let mut out = Vec::<(String, HashSet<u64>)>::new();
4719 for spec in self.saved_term_specs() {
4720 for term in &spec.random_effect_terms {
4721 if term.lenient_unseen || term.drop_first_level {
4722 continue;
4723 }
4724 let Some(levels) = term.frozen_levels.as_ref() else {
4725 continue;
4726 };
4727 let Some(name) = training_headers.get(term.feature_col) else {
4728 continue;
4729 };
4730 let is_numeric = schema
4733 .columns
4734 .iter()
4735 .find(|c| &c.name == name)
4736 .map(|c| matches!(c.kind, ColumnKindTag::Continuous | ColumnKindTag::Binary))
4737 .unwrap_or(false);
4738 if !is_numeric {
4739 continue;
4740 }
4741 let vocab: HashSet<u64> = levels
4742 .iter()
4743 .map(|&b| gam_data::canonical_level_bits(f64::from_bits(b)))
4744 .collect();
4745 out.push((name.clone(), vocab));
4746 }
4747 }
4748 out
4749 }
4750
4751 pub fn validate_for_persistence(&self) -> Result<(), FittedModelError> {
4752 self.validate_payload_version()?;
4766 if let (Some(fit_result), Some(unified)) =
4767 (self.fit_result.as_ref(), self.unified.as_ref())
4768 && fit_result.training_sample_size() != unified.training_sample_size()
4769 {
4770 return Err(FittedModelError::SchemaMismatch {
4771 reason: format!(
4772 "saved model fit_result training_sample_size {} disagrees with its unified \
4773 materialization {}; regenerate the model",
4774 fit_result.training_sample_size(),
4775 unified.training_sample_size()
4776 ),
4777 });
4778 }
4779 let expectile_family_tag = {
4780 let family = self.family.trim().to_ascii_lowercase();
4781 family == "expectile" || family.starts_with("expectile(")
4782 };
4783 match self.estimator {
4784 FittedEstimator::Likelihood if expectile_family_tag => {
4785 return Err(FittedModelError::SchemaMismatch {
4786 reason:
4787 "saved family is tagged expectile but estimator metadata says likelihood"
4788 .to_string(),
4789 });
4790 }
4791 FittedEstimator::Likelihood => {}
4792 FittedEstimator::Expectile { tau } => {
4793 if !tau.is_finite() || tau <= 0.0 || tau >= 1.0 {
4794 return Err(FittedModelError::SchemaMismatch {
4795 reason: format!(
4796 "saved expectile estimator requires finite tau strictly in (0, 1), got {tau}"
4797 ),
4798 });
4799 }
4800 let gaussian_identity_standard = self.model_kind == ModelKind::Standard
4801 && matches!(
4802 &self.family_state,
4803 FittedFamily::Standard { likelihood, .. }
4804 if likelihood == &LikelihoodSpec::gaussian_identity()
4805 );
4806 if !gaussian_identity_standard || !expectile_family_tag {
4807 return Err(FittedModelError::SchemaMismatch {
4808 reason: format!(
4809 "saved expectile estimator requires an expectile-tagged standard \
4810 Gaussian-identity fit; got model_kind={:?}, family={:?}, likelihood={:?}",
4811 self.model_kind,
4812 self.family,
4813 self.family_state.likelihood(),
4814 ),
4815 });
4816 }
4817 }
4818 }
4819 if self.training_table_kind.trim().is_empty() {
4820 return Err(FittedModelError::MissingField {
4821 reason: "saved model training_table_kind must be non-empty".to_string(),
4822 });
4823 }
4824 if let Some(scan) = self.spline_scan.as_ref() {
4825 if self.fit_result.is_some() || self.unified.is_some() {
4830 return Err(FittedModelError::SchemaMismatch {
4831 reason: "spline-scan model must not also carry a dense fit_result/unified \
4832 payload; the representations are mutually exclusive"
4833 .to_string(),
4834 });
4835 }
4836 if self.model_kind != ModelKind::Standard
4837 || self.family_state.likelihood() != LikelihoodSpec::gaussian_identity()
4838 {
4839 return Err(FittedModelError::SchemaMismatch {
4840 reason: format!(
4841 "spline-scan representation requires a standard Gaussian-identity model; \
4842 got model_kind={:?}, likelihood={:?}",
4843 self.model_kind,
4844 self.family_state.likelihood()
4845 ),
4846 });
4847 }
4848 if scan.feature_column.is_empty() {
4849 return Err(FittedModelError::MissingField {
4850 reason: "spline-scan model is missing its feature column name; refit"
4851 .to_string(),
4852 });
4853 }
4854 gam_solve::spline_scan::SplineScanFit::from_state(&scan.state)
4855 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
4856 if self.data_schema.is_none() {
4862 return Err(FittedModelError::MissingField {
4863 reason: "spline-scan model is missing data_schema; refit".to_string(),
4864 });
4865 }
4866 if self.training_headers.is_none() {
4867 return Err(FittedModelError::MissingField {
4868 reason: "spline-scan model is missing training_headers; refit".to_string(),
4869 });
4870 }
4871 return Ok(());
4872 } else if let Some(cascade) = self.residual_cascade.as_ref() {
4873 if self.spline_scan.is_some() || self.fit_result.is_some() || self.unified.is_some() {
4877 return Err(FittedModelError::SchemaMismatch {
4878 reason: "residual-cascade model must not also carry spline_scan / \
4879 fit_result / unified payloads; the representations are \
4880 mutually exclusive"
4881 .to_string(),
4882 });
4883 }
4884 if self.model_kind != ModelKind::Standard
4885 || self.family_state.likelihood() != LikelihoodSpec::gaussian_identity()
4886 {
4887 return Err(FittedModelError::SchemaMismatch {
4888 reason: format!(
4889 "residual-cascade representation requires a standard Gaussian-identity \
4890 model; got model_kind={:?}, likelihood={:?}",
4891 self.model_kind,
4892 self.family_state.likelihood()
4893 ),
4894 });
4895 }
4896 if cascade.feature_columns.is_empty()
4897 || !(2..=3).contains(&cascade.feature_columns.len())
4898 {
4899 return Err(FittedModelError::MissingField {
4900 reason: format!(
4901 "residual-cascade model needs 2 or 3 feature columns; got {}; refit",
4902 cascade.feature_columns.len()
4903 ),
4904 });
4905 }
4906 gam_solve::residual_cascade::ResidualCascadeFit::from_state(&cascade.state)
4907 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
4908 if self.data_schema.is_none() {
4909 return Err(FittedModelError::MissingField {
4910 reason: "residual-cascade model is missing data_schema; refit".to_string(),
4911 });
4912 }
4913 if self.training_headers.is_none() {
4914 return Err(FittedModelError::MissingField {
4915 reason: "residual-cascade model is missing training_headers; refit".to_string(),
4916 });
4917 }
4918 return Ok(());
4919 } else if self.fit_result.is_none() {
4920 return Err(FittedModelError::MissingField {
4921 reason: "model is missing canonical fit_result payload; refit".to_string(),
4922 });
4923 }
4924 if self.data_schema.is_none() {
4925 return Err(FittedModelError::MissingField {
4926 reason: "model is missing data_schema; refit".to_string(),
4927 });
4928 }
4929 if self.training_headers.is_none() {
4930 return Err(FittedModelError::MissingField {
4931 reason: "model is missing training_headers; refit to guarantee stable feature mapping at prediction time"
4932 .to_string(),
4933 });
4934 }
4935 let spec = self.resolved_termspec.as_ref().ok_or_else(|| {
4936 FittedModelError::MissingField {
4937 reason: "model is missing resolved_termspec; refit to guarantee train/predict design consistency"
4938 .to_string(),
4939 }
4940 })?;
4941 validate_frozen_term_collectionspec(spec, "resolved_termspec")?;
4942
4943 if self.formula_noise.is_some() && self.resolved_termspec_noise.is_none() {
4944 return Err(FittedModelError::MissingField {
4945 reason: "model defines formula_noise but is missing resolved_termspec_noise; refit"
4946 .to_string(),
4947 });
4948 }
4949 if let Some(spec_noise) = self.resolved_termspec_noise.as_ref() {
4950 validate_frozen_term_collectionspec(spec_noise, "resolved_termspec_noise")?;
4951 }
4952 if matches!(self.family_state, FittedFamily::TransformationNormal { .. }) {
4953 let score = self.transformation_score_calibration.ok_or_else(|| {
4954 FittedModelError::MissingField {
4955 reason: "transformation-normal model is missing transformation_score_calibration; refit"
4956 .to_string(),
4957 }
4958 })?;
4959 score.validate("transformation-normal model")?;
4960 let geometry = self.transformation_geometry.as_ref().ok_or_else(|| {
4965 FittedModelError::MissingField {
4966 reason: "transformation-normal model is missing the direct-α geometry record \
4967 (transformation_geometry); this is a pre-cutover (v12-or-older) CTN \
4968 model whose squared-γ chart is not replayable under the direct-α \
4969 cutover (gam#2306) — refit"
4970 .to_string(),
4971 }
4972 })?;
4973 geometry.validate("transformation-normal model")?;
4974 if let Some(knots) = self.transformation_response_knots.as_ref() {
4978 if geometry.response_knot_count != knots.len() {
4979 return Err(FittedModelError::SchemaMismatch {
4980 reason: format!(
4981 "transformation-normal geometry response_knot_count {} disagrees with \
4982 transformation_response_knots length {}",
4983 geometry.response_knot_count,
4984 knots.len()
4985 ),
4986 });
4987 }
4988 }
4989 if let Some(degree) = self.transformation_response_degree {
4990 if geometry.response_degree != degree {
4991 return Err(FittedModelError::SchemaMismatch {
4992 reason: format!(
4993 "transformation-normal geometry response_degree {} disagrees with \
4994 transformation_response_degree {degree}",
4995 geometry.response_degree
4996 ),
4997 });
4998 }
4999 }
5000 let carrier = self.transformation_cone_carrier.as_ref().ok_or_else(|| {
5005 FittedModelError::MissingField {
5006 reason: "transformation-normal model is missing the monotonicity-cone carrier \
5007 (transformation_cone_carrier); constrained posterior sampling cannot \
5008 certify draws against the positivity cone — refit"
5009 .to_string(),
5010 }
5011 })?;
5012 let expected = geometry
5013 .cone_carrier_row_count
5014 .checked_mul(geometry.cone_carrier_covariate_width)
5015 .ok_or_else(|| FittedModelError::SchemaMismatch {
5016 reason: "transformation-normal cone carrier dimensions overflow usize"
5017 .to_string(),
5018 })?;
5019 if carrier.len() != expected {
5020 return Err(FittedModelError::SchemaMismatch {
5021 reason: format!(
5022 "transformation-normal cone carrier length {} disagrees with geometry \
5023 {} rows x {} covariate columns = {expected}",
5024 carrier.len(),
5025 geometry.cone_carrier_row_count,
5026 geometry.cone_carrier_covariate_width,
5027 ),
5028 });
5029 }
5030 if carrier.iter().any(|value| !value.is_finite()) {
5031 return Err(FittedModelError::SchemaMismatch {
5032 reason: "transformation-normal cone carrier contains a non-finite entry"
5033 .to_string(),
5034 });
5035 }
5036 }
5037 if matches!(self.family_state, FittedFamily::MarginalSlope { .. }) {
5038 if self.formula_logslope.is_none() {
5039 return Err(FittedModelError::MissingField {
5040 reason: "marginal-slope model is missing formula_logslope; refit".to_string(),
5041 });
5042 }
5043 if self.z_column.is_none() {
5044 return Err(FittedModelError::MissingField {
5045 reason: "marginal-slope model is missing z_column; refit".to_string(),
5046 });
5047 }
5048 let z_normalization =
5049 self.latent_z_normalization
5050 .ok_or_else(|| FittedModelError::MissingField {
5051 reason: "marginal-slope model is missing latent_z_normalization; refit"
5052 .to_string(),
5053 })?;
5054 z_normalization.validate("marginal-slope model")?;
5055 let latent_measure =
5056 self.latent_measure
5057 .as_ref()
5058 .ok_or_else(|| FittedModelError::MissingField {
5059 reason: "marginal-slope model is missing latent_measure; refit".to_string(),
5060 })?;
5061 latent_measure
5062 .validate("marginal-slope model latent_measure")
5063 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
5064 if self.marginal_baseline.is_none() || self.logslope_baseline.is_none() {
5065 return Err(FittedModelError::MissingField {
5066 reason: "marginal-slope model is missing baseline offsets; refit".to_string(),
5067 });
5068 }
5069 if self.resolved_termspec_logslope.as_ref().is_none() {
5070 return Err(FittedModelError::MissingField {
5071 reason: "marginal-slope model is missing resolved_termspec_logslope for the logslope surface"
5072 .to_string(),
5073 });
5074 }
5075 match self.family_state.frailty() {
5076 Some(FrailtySpec::None)
5077 | Some(FrailtySpec::GaussianShift {
5078 scale: FrailtyScale::Fixed { .. },
5079 }) => {}
5080 Some(FrailtySpec::GaussianShift {
5081 scale: FrailtyScale::Learned { .. },
5082 }) => {
5083 return Err(FittedModelError::IncompatibleConfig {
5084 reason: "marginal-slope model requires a fixed GaussianShift sigma in family_state.frailty"
5085 .to_string(),
5086 });
5087 }
5088 Some(FrailtySpec::HazardMultiplier { .. }) => {
5089 return Err(FittedModelError::IncompatibleConfig {
5090 reason: "marginal-slope model does not support HazardMultiplier frailty"
5091 .to_string(),
5092 });
5093 }
5094 None => {
5095 return Err(FittedModelError::MissingField {
5096 reason: "marginal-slope model is missing family_state.frailty; refit"
5097 .to_string(),
5098 });
5099 }
5100 }
5101 }
5102
5103 if let FittedFamily::Survival {
5104 survival_likelihood,
5105 frailty,
5106 ..
5107 } = &self.family_state
5108 {
5109 if matches!(
5110 survival_likelihood.as_deref(),
5111 Some("latent") | Some("latent-binary")
5112 ) {
5113 return Err(FittedModelError::SchemaMismatch {
5114 reason: "latent hazard-window models must persist explicit family_state metadata, not generic survival metadata"
5115 .to_string(),
5116 });
5117 }
5118 if survival_likelihood.as_deref() == Some("marginal-slope") {
5119 if self.formula_logslope.is_none() {
5120 return Err(FittedModelError::MissingField {
5121 reason: "survival marginal-slope model is missing formula_logslope; refit"
5122 .to_string(),
5123 });
5124 }
5125 if self.z_column.is_none() {
5126 return Err(FittedModelError::MissingField {
5127 reason: "survival marginal-slope model is missing z_column; refit"
5128 .to_string(),
5129 });
5130 }
5131 let z_normalization =
5132 self.latent_z_normalization
5133 .ok_or_else(|| {
5134 FittedModelError::MissingField {
5135 reason:
5136 "survival marginal-slope model is missing latent_z_normalization; refit"
5137 .to_string(),
5138 }
5139 })?;
5140 z_normalization.validate("survival marginal-slope model")?;
5141 let latent_measure =
5142 self.latent_measure
5143 .as_ref()
5144 .ok_or_else(|| FittedModelError::MissingField {
5145 reason:
5146 "survival marginal-slope model is missing latent_measure; refit"
5147 .to_string(),
5148 })?;
5149 latent_measure
5150 .validate("survival marginal-slope model latent_measure")
5151 .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
5152 if self.logslope_baseline.is_none() {
5153 return Err(FittedModelError::MissingField {
5154 reason: "survival marginal-slope model is missing logslope_baseline; refit"
5155 .to_string(),
5156 });
5157 }
5158 if self.resolved_termspec_logslope.as_ref().is_none() {
5159 return Err(FittedModelError::MissingField {
5160 reason: "survival marginal-slope model is missing resolved_termspec_logslope for the logslope surface"
5161 .to_string(),
5162 });
5163 }
5164 match frailty {
5165 FrailtySpec::None
5166 | FrailtySpec::GaussianShift {
5167 scale: FrailtyScale::Fixed { .. },
5168 } => {}
5169 FrailtySpec::GaussianShift {
5170 scale: FrailtyScale::Learned { .. },
5171 } => {
5172 return Err(FittedModelError::IncompatibleConfig {
5173 reason: "survival marginal-slope model requires a fixed GaussianShift sigma in family_state.frailty"
5174 .to_string(),
5175 });
5176 }
5177 FrailtySpec::HazardMultiplier { .. } => {
5178 return Err(FittedModelError::IncompatibleConfig {
5179 reason: "survival marginal-slope model does not support HazardMultiplier frailty"
5180 .to_string(),
5181 });
5182 }
5183 }
5184 } else if !matches!(frailty, FrailtySpec::None) {
5185 return Err(FittedModelError::IncompatibleConfig {
5186 reason:
5187 "non-marginal survival models do not currently persist a frailty modifier"
5188 .to_string(),
5189 });
5190 }
5191 if self.survival_time_basis.is_none() {
5199 return Err(FittedModelError::MissingField {
5200 reason: "survival model is missing survival_time_basis; refit to persist the baseline-time basis configuration".to_string(),
5201 });
5202 }
5203 if self.survival_time_anchor.is_none() {
5204 return Err(FittedModelError::MissingField {
5205 reason: "survival model is missing survival_time_anchor; refit to persist the baseline-time anchor".to_string(),
5206 });
5207 }
5208 }
5209 if let FittedFamily::LatentSurvival { frailty } = &self.family_state {
5210 match frailty {
5211 FrailtySpec::HazardMultiplier {
5212 scale: FrailtyScale::Fixed { .. },
5213 ..
5214 } => {}
5215 FrailtySpec::HazardMultiplier {
5216 scale: FrailtyScale::Learned { .. },
5217 ..
5218 } => {
5219 return Err(FittedModelError::IncompatibleConfig {
5220 reason: "latent survival model requires a fixed HazardMultiplier sigma in family_state.frailty"
5221 .to_string(),
5222 });
5223 }
5224 FrailtySpec::GaussianShift { .. } | FrailtySpec::None => {
5225 return Err(FittedModelError::IncompatibleConfig {
5226 reason: "latent survival model requires a fixed HazardMultiplier frailty specification"
5227 .to_string(),
5228 });
5229 }
5230 }
5231 if self.survival_likelihood.as_deref() != Some("latent") {
5232 return Err(FittedModelError::SchemaMismatch {
5233 reason: "latent survival model must persist survival_likelihood=latent"
5234 .to_string(),
5235 });
5236 }
5237 }
5238 if let FittedFamily::LatentBinary { frailty } = &self.family_state {
5239 match frailty {
5240 FrailtySpec::HazardMultiplier {
5241 scale: FrailtyScale::Fixed { .. },
5242 ..
5243 } => {}
5244 FrailtySpec::HazardMultiplier {
5245 scale: FrailtyScale::Learned { .. },
5246 ..
5247 } => {
5248 return Err(FittedModelError::IncompatibleConfig {
5249 reason: "latent binary model requires a fixed HazardMultiplier sigma in family_state.frailty"
5250 .to_string(),
5251 });
5252 }
5253 FrailtySpec::GaussianShift { .. } | FrailtySpec::None => {
5254 return Err(FittedModelError::IncompatibleConfig {
5255 reason: "latent binary model requires a fixed HazardMultiplier frailty specification"
5256 .to_string(),
5257 });
5258 }
5259 }
5260 if self.survival_likelihood.as_deref() != Some("latent-binary") {
5261 return Err(FittedModelError::SchemaMismatch {
5262 reason: "latent binary model must persist survival_likelihood=latent-binary"
5263 .to_string(),
5264 });
5265 }
5266 }
5267
5268 let family_likelihood = match &self.family_state {
5269 FittedFamily::Standard { likelihood, .. }
5270 | FittedFamily::LocationScale { likelihood, .. }
5271 | FittedFamily::MarginalSlope { likelihood, .. }
5272 | FittedFamily::Survival { likelihood, .. }
5273 | FittedFamily::TransformationNormal { likelihood, .. } => Some(likelihood),
5274 FittedFamily::LatentSurvival { .. } | FittedFamily::LatentBinary { .. } => None,
5275 };
5276 let is_standard_or_location_scale = matches!(
5277 self.family_state,
5278 FittedFamily::Standard { .. } | FittedFamily::LocationScale { .. }
5279 );
5280 if is_standard_or_location_scale
5281 && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_sas)
5282 {
5283 self.saved_sas_state()?;
5284 }
5285 if is_standard_or_location_scale
5286 && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_beta_logistic)
5287 {
5288 self.saved_beta_logistic_state()?;
5289 }
5290 if is_standard_or_location_scale
5291 && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_mixture)
5292 {
5293 self.saved_mixture_state()?;
5294 }
5295 if matches!(self.family_state, FittedFamily::Standard { .. })
5296 && family_likelihood.is_some_and(LikelihoodSpec::is_latent_cloglog)
5297 {
5298 self.saved_latent_cloglog_state()?;
5299 }
5300 if matches!(self.family_state, FittedFamily::LocationScale { .. })
5301 && family_likelihood.is_some_and(LikelihoodSpec::is_latent_cloglog)
5302 {
5303 return Err(FittedModelError::IncompatibleConfig {
5304 reason: "latent-cloglog-binomial is not supported for location-scale saved models"
5305 .to_string(),
5306 });
5307 }
5308 if matches!(self.family_state, FittedFamily::Survival { .. })
5309 && self.survival_likelihood.is_none()
5310 {
5311 return Err(FittedModelError::MissingField {
5312 reason: "saved survival model is missing survival_likelihood metadata; refit"
5313 .to_string(),
5314 });
5315 }
5316 let is_survival_location_scale = matches!(self.family_state, FittedFamily::Survival { .. })
5317 && self
5318 .survival_likelihood
5319 .as_deref()
5320 .is_some_and(|value| value.eq_ignore_ascii_case("location-scale"));
5321 if !is_survival_location_scale && self.survival_location_scale_structure.is_some() {
5322 return Err(FittedModelError::SchemaMismatch {
5323 reason: "non-location-scale model carries location-scale replay structure"
5324 .to_string(),
5325 });
5326 }
5327 let has_any_saved_link_wiggle = self.linkwiggle_knots.is_some()
5328 || self.linkwiggle_degree.is_some()
5329 || self.linkwiggle_penalty_metadata.is_some()
5330 || self.beta_link_wiggle.is_some()
5331 || self
5332 .fit_result
5333 .as_ref()
5334 .and_then(|fit| fit.block_by_role(BlockRole::LinkWiggle))
5335 .is_some();
5336 let saved_link_wiggle = self.saved_link_wiggle()?;
5337 if has_any_saved_link_wiggle && saved_link_wiggle.is_none() {
5338 return Err(FittedModelError::SchemaMismatch {
5339 reason: "saved model has incomplete link-wiggle state; expected metadata and coefficients"
5340 .to_string(),
5341 });
5342 }
5343 if matches!(self.family_state, FittedFamily::Standard { .. })
5344 && saved_link_wiggle.is_some()
5345 && self.linkwiggle_penalty_metadata.is_none()
5346 {
5347 return Err(FittedModelError::MissingField {
5348 reason: "standard link-wiggle model is missing canonical penalty metadata; refit"
5349 .to_string(),
5350 });
5351 }
5352 let has_any_saved_baseline_time_wiggle = self.baseline_timewiggle_knots.is_some()
5353 || self.baseline_timewiggle_degree.is_some()
5354 || self.baseline_timewiggle_penalty_orders.is_some()
5355 || self.baseline_timewiggle_double_penalty.is_some()
5356 || self.beta_baseline_timewiggle.is_some()
5357 || self.beta_baseline_timewiggle_by_cause.is_some();
5358 let is_joint_cause_specific = self
5359 .survival_cause_count
5360 .is_some_and(|cause_count| cause_count > 1);
5361 if has_any_saved_baseline_time_wiggle {
5362 if is_joint_cause_specific {
5363 let complete = self.baseline_timewiggle_knots.is_some()
5364 && self.baseline_timewiggle_degree.is_some()
5365 && self.baseline_timewiggle_penalty_orders.is_some()
5366 && self.baseline_timewiggle_double_penalty.is_some()
5367 && self.beta_baseline_timewiggle_by_cause.is_some();
5368 if !complete {
5369 return Err(FittedModelError::SchemaMismatch {
5370 reason: "saved joint cause-specific survival model has incomplete baseline-timewiggle state; expected metadata and per-cause coefficients"
5371 .to_string(),
5372 });
5373 }
5374 } else if self.saved_baseline_time_wiggle()?.is_none() {
5375 return Err(FittedModelError::SchemaMismatch {
5376 reason: "saved model has incomplete baseline-timewiggle state; expected metadata and coefficients"
5377 .to_string(),
5378 });
5379 }
5380 }
5381 if is_survival_location_scale {
5382 validate_survival_location_scale_saved_fit(self.payload(), saved_link_wiggle.as_ref())?;
5383 }
5384 self.validate_required_posterior_mean_state()?;
5385
5386 if let Some(runtime) = self.score_warp_runtime.as_ref() {
5396 runtime.validate_exact_replay_contract().map_err(|err| {
5397 FittedModelError::PayloadCorrupt {
5398 reason: format!("saved anchored score-warp runtime is invalid: {err}"),
5399 }
5400 })?;
5401 }
5402 if let Some(runtime) = self.link_deviation_runtime.as_ref() {
5403 runtime.validate_exact_replay_contract().map_err(|err| {
5404 FittedModelError::PayloadCorrupt {
5405 reason: format!("saved anchored link-deviation runtime is invalid: {err}"),
5406 }
5407 })?;
5408 }
5409 if matches!(self.family_state, FittedFamily::MarginalSlope { .. }) {
5410 validate_marginal_slope_saved_fit(
5411 self.fit_result.as_ref().expect("checked above"),
5412 self.score_warp_runtime.as_ref(),
5413 self.link_deviation_runtime.as_ref(),
5414 "fit_result",
5415 )?;
5416 let unified = self
5417 .unified
5418 .as_ref()
5419 .ok_or_else(|| FittedModelError::MissingField {
5420 reason: "marginal-slope model is missing unified fit payload; refit"
5421 .to_string(),
5422 })?;
5423 validate_marginal_slope_saved_fit(
5424 unified,
5425 self.score_warp_runtime.as_ref(),
5426 self.link_deviation_runtime.as_ref(),
5427 "unified",
5428 )?;
5429 }
5430 if self
5431 .survival_likelihood
5432 .as_deref()
5433 .is_some_and(|value| value.eq_ignore_ascii_case("marginal-slope"))
5434 {
5435 validate_survival_marginal_slope_saved_fit(
5436 self,
5437 self.fit_result.as_ref().expect("checked above"),
5438 "fit_result",
5439 )?;
5440 if let Some(unified) = self.unified.as_ref() {
5441 validate_survival_marginal_slope_saved_fit(self, unified, "unified")?;
5442 }
5443 }
5444
5445 Ok(())
5446 }
5447
5448 fn validate_required_posterior_mean_state(&self) -> Result<(), FittedModelError> {
5458 if !self.prediction_uses_posterior_mean() {
5459 return Ok(());
5460 }
5461 let fit = self
5462 .payload()
5463 .fit_result
5464 .as_ref()
5465 .ok_or_else(|| FittedModelError::MissingField {
5466 reason:
5467 "curved-link model is missing the fit state required for posterior-mean prediction"
5468 .to_string(),
5469 })?;
5470 let p = fit.beta.len();
5471 if let Some(covariance) = fit.beta_covariance() {
5472 if covariance.dim() == (p, p) {
5473 return Ok(());
5474 }
5475 return Err(FittedModelError::SchemaMismatch {
5476 reason: format!(
5477 "curved-link model conditional covariance has shape {}x{}, expected {p}x{p} in the saved coefficient frame",
5478 covariance.nrows(),
5479 covariance.ncols(),
5480 ),
5481 });
5482 }
5483
5484 if fit
5485 .geometry
5486 .as_ref()
5487 .is_some_and(|geometry| !geometry.coefficient_gauge.is_identity())
5488 {
5489 return Err(FittedModelError::SchemaMismatch {
5490 reason: format!(
5491 "curved-link model has no saved/raw-frame covariance and its penalized precision lives in an active gauge; persist the lifted {p}x{p} covariance required for posterior-mean prediction"
5492 ),
5493 });
5494 }
5495 let precision = fit.penalized_hessian().ok_or_else(|| FittedModelError::MissingField {
5496 reason: format!(
5497 "curved-link model must persist a {p}x{p} joint conditional covariance or penalized precision to compute the required posterior mean"
5498 ),
5499 })?;
5500 if precision.dim() != (p, p) {
5501 return Err(FittedModelError::SchemaMismatch {
5502 reason: format!(
5503 "curved-link model penalized precision has shape {}x{}, expected {p}x{p} in the saved coefficient frame",
5504 precision.nrows(),
5505 precision.ncols(),
5506 ),
5507 });
5508 }
5509 precision
5510 .cholesky(faer::Side::Lower)
5511 .map_err(|error| FittedModelError::PayloadCorrupt {
5512 reason: format!(
5513 "curved-link model penalized precision cannot define the required posterior mean: strict Cholesky failed: {error}"
5514 ),
5515 })?;
5516 fit.coefficient_covariance_scale()
5517 .map_err(|error| FittedModelError::PayloadCorrupt {
5518 reason: format!(
5519 "curved-link model cannot scale its saved penalized precision into a posterior covariance: {error}"
5520 ),
5521 })?;
5522 Ok(())
5523 }
5524
5525 pub fn validate_persisted_form_parses_back(&self) -> Result<(), FittedModelError> {
5547 let value =
5548 serde_json::to_value(self).map_err(|error| FittedModelError::PayloadCorrupt {
5549 reason: format!("failed to serialize model: {error}"),
5550 })?;
5551 let Err(parse_error) = serde_json::from_value::<Self>(value.clone()) else {
5552 return Ok(());
5553 };
5554 let mut nulls = Vec::new();
5555 collect_json_null_paths(&value, "$", &mut nulls);
5556 const REPORTED_NULL_PATHS: usize = 24;
5560 let truncated = nulls.len().saturating_sub(REPORTED_NULL_PATHS);
5561 nulls.truncate(REPORTED_NULL_PATHS);
5562 Err(FittedModelError::PayloadCorrupt {
5563 reason: format!(
5564 "refusing to persist a model that cannot be parsed back: {parse_error}. \
5565 A non-finite f64 serialises as JSON null, so these null-valued paths are \
5566 the candidates: [{}]{}",
5567 nulls.join(", "),
5568 if truncated > 0 {
5569 format!(" (+{truncated} more)")
5570 } else {
5571 String::new()
5572 },
5573 ),
5574 })
5575 }
5576
5577 pub fn validate_numeric_finiteness(&self) -> Result<(), FittedModelError> {
5578 let corrupt = |reason: String| FittedModelError::PayloadCorrupt { reason };
5579 if let Some(fit) = self.fit_result.as_ref() {
5580 fit.validate_numeric_finiteness()
5581 .map_err(|e| corrupt(e.to_string()))?;
5582 }
5583
5584 for (name, opt) in [
5585 ("survival_baseline_scale", self.survival_baseline_scale),
5586 ("survival_baseline_shape", self.survival_baseline_shape),
5587 ("survival_baseline_rate", self.survival_baseline_rate),
5588 ("survival_baseline_makeham", self.survival_baseline_makeham),
5589 (
5590 "survival_time_smooth_lambda",
5591 self.survival_time_smooth_lambda,
5592 ),
5593 ("survival_time_anchor", self.survival_time_anchor),
5594 ("survivalridge_lambda", self.survivalridge_lambda),
5595 ] {
5596 if let Some(v) = opt {
5597 ensure_finite_scalar(name, v).map_err(corrupt)?;
5598 }
5599 }
5600
5601 if let Some(v) = self.beta_noise.as_ref() {
5602 validate_all_finite("beta_noise", v.iter().copied()).map_err(corrupt)?;
5603 }
5604 if let Some(v) = self.noise_projection.as_ref() {
5605 validate_all_finite("noise_projection", v.iter().flatten().copied())
5606 .map_err(corrupt)?;
5607 if self.noise_projection_ridge_alpha.is_none() {
5608 return Err(FittedModelError::MissingField {
5609 reason:
5610 "model has noise_projection but is missing noise_projection_ridge_alpha; refit"
5611 .to_string(),
5612 });
5613 }
5614 }
5615 if let Some(v) = self.noise_center.as_ref() {
5616 validate_all_finite("noise_center", v.iter().copied()).map_err(corrupt)?;
5617 }
5618 if let Some(v) = self.noise_scale.as_ref() {
5619 validate_all_finite("noise_scale", v.iter().copied()).map_err(corrupt)?;
5620 }
5621 if let Some(v) = self.noise_projection_ridge_alpha {
5622 ensure_finite_scalar("noise_projection_ridge_alpha", v).map_err(corrupt)?;
5623 if v < 0.0 {
5624 return Err(FittedModelError::InvalidInput {
5625 reason: format!("noise_projection_ridge_alpha must be non-negative, got {v}"),
5626 });
5627 }
5628 }
5629 if let Some(v) = self.gaussian_response_scale {
5630 ensure_finite_scalar("gaussian_response_scale", v).map_err(corrupt)?;
5631 }
5632 if let Some(v) = self.beta_link_wiggle.as_ref() {
5633 validate_all_finite("beta_link_wiggle", v.iter().copied()).map_err(corrupt)?;
5634 }
5635 if let Some(v) = self.link_wiggle_index_shift.as_ref() {
5636 validate_all_finite("link_wiggle_index_shift", v.iter().copied()).map_err(corrupt)?;
5637 }
5638 if let Some(v) = self.beta_baseline_timewiggle.as_ref() {
5639 validate_all_finite("beta_baseline_timewiggle", v.iter().copied()).map_err(corrupt)?;
5640 }
5641 if let Some(v) = self.beta_baseline_timewiggle_by_cause.as_ref() {
5642 validate_all_finite(
5643 "beta_baseline_timewiggle_by_cause",
5644 v.iter().flatten().copied(),
5645 )
5646 .map_err(corrupt)?;
5647 }
5648 if let Some(v) = self.latent_z_normalization {
5649 v.validate("latent_z_normalization")?;
5650 }
5651 if let Some(v) = self.latent_measure.as_ref() {
5652 v.validate("latent_measure").map_err(corrupt)?;
5653 }
5654 if let Some(v) = self.survival_beta_time.as_ref() {
5655 validate_all_finite("survival_beta_time", v.iter().copied()).map_err(corrupt)?;
5656 }
5657 if let Some(v) = self.survival_beta_threshold.as_ref() {
5658 validate_all_finite("survival_beta_threshold", v.iter().copied()).map_err(corrupt)?;
5659 }
5660 if let Some(v) = self.survival_beta_log_sigma.as_ref() {
5661 validate_all_finite("survival_beta_log_sigma", v.iter().copied()).map_err(corrupt)?;
5662 }
5663 if let Some(v) = self.mixture_link_param_covariance.as_ref() {
5664 validate_all_finite("mixture_link_param_covariance", v.iter().flatten().copied())
5665 .map_err(corrupt)?;
5666 }
5667 if let Some(v) = self.sas_param_covariance.as_ref() {
5668 validate_all_finite("sas_param_covariance", v.iter().flatten().copied())
5669 .map_err(corrupt)?;
5670 }
5671 Ok(())
5672 }
5673}
5674
5675use gam_solve::estimate::{ensure_finite_scalar, validate_all_finite};
5676
5677fn validate_frozen_term_collectionspec(
5678 spec: &TermCollectionSpec,
5679 label: &str,
5680) -> Result<(), FittedModelError> {
5681 spec.validate_frozen(label)
5682 .map_err(|reason| FittedModelError::SchemaMismatch { reason })
5683}
5684
5685impl Deref for FittedModel {
5686 type Target = FittedModelPayload;
5687
5688 fn deref(&self) -> &Self::Target {
5689 self.payload()
5690 }
5691}
5692
5693impl DerefMut for FittedModel {
5694 fn deref_mut(&mut self) -> &mut Self::Target {
5695 self.payload_mut()
5696 }
5697}
5698
5699pub fn survival_baseline_config_from_model(
5704 model: &FittedModel,
5705) -> Result<SurvivalBaselineConfig, FittedModelError> {
5706 let target = model.survival_baseline_target.as_deref().ok_or_else(|| {
5707 FittedModelError::MissingField {
5708 reason: "saved survival model missing survival_baseline_target; refit".to_string(),
5709 }
5710 })?;
5711 parse_survival_baseline_config(
5712 target,
5713 model.survival_baseline_scale,
5714 model.survival_baseline_shape,
5715 model.survival_baseline_rate,
5716 model.survival_baseline_makeham,
5717 )
5718 .map_err(|reason| FittedModelError::IncompatibleConfig { reason })
5719}
5720
5721pub fn load_survival_time_basis_config_from_model(
5722 model: &FittedModel,
5723) -> Result<SurvivalTimeBasisConfig, FittedModelError> {
5724 match model
5725 .survival_time_basis
5726 .as_deref()
5727 .ok_or_else(|| FittedModelError::MissingField {
5728 reason: "saved survival model missing survival_time_basis".to_string(),
5729 })?
5730 .to_ascii_lowercase()
5731 .as_str()
5732 {
5733 "none" => Ok(SurvivalTimeBasisConfig::None),
5734 "linear" => Ok(SurvivalTimeBasisConfig::Linear),
5735 "bspline" => {
5736 let degree =
5737 model
5738 .survival_time_degree
5739 .ok_or_else(|| FittedModelError::MissingField {
5740 reason: "saved survival bspline model missing survival_time_degree"
5741 .to_string(),
5742 })?;
5743 let knots = model.survival_time_knots.clone().ok_or_else(|| {
5744 FittedModelError::MissingField {
5745 reason: "saved survival bspline model missing survival_time_knots".to_string(),
5746 }
5747 })?;
5748 let smooth_lambda = model.survival_time_smooth_lambda.unwrap_or(1e-2);
5749 if degree < 1 || knots.is_empty() {
5750 return Err(FittedModelError::SchemaMismatch {
5751 reason: "saved survival bspline time basis metadata is invalid".to_string(),
5752 });
5753 }
5754 Ok(SurvivalTimeBasisConfig::BSpline {
5755 degree,
5756 knots: Array1::from_vec(knots),
5757 smooth_lambda,
5758 })
5759 }
5760 "ispline" => {
5761 let degree =
5762 model
5763 .survival_time_degree
5764 .ok_or_else(|| FittedModelError::MissingField {
5765 reason: "saved survival ispline model missing survival_time_degree"
5766 .to_string(),
5767 })?;
5768 let knots = model.survival_time_knots.clone().ok_or_else(|| {
5769 FittedModelError::MissingField {
5770 reason: "saved survival ispline model missing survival_time_knots".to_string(),
5771 }
5772 })?;
5773 let keep_cols = model.survival_time_keep_cols.clone().ok_or_else(|| {
5774 FittedModelError::MissingField {
5775 reason: "saved survival ispline model missing survival_time_keep_cols"
5776 .to_string(),
5777 }
5778 })?;
5779 let smooth_lambda = model.survival_time_smooth_lambda.unwrap_or(1e-2);
5780 if degree < 1 || knots.is_empty() || keep_cols.is_empty() {
5781 return Err(FittedModelError::SchemaMismatch {
5782 reason: "saved survival ispline time basis metadata is invalid".to_string(),
5783 });
5784 }
5785 Ok(SurvivalTimeBasisConfig::ISpline {
5786 degree,
5787 knots: Array1::from_vec(knots),
5788 keep_cols,
5789 smooth_lambda,
5790 })
5791 }
5792 other => Err(FittedModelError::IncompatibleConfig {
5793 reason: format!("unsupported saved survival_time_basis '{other}'"),
5794 }),
5795 }
5796}
5797
5798fn collect_json_null_paths(value: &serde_json::Value, path: &str, out: &mut Vec<String>) {
5806 match value {
5807 serde_json::Value::Null => out.push(path.to_string()),
5808 serde_json::Value::Array(items) => {
5809 for (index, item) in items.iter().enumerate() {
5810 collect_json_null_paths(item, &format!("{path}[{index}]"), out);
5811 }
5812 }
5813 serde_json::Value::Object(entries) => {
5814 for (key, item) in entries {
5815 collect_json_null_paths(item, &format!("{path}.{key}"), out);
5816 }
5817 }
5818 _ => return,
5824 }
5825}
5826
5827#[cfg(test)]
5828mod tests {
5829 use super::*;
5830 use crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL;
5831 use crate::survival::lognormal_kernel::FrailtySpec;
5832 use gam_data::SchemaColumn;
5833 use gam_problem::types::{LikelihoodScaleMetadata, LogLikelihoodNormalization};
5834 use gam_solve::estimate::{FitArtifacts, FittedBlock, FittedLinkState};
5835 use gam_solve::pirls::PirlsStatus;
5836 use ndarray::{Array1, Array2, array};
5837
5838 fn empty_termspec() -> TermCollectionSpec {
5839 TermCollectionSpec {
5840 linear_terms: vec![],
5841 random_effect_terms: vec![],
5842 smooth_terms: vec![],
5843 }
5844 }
5845
5846 fn transformation_normal_payload(version: u32, fit: UnifiedFitResult) -> FittedModelPayload {
5851 let mut payload = FittedModelPayload::new(
5852 version,
5853 "y ~ s(x)".to_string(),
5854 ModelKind::TransformationNormal,
5855 FittedFamily::TransformationNormal {
5856 likelihood: LikelihoodSpec::gaussian_identity(),
5857 },
5858 "transformation-normal".to_string(),
5859 );
5860 payload.fit_result = Some(fit.clone());
5861 payload.unified = Some(fit);
5862 payload.data_schema = Some(DataSchema {
5863 columns: vec![
5864 SchemaColumn {
5865 name: "y".to_string(),
5866 kind: ColumnKindTag::Continuous,
5867 levels: vec![],
5868 },
5869 SchemaColumn {
5870 name: "x".to_string(),
5871 kind: ColumnKindTag::Continuous,
5872 levels: vec![],
5873 },
5874 ],
5875 });
5876 payload.set_training_feature_metadata(vec!["x".to_string()], vec![(0.0, 1.0)]);
5877 payload.resolved_termspec = Some(empty_termspec());
5878 let knots = vec![0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0];
5879 payload.transformation_response_knots = Some(knots.clone());
5880 payload.transformation_response_transform = Some(vec![vec![1.0]]);
5881 payload.transformation_response_degree = Some(2);
5882 payload.transformation_response_median = Some(0.5);
5883 payload.transformation_score_calibration =
5884 Some(TransformationScoreCalibration::finite_support_pit());
5885 payload.transformation_geometry = Some(SavedTransformationNormalGeometry {
5886 parameterization: TransformationNormalParameterization::DirectAlpha,
5887 response_degree: 2,
5888 response_knot_count: knots.len(),
5889 shape_coordinate_count: 3,
5890 cone_carrier_covariate_width: 2,
5891 cone_carrier_row_count: 16,
5892 certified_response_support: (0.0, 1.0),
5893 response_median: 0.5,
5894 });
5895 payload.transformation_cone_carrier = Some(
5897 (0..16 * 2).map(|i| 1.0 + 0.01 * i as f64).collect(),
5898 );
5899 payload
5900 }
5901
5902 fn transformation_normal_fit() -> UnifiedFitResult {
5903 saved_fit(vec![FittedBlock {
5904 beta: Array1::from_vec(vec![0.1, 0.2, -0.3]),
5905 role: BlockRole::Mean,
5906 edf: 1.0,
5907 lambdas: Array1::zeros(0),
5908 }])
5909 }
5910
5911 #[test]
5914 fn transformation_normal_geometry_round_trips_and_validates() {
5915 let payload = transformation_normal_payload(MODEL_PAYLOAD_VERSION, transformation_normal_fit());
5916 let model = FittedModel::from_payload(payload);
5917 model
5918 .validate_for_persistence()
5919 .expect("CTN model carrying the direct-α geometry record validates");
5920
5921 let json = serde_json::to_string(&model).expect("serialize CTN model");
5922 let restored: FittedModel = serde_json::from_str(&json).expect("parse CTN model");
5923 restored
5924 .validate_for_persistence()
5925 .expect("restored CTN model validates");
5926 let geometry = restored
5927 .payload()
5928 .transformation_geometry
5929 .as_ref()
5930 .expect("restored payload carries the direct-α geometry record");
5931 assert_eq!(
5932 geometry.parameterization,
5933 TransformationNormalParameterization::DirectAlpha
5934 );
5935 assert_eq!(geometry.response_degree, 2);
5936 assert_eq!(geometry.response_knot_count, 7);
5937 assert_eq!(geometry.shape_coordinate_count, 3);
5938 assert_eq!(geometry.cone_carrier_covariate_width, 2);
5939 assert_eq!(geometry.cone_carrier_row_count, 16);
5940 assert_eq!(geometry.certified_response_support, (0.0, 1.0));
5941 assert_eq!(geometry.response_median, 0.5);
5942 }
5943
5944 #[test]
5948 fn validate_for_persistence_rejects_ctn_without_geometry_record() {
5949 let mut payload =
5950 transformation_normal_payload(MODEL_PAYLOAD_VERSION, transformation_normal_fit());
5951 payload.transformation_geometry = None;
5952 let err = FittedModel::from_payload(payload)
5953 .validate_for_persistence()
5954 .expect_err("CTN model without the direct-α geometry record must be rejected");
5955 assert!(
5956 err.to_string().contains("transformation_geometry"),
5957 "message names the field: {err}"
5958 );
5959 assert!(
5960 err.to_string().contains("pre-cutover"),
5961 "message explains the pre-cutover rejection: {err}"
5962 );
5963
5964 let mut mismatched =
5968 transformation_normal_payload(MODEL_PAYLOAD_VERSION, transformation_normal_fit());
5969 if let Some(geometry) = mismatched.transformation_geometry.as_mut() {
5970 geometry.response_knot_count += 1;
5971 }
5972 let err = FittedModel::from_payload(mismatched)
5973 .validate_for_persistence()
5974 .expect_err("geometry disagreeing with the persisted knots must be rejected");
5975 assert!(
5976 err.to_string().contains("response_knot_count"),
5977 "message names the mismatch: {err}"
5978 );
5979 }
5980
5981 #[test]
5985 fn validate_for_persistence_rejects_ctn_without_or_with_mismatched_cone_carrier() {
5986 let mut missing =
5987 transformation_normal_payload(MODEL_PAYLOAD_VERSION, transformation_normal_fit());
5988 missing.transformation_cone_carrier = None;
5989 let err = FittedModel::from_payload(missing)
5990 .validate_for_persistence()
5991 .expect_err("CTN model without the cone carrier must be rejected");
5992 assert!(
5993 err.to_string().contains("transformation_cone_carrier"),
5994 "message names the missing field: {err}"
5995 );
5996
5997 let mut mismatched =
5998 transformation_normal_payload(MODEL_PAYLOAD_VERSION, transformation_normal_fit());
5999 mismatched.transformation_cone_carrier = Some(vec![0.0; 31]);
6001 let err = FittedModel::from_payload(mismatched)
6002 .validate_for_persistence()
6003 .expect_err("a cone carrier disagreeing with the geometry dimensions must be rejected");
6004 assert!(
6005 err.to_string().contains("cone carrier length"),
6006 "message names the dimension mismatch: {err}"
6007 );
6008 }
6009
6010 #[test]
6014 fn spline_scan_payload_round_trips_and_validates() {
6015 let x: Vec<f64> = (0..40).map(|i| i as f64 / 39.0).collect();
6016 let y: Vec<f64> = x.iter().map(|&v| (4.0 * v).sin() + 0.1 * v).collect();
6017 let w = vec![1.0_f64; x.len()];
6018 let fit = gam_solve::spline_scan::fit_spline_scan(&x, &y, &w, 2).expect("scan fit");
6019 let make_payload = || {
6020 crate::inference::model_payload_builders::assemble_spline_scan_payload(
6021 "y ~ s(x)".to_string(),
6022 "x".to_string(),
6023 &fit,
6024 DataSchema {
6025 columns: vec![
6026 SchemaColumn {
6027 name: "y".to_string(),
6028 kind: ColumnKindTag::Continuous,
6029 levels: vec![],
6030 },
6031 SchemaColumn {
6032 name: "x".to_string(),
6033 kind: ColumnKindTag::Continuous,
6034 levels: vec![],
6035 },
6036 ],
6037 },
6038 vec!["x".to_string()],
6039 vec![(0.0, 1.0)],
6040 )
6041 };
6042 let model = FittedModel::from_payload(make_payload());
6045 model
6046 .validate_for_persistence()
6047 .expect("scan model validates");
6048 model
6049 .validate_numeric_finiteness()
6050 .expect("scan model is finite");
6051
6052 let json = serde_json::to_string(&model).expect("serialize model");
6053 let restored: FittedModel = serde_json::from_str(&json).expect("parse model");
6054 restored
6055 .validate_for_persistence()
6056 .expect("restored scan model validates");
6057 let (column, replay) = restored
6058 .saved_spline_scan()
6059 .expect("restore scan fit")
6060 .expect("payload carries the scan representation");
6061 assert_eq!(column, "x");
6062 for &xq in &[-0.1, 0.0, 0.31, 0.5, 0.77, 1.0, 1.4] {
6063 let (m0, v0) = fit.predict(xq).expect("predict original");
6064 let (m1, v1) = replay.predict(xq).expect("predict replayed");
6065 assert_eq!(m0.to_bits(), m1.to_bits(), "mean drift at x={xq}");
6066 assert_eq!(v0.to_bits(), v1.to_bits(), "variance drift at x={xq}");
6067 }
6068
6069 let mut dense = make_payload();
6071 dense.spline_scan = None;
6072 let err = FittedModel::from_payload(dense)
6073 .validate_for_persistence()
6074 .expect_err("dense payload without fit_result must be rejected");
6075 assert!(err.to_string().contains("fit_result"));
6076
6077 let mut corrupt = make_payload();
6079 corrupt
6080 .spline_scan
6081 .as_mut()
6082 .expect("scan channel present")
6083 .state
6084 .knots
6085 .truncate(2);
6086 FittedModel::from_payload(corrupt)
6087 .validate_for_persistence()
6088 .expect_err("corrupt scan state must be rejected");
6089 let mut unnamed = make_payload();
6090 unnamed
6091 .spline_scan
6092 .as_mut()
6093 .expect("scan channel present")
6094 .feature_column
6095 .clear();
6096 FittedModel::from_payload(unnamed)
6097 .validate_for_persistence()
6098 .expect_err("missing feature column must be rejected");
6099 }
6100
6101 fn standard_gaussian_payload() -> FittedModelPayload {
6102 FittedModelPayload::new(
6103 MODEL_PAYLOAD_VERSION,
6104 "y ~ 1".to_string(),
6105 ModelKind::Standard,
6106 FittedFamily::Standard {
6107 likelihood: LikelihoodSpec::gaussian_identity(),
6108 link: Some(StandardLink::Identity),
6109 latent_cloglog_state: None,
6110 mixture_state: None,
6111 sas_state: None,
6112 },
6113 "gaussian".to_string(),
6114 )
6115 }
6116
6117 fn anchored_runtime(basis_dim: usize) -> SavedCompiledFlexBlock {
6118 SavedCompiledFlexBlock {
6119 kernel: ANCHORED_DEVIATION_KERNEL.to_string(),
6120 breakpoints: vec![-1.0, 1.0],
6121 basis_dim,
6122 span_c0: vec![vec![0.0; basis_dim]],
6123 span_c1: vec![vec![0.0; basis_dim]],
6124 span_c2: vec![vec![0.0; basis_dim]],
6125 span_c3: vec![vec![0.0; basis_dim]],
6126 anchor_correction: None,
6127 anchor_components: Vec::new(),
6128 }
6129 }
6130
6131 fn saved_fit(blocks: Vec<FittedBlock>) -> UnifiedFitResult {
6132 let p: usize = blocks.iter().map(|block| block.beta.len()).sum();
6133 UnifiedFitResult::try_from_parts(gam_solve::estimate::UnifiedFitResultParts {
6134 blocks,
6135 training_sample_size: 16,
6136 log_lambdas: Array1::zeros(0),
6137 lambdas: Array1::zeros(0),
6138 likelihood_family: Some(LikelihoodSpec::binomial_probit()),
6139 likelihood_scale: LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 },
6142 log_likelihood_normalization: LogLikelihoodNormalization::Full,
6143 log_likelihood: 0.0,
6144 deviance: 0.0,
6145 reml_score: Some(0.0),
6146 stable_penalty_term: 0.0,
6147 penalized_objective: Some(0.0),
6148 used_device: false,
6149 outer_iterations: 0,
6150 outer_converged: true,
6151 outer_gradient_norm: None,
6152 standard_deviation: 1.0,
6153 covariance_conditional: Some(Array2::zeros((p, p))),
6154 covariance_corrected: Some(Array2::zeros((p, p))),
6155 inference: None,
6156 fitted_link: FittedLinkState::Standard(None),
6157 geometry: None,
6158 block_states: vec![],
6159 pirls_status: PirlsStatus::Converged,
6160 max_abs_eta: 0.0,
6161 constraint_kkt: None,
6162 artifacts: FitArtifacts {
6163 pirls: None,
6164 null_space_logdet: None,
6165 null_space_dim: None,
6166 survival_link_wiggle_knots: None,
6167 survival_link_wiggle_degree: None,
6168 criterion_certificate: None,
6169 rho_posterior_certificate: None,
6170 rho_posterior_escalation: None,
6171 rho_covariance: None,
6172 joint_log_lambdas: None,
6173 firth_bias_reduction: false,
6174 covariance_declined: None,
6175 },
6176 inner_cycles: 0,
6177 })
6178 .expect("test fixture fit must assemble")
6179 }
6180
6181 fn standard_binomial_model(fit: UnifiedFitResult) -> FittedModel {
6182 let mut payload = FittedModelPayload::new(
6183 MODEL_PAYLOAD_VERSION,
6184 "y ~ 1".to_string(),
6185 ModelKind::Standard,
6186 FittedFamily::Standard {
6187 likelihood: LikelihoodSpec::binomial_probit(),
6188 link: Some(StandardLink::Probit),
6189 latent_cloglog_state: None,
6190 mixture_state: None,
6191 sas_state: None,
6192 },
6193 "binomial".to_string(),
6194 );
6195 payload.fit_result = Some(fit.clone());
6196 payload.unified = Some(fit);
6197 FittedModel::from_payload(payload)
6198 }
6199
6200 #[test]
6201 fn dense_materializations_must_share_training_sample_size() {
6202 let canonical = saved_fit(vec![FittedBlock {
6203 beta: array![0.25],
6204 role: BlockRole::Mean,
6205 edf: 1.0,
6206 lambdas: Array1::zeros(0),
6207 }]);
6208 let mut encoded = serde_json::to_value(&canonical).expect("serialize canonical fit");
6209 encoded
6210 .as_object_mut()
6211 .expect("fit serializes as an object")
6212 .insert("training_sample_size".to_string(), serde_json::json!(17));
6213 let divergent =
6214 serde_json::from_value::<UnifiedFitResult>(encoded).expect("decode divergent alias");
6215
6216 let model = standard_binomial_model(canonical);
6217 let mut payload = model.payload().clone();
6218 payload.unified = Some(divergent);
6219 let error = FittedModel::from_payload(payload)
6220 .validate_for_persistence()
6221 .expect_err("dense aliases cannot disagree about training rows");
6222 assert!(
6223 error.to_string().contains("training_sample_size"),
6224 "alias disagreement reported an unrelated error: {error}"
6225 );
6226 }
6227
6228 #[test]
6229 fn curved_link_persistence_rejects_mode_without_posterior_state() {
6230 let mut fit = saved_fit(vec![FittedBlock {
6231 beta: array![0.25],
6232 role: BlockRole::Mean,
6233 edf: 1.0,
6234 lambdas: Array1::zeros(0),
6235 }]);
6236 fit.covariance_conditional = None;
6237 fit.inference = None;
6238 fit.geometry = None;
6239
6240 let error = standard_binomial_model(fit)
6241 .validate_required_posterior_mean_state()
6242 .expect_err("a curved-link mode alone is not a persistable fitted model");
6243 assert!(error.to_string().contains("posterior mean"));
6244 assert!(error.to_string().contains("covariance or penalized precision"));
6245 }
6246
6247 #[test]
6248 fn curved_link_persistence_accepts_and_round_trips_saved_posterior_state() {
6249 let fit = saved_fit(vec![FittedBlock {
6250 beta: array![0.25],
6251 role: BlockRole::Mean,
6252 edf: 1.0,
6253 lambdas: Array1::zeros(0),
6254 }]);
6255 let model = standard_binomial_model(fit);
6256 model
6257 .validate_required_posterior_mean_state()
6258 .expect("joint conditional covariance completes the curved-link fit");
6259
6260 let json = serde_json::to_string(&model).expect("serialize fitted model");
6261 let restored: FittedModel = serde_json::from_str(&json).expect("restore fitted model");
6262 restored
6263 .validate_required_posterior_mean_state()
6264 .expect("posterior state must survive the saved-model wire format");
6265 assert_eq!(
6266 restored
6267 .payload()
6268 .fit_result
6269 .as_ref()
6270 .and_then(UnifiedFitResult::beta_covariance),
6271 model
6272 .payload()
6273 .fit_result
6274 .as_ref()
6275 .and_then(UnifiedFitResult::beta_covariance),
6276 );
6277 }
6278
6279 #[test]
6280 fn curved_link_persistence_accepts_factorizable_saved_precision() {
6281 let mut fit = saved_fit(vec![FittedBlock {
6282 beta: array![0.25],
6283 role: BlockRole::Mean,
6284 edf: 1.0,
6285 lambdas: Array1::zeros(0),
6286 }]);
6287 fit.covariance_conditional = None;
6288 fit.geometry = Some(gam_solve::estimate::FitGeometry {
6289 coefficient_gauge: gam_problem::gauge::Gauge::identity(&[1]),
6290 penalized_hessian: gam_problem::dispersion_cov::UnscaledPrecision::wrap(array![[2.0]]),
6291 constrained_posterior: None,
6292 working: None,
6293 });
6294
6295 standard_binomial_model(fit)
6296 .validate_required_posterior_mean_state()
6297 .expect("a same-frame strictly-SPD precision can reconstruct posterior covariance");
6298 }
6299
6300 #[test]
6301 fn curved_link_persistence_rejects_active_frame_precision_without_lifted_covariance() {
6302 let mut fit = saved_fit(vec![FittedBlock {
6303 beta: array![0.25],
6304 role: BlockRole::Mean,
6305 edf: 1.0,
6306 lambdas: Array1::zeros(0),
6307 }]);
6308 fit.covariance_conditional = None;
6309 fit.geometry = Some(gam_solve::estimate::FitGeometry {
6310 coefficient_gauge: gam_problem::gauge::Gauge::from_block_transforms(&[array![[2.0]]]),
6311 penalized_hessian: gam_problem::dispersion_cov::UnscaledPrecision::wrap(array![[2.0]]),
6312 constrained_posterior: None,
6313 working: None,
6314 });
6315
6316 let error = standard_binomial_model(fit)
6317 .validate_required_posterior_mean_state()
6318 .expect_err("active-frame precision cannot be paired with raw prediction rows");
6319 assert!(error.to_string().contains("active gauge"));
6320 assert!(error.to_string().contains("lifted"));
6321 }
6322
6323 fn marginal_slope_payload(version: u32, fit: UnifiedFitResult) -> FittedModelPayload {
6324 let mut payload = FittedModelPayload::new(
6325 version,
6326 "y ~ 1".to_string(),
6327 ModelKind::MarginalSlope,
6328 FittedFamily::MarginalSlope {
6329 likelihood: LikelihoodSpec::binomial_probit(),
6330 base_link: InverseLink::Standard(StandardLink::Probit),
6331 frailty: FrailtySpec::None,
6332 },
6333 "bernoulli-marginal-slope".to_string(),
6334 );
6335 payload.fit_result = Some(fit.clone());
6336 payload.unified = Some(fit);
6337 payload.data_schema = Some(DataSchema {
6338 columns: vec![SchemaColumn {
6339 name: "z".to_string(),
6340 kind: ColumnKindTag::Continuous,
6341 levels: vec![],
6342 }],
6343 });
6344 payload.set_training_feature_metadata(vec!["z".to_string()], vec![(0.0, 0.0)]);
6345 payload.resolved_termspec = Some(empty_termspec());
6346 payload.resolved_termspec_logslope = Some(empty_termspec());
6347 payload.formula_logslope = Some("1".to_string());
6348 payload.z_column = Some("z".to_string());
6349 payload.latent_z_normalization = Some(SavedLatentZNormalization { mean: 0.0, sd: 1.0 });
6350 payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
6351 payload.marginal_baseline = Some(0.0);
6352 payload.logslope_baseline = Some(0.0);
6353 payload.link = Some(InverseLink::Standard(StandardLink::Probit));
6354 payload
6355 }
6356
6357 #[test]
6358 fn from_payload_synchronizes_used_device_from_saved_fit() {
6359 let mut fit = saved_fit(vec![
6360 FittedBlock {
6361 beta: Array1::from_vec(vec![0.25]),
6362 role: BlockRole::Mean,
6363 edf: 1.0,
6364 lambdas: Array1::zeros(0),
6365 },
6366 FittedBlock {
6367 beta: Array1::from_vec(vec![0.5]),
6368 role: BlockRole::Scale,
6369 edf: 1.0,
6370 lambdas: Array1::zeros(0),
6371 },
6372 ]);
6373 fit.used_device = true;
6374 let mut payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
6375 payload.used_device = false;
6376
6377 let model = FittedModel::from_payload(payload);
6378
6379 assert!(model.payload().used_device);
6380 }
6381
6382 fn survival_marginal_slope_payload(version: u32, fit: UnifiedFitResult) -> FittedModelPayload {
6383 let mut payload = FittedModelPayload::new(
6384 version,
6385 "Surv(entry, exit, event) ~ 1".to_string(),
6386 ModelKind::Survival,
6387 FittedFamily::Survival {
6388 likelihood: LikelihoodSpec::royston_parmar(),
6389 survival_likelihood: Some("marginal-slope".to_string()),
6390 survival_distribution: Some(ResidualDistribution::Gaussian),
6391 frailty: FrailtySpec::None,
6392 },
6393 "survival".to_string(),
6394 );
6395 payload.fit_result = Some(fit.clone());
6396 payload.unified = Some(fit);
6397 payload.survival_likelihood = Some("marginal-slope".to_string());
6398 payload.survival_distribution = Some(ResidualDistribution::Gaussian);
6399 payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
6400 payload.data_schema = Some(DataSchema {
6401 columns: vec![SchemaColumn {
6402 name: "z".to_string(),
6403 kind: ColumnKindTag::Continuous,
6404 levels: vec![],
6405 }],
6406 });
6407 payload.set_training_feature_metadata(vec!["z".to_string()], vec![(0.0, 0.0)]);
6408 payload.resolved_termspec = Some(empty_termspec());
6409 payload.resolved_termspec_logslope = Some(empty_termspec());
6410 payload.formula_logslope = Some("1".to_string());
6411 payload.z_column = Some("z".to_string());
6412 payload.latent_z_normalization = Some(SavedLatentZNormalization { mean: 0.0, sd: 1.0 });
6413 payload.survival_marginal_slope_score_covariance = Some(vec![vec![1.0]]);
6414 payload.logslope_baseline = Some(0.0);
6415 payload.link = Some(InverseLink::Standard(StandardLink::Probit));
6416 payload
6417 }
6418
6419 fn gamma_dispersion_location_scale_payload() -> FittedModelPayload {
6420 let mut payload = FittedModelPayload::new(
6426 MODEL_PAYLOAD_VERSION,
6427 "y ~ x".to_string(),
6428 ModelKind::LocationScale,
6429 FittedFamily::LocationScale {
6430 likelihood: LikelihoodSpec::gamma_log(),
6431 base_link: Some(InverseLink::Standard(StandardLink::Log)),
6432 },
6433 "gamma-location-scale".to_string(),
6434 );
6435 payload.data_schema = Some(DataSchema {
6436 columns: vec![
6437 SchemaColumn {
6438 name: "y".to_string(),
6439 kind: ColumnKindTag::Continuous,
6440 levels: vec![],
6441 },
6442 SchemaColumn {
6443 name: "x".to_string(),
6444 kind: ColumnKindTag::Continuous,
6445 levels: vec![],
6446 },
6447 ],
6448 });
6449 payload.set_training_feature_metadata(vec!["x".to_string()], vec![(-1.0, 1.0)]);
6450 payload.resolved_termspec = Some(empty_termspec());
6451 payload.resolved_termspec_noise = Some(empty_termspec());
6452 payload.formula_noise = Some("x".to_string());
6453 payload.beta_noise = Some(vec![0.0]);
6454 payload.link = Some(InverseLink::Standard(StandardLink::Log));
6455 payload
6456 }
6457
6458 #[test]
6465 fn dispersion_location_scale_payload_is_not_classified_binomial() {
6466 let model = FittedModel::from_payload(gamma_dispersion_location_scale_payload());
6467 assert_eq!(
6468 model.predict_model_class(),
6469 PredictModelClass::DispersionLocationScale,
6470 "Gamma dispersion location-scale must route through the dispersion \
6471 predictor, not the binomial threshold-scale class",
6472 );
6473 assert!(
6474 !matches!(
6475 model.predict_model_class(),
6476 PredictModelClass::BinomialLocationScale
6477 ),
6478 "dispersion location-scale must never be classified as binomial",
6479 );
6480
6481 for likelihood in [
6483 LikelihoodSpec::gamma_log(),
6484 LikelihoodSpec::new(
6485 ResponseFamily::NegativeBinomial {
6486 theta: 1.0,
6487 theta_fixed: false,
6488 },
6489 InverseLink::Standard(StandardLink::Log),
6490 ),
6491 LikelihoodSpec::new(
6492 ResponseFamily::Beta { phi: 1.0 },
6493 InverseLink::Standard(StandardLink::Logit),
6494 ),
6495 LikelihoodSpec::new(
6496 ResponseFamily::Tweedie { p: 1.5 },
6497 InverseLink::Standard(StandardLink::Log),
6498 ),
6499 ] {
6500 let mut payload = gamma_dispersion_location_scale_payload();
6501 payload.family_state = FittedFamily::LocationScale {
6502 base_link: Some(likelihood.link.clone()),
6503 likelihood: likelihood.clone(),
6504 };
6505 let model = FittedModel::from_payload(payload);
6506 assert_eq!(
6507 model.predict_model_class(),
6508 PredictModelClass::DispersionLocationScale,
6509 "dispersion family {:?} mis-classified",
6510 likelihood.response,
6511 );
6512 }
6513 }
6514
6515 #[test]
6516 fn axis_clip_leaves_numeric_random_effect_group_axis_unclipped() {
6517 let data = array![[100.0], [-100.0]];
6518 let col_map = HashMap::from([("g".to_string(), 0usize)]);
6519
6520 let mut plain_payload = standard_gaussian_payload();
6521 plain_payload.data_schema = Some(DataSchema {
6522 columns: vec![SchemaColumn {
6523 name: "g".to_string(),
6524 kind: ColumnKindTag::Continuous,
6525 levels: vec![],
6526 }],
6527 });
6528 plain_payload.set_training_feature_metadata(vec!["g".to_string()], vec![(0.0, 7.0)]);
6529 plain_payload.resolved_termspec = Some(empty_termspec());
6530 let plain = FittedModel::from_payload(plain_payload.clone());
6531 let clipped = plain
6532 .axis_clip_to_training_ranges(data.view(), &col_map)
6533 .expect("ordinary continuous axis should clip outside the training range");
6534 assert_eq!(clipped.column(0).to_vec(), vec![7.0, 0.0]);
6535
6536 let mut group_payload = plain_payload;
6537 let mut group_spec = empty_termspec();
6538 group_spec
6539 .random_effect_terms
6540 .push(gam_terms::smooth::RandomEffectTermSpec {
6541 name: "g".to_string(),
6542 feature_col: 0,
6543 drop_first_level: false,
6544 penalized: true,
6545 frozen_levels: Some(vec![0.0_f64.to_bits(), 7.0_f64.to_bits()]),
6546 lenient_unseen: true,
6547 });
6548 group_payload.resolved_termspec = Some(group_spec);
6549 let group_model = FittedModel::from_payload(group_payload);
6550
6551 assert_eq!(
6552 group_model.random_effect_group_columns(),
6553 HashSet::from(["g".to_string()])
6554 );
6555
6556 assert_eq!(
6557 group_model.axis_clip_to_training_ranges(data.view(), &col_map),
6558 None,
6559 "numeric group labels must reach RandomEffectOperator as unseen levels, not be clipped to boundary seen levels"
6560 );
6561 }
6562
6563 #[test]
6577 fn bare_categorical_fixed_factor_unseen_level_rejected_by_predict_encode() {
6578 use csv::StringRecord;
6579 use gam_data::{EncodedDataset, UnseenCategoryPolicy, encode_recordswith_schema};
6580 use gam_runtime::resource::ResourcePolicy;
6581 use gam_terms::inference::formula_dsl::parse_formula;
6582 use gam_terms::term_builder::build_termspec;
6583
6584 let train_schema = DataSchema {
6586 columns: vec![
6587 SchemaColumn {
6588 name: "y".to_string(),
6589 kind: ColumnKindTag::Continuous,
6590 levels: vec![],
6591 },
6592 SchemaColumn {
6593 name: "g".to_string(),
6594 kind: ColumnKindTag::Categorical,
6595 levels: vec!["a".to_string(), "b".to_string(), "c".to_string()],
6596 },
6597 ],
6598 };
6599 let train = EncodedDataset {
6600 headers: vec!["y".to_string(), "g".to_string()],
6601 values: Array2::from_shape_vec(
6602 (6, 2),
6603 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],
6604 )
6605 .expect("rectangular training frame"),
6606 schema: train_schema.clone(),
6607 column_kinds: vec![ColumnKindTag::Continuous, ColumnKindTag::Categorical],
6608 };
6609 let build_col_map = train.column_map();
6610
6611 let model_for = |formula: &str| -> FittedModel {
6612 let parsed = parse_formula(formula).expect("formula parses");
6613 let mut notes = Vec::new();
6614 let spec = build_termspec(
6615 &parsed.terms,
6616 &train,
6617 &build_col_map,
6618 &mut notes,
6619 &ResourcePolicy::default_library(),
6620 )
6621 .unwrap_or_else(|err| panic!("`{formula}` must build a term spec, got: {err:?}"));
6622 let mut payload = standard_gaussian_payload();
6623 payload.data_schema = Some(train_schema.clone());
6624 payload.set_training_feature_metadata(
6625 vec!["y".to_string(), "g".to_string()],
6626 vec![(0.0, 2.0), (0.0, 2.0)],
6627 );
6628 payload.resolved_termspec = Some(spec);
6629 FittedModel::from_payload(payload)
6630 };
6631
6632 let g_schema = DataSchema {
6634 columns: vec![SchemaColumn {
6635 name: "g".to_string(),
6636 kind: ColumnKindTag::Categorical,
6637 levels: vec!["a".to_string(), "b".to_string(), "c".to_string()],
6638 }],
6639 };
6640 let encode_level = |model: &FittedModel, level: &str| -> Result<EncodedDataset, String> {
6641 let policy = UnseenCategoryPolicy::encode_unknown_for_columns(
6642 model.random_effect_group_columns(),
6643 );
6644 encode_recordswith_schema(
6645 vec!["g".to_string()],
6646 vec![StringRecord::from(vec![level])],
6647 &g_schema,
6648 policy,
6649 )
6650 };
6651
6652 let bare = model_for("y ~ g");
6655 assert!(
6656 !bare.random_effect_group_columns().contains("g"),
6657 "bare `+ g` is a fixed parametric factor; it must NOT be whitelisted for lenient \
6658 unseen-level encoding (#2102)"
6659 );
6660 encode_level(&bare, "a").expect("a seen level must still encode for the fixed factor");
6661 let err = encode_level(&bare, "TYPO")
6662 .expect_err("an unseen fixed-factor level must raise a schema mismatch (#2102)");
6663 assert!(
6664 err.contains("unseen level"),
6665 "expected an unseen-level schema mismatch naming the level, got: {err}"
6666 );
6667
6668 let factor = model_for("y ~ factor(g)");
6673 assert!(
6674 !factor.random_effect_group_columns().contains("g"),
6675 "factor(g) is a FIXED categorical factor; it must NOT be whitelisted for lenient \
6676 unseen-level encoding (#2137)"
6677 );
6678 encode_level(&factor, "a").expect("a seen level must still encode for factor(g)");
6679 let factor_err = encode_level(&factor, "TYPO")
6680 .expect_err("an unseen factor(g) level must raise a schema mismatch (#2137)");
6681 assert!(
6682 factor_err.contains("unseen level"),
6683 "expected an unseen-level schema mismatch naming the level, got: {factor_err}"
6684 );
6685
6686 for formula in ["y ~ group(g)", "y ~ re(g)", "y ~ s(g, bs=\"re\")"] {
6689 let grouped = model_for(formula);
6690 assert!(
6691 grouped.random_effect_group_columns().contains("g"),
6692 "`{formula}` is a random effect; it must remain lenient on unseen levels \
6693 (held-out-group policy)"
6694 );
6695 encode_level(&grouped, "TYPO").unwrap_or_else(|err| {
6696 panic!("`{formula}` must tolerate an unseen level, got: {err}")
6697 });
6698 }
6699 }
6700
6701 #[test]
6702 fn validate_for_persistence_rejects_marginal_slope_score_warp_basis_mismatch() {
6703 let fit = saved_fit(vec![
6704 FittedBlock {
6705 beta: array![0.1],
6706 role: BlockRole::Mean,
6707 edf: 1.0,
6708 lambdas: Array1::zeros(0),
6709 },
6710 FittedBlock {
6711 beta: array![0.2],
6712 role: BlockRole::Scale,
6713 edf: 1.0,
6714 lambdas: Array1::zeros(0),
6715 },
6716 FittedBlock {
6717 beta: array![0.3],
6718 role: BlockRole::Mean,
6719 edf: 1.0,
6720 lambdas: Array1::zeros(0),
6721 },
6722 ]);
6723 let mut payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
6724 payload.score_warp_runtime = Some(anchored_runtime(2));
6725
6726 let err = FittedModel::from_payload(payload)
6727 .validate_for_persistence()
6728 .expect_err("marginal-slope score-warp basis mismatch should fail validation");
6729 assert!(err.to_string().contains("score-warp coefficient mismatch"));
6730 }
6731
6732 #[test]
6733 fn saved_prediction_runtime_rejects_survival_marginal_slope_link_basis_mismatch() {
6734 let fit = saved_fit(vec![
6735 FittedBlock {
6736 beta: array![0.1],
6737 role: BlockRole::Time,
6738 edf: 1.0,
6739 lambdas: Array1::zeros(0),
6740 },
6741 FittedBlock {
6742 beta: array![0.2],
6743 role: BlockRole::Mean,
6744 edf: 1.0,
6745 lambdas: Array1::zeros(0),
6746 },
6747 FittedBlock {
6748 beta: array![0.3],
6749 role: BlockRole::Scale,
6750 edf: 1.0,
6751 lambdas: Array1::zeros(0),
6752 },
6753 FittedBlock {
6754 beta: array![0.4],
6755 role: BlockRole::LinkWiggle,
6756 edf: 1.0,
6757 lambdas: Array1::zeros(0),
6758 },
6759 ]);
6760 let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
6761 payload.link_deviation_runtime = Some(anchored_runtime(2));
6762
6763 let err = FittedModel::from_payload(payload)
6764 .saved_prediction_runtime()
6765 .expect_err(
6766 "survival marginal-slope link basis mismatch should fail runtime validation",
6767 );
6768 assert!(
6769 err.to_string()
6770 .contains("link-deviation coefficient mismatch")
6771 );
6772 }
6773
6774 #[test]
6775 fn apply_survival_time_basis_writes_all_required_fields() {
6776 use crate::survival::construction::SavedSurvivalTimeBasis;
6777
6778 let fit = saved_fit(vec![
6779 FittedBlock {
6780 beta: array![0.1],
6781 role: BlockRole::Time,
6782 edf: 1.0,
6783 lambdas: Array1::zeros(0),
6784 },
6785 FittedBlock {
6786 beta: array![0.2],
6787 role: BlockRole::Mean,
6788 edf: 1.0,
6789 lambdas: Array1::zeros(0),
6790 },
6791 FittedBlock {
6792 beta: array![0.3],
6793 role: BlockRole::Scale,
6794 edf: 1.0,
6795 lambdas: Array1::zeros(0),
6796 },
6797 ]);
6798 let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
6799
6800 let snapshot = SavedSurvivalTimeBasis {
6805 basisname: "royston-parmar".to_string(),
6806 degree: Some(3),
6807 knots: Some(vec![0.0, 1.0, 2.0]),
6808 keep_cols: Some(vec![0, 2]),
6809 smooth_lambda: Some(0.5),
6810 anchor: 0.25,
6811 };
6812 payload.apply_survival_time_basis(&snapshot);
6813
6814 assert_eq!(
6815 payload.survival_time_basis.as_deref(),
6816 Some("royston-parmar")
6817 );
6818 assert_eq!(payload.survival_time_degree, Some(3));
6819 assert_eq!(payload.survival_time_knots, Some(vec![0.0, 1.0, 2.0]));
6820 assert_eq!(payload.survival_time_keep_cols, Some(vec![0, 2]));
6821 assert_eq!(payload.survival_time_smooth_lambda, Some(0.5));
6822 assert_eq!(payload.survival_time_anchor, Some(0.25));
6823 }
6824
6825 #[test]
6826 fn validate_for_persistence_rejects_survival_without_time_anchor_metadata() {
6827 let fit = saved_fit(vec![
6828 FittedBlock {
6829 beta: array![0.1],
6830 role: BlockRole::Time,
6831 edf: 1.0,
6832 lambdas: Array1::zeros(0),
6833 },
6834 FittedBlock {
6835 beta: array![0.2],
6836 role: BlockRole::Mean,
6837 edf: 1.0,
6838 lambdas: Array1::zeros(0),
6839 },
6840 FittedBlock {
6841 beta: array![0.3],
6842 role: BlockRole::Scale,
6843 edf: 1.0,
6844 lambdas: Array1::zeros(0),
6845 },
6846 ]);
6847 let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
6848 payload.survival_time_basis = Some("ispline".to_string());
6854
6855 let err = FittedModel::from_payload(payload)
6856 .validate_for_persistence()
6857 .expect_err("survival model without time-anchor metadata should fail validation");
6858 assert!(err.to_string().contains("missing survival_time_anchor"));
6859 }
6860
6861 #[test]
6862 fn validate_for_persistence_rejects_survival_without_time_basis_metadata() {
6863 let fit = saved_fit(vec![
6864 FittedBlock {
6865 beta: array![0.1],
6866 role: BlockRole::Time,
6867 edf: 1.0,
6868 lambdas: Array1::zeros(0),
6869 },
6870 FittedBlock {
6871 beta: array![0.2],
6872 role: BlockRole::Mean,
6873 edf: 1.0,
6874 lambdas: Array1::zeros(0),
6875 },
6876 FittedBlock {
6877 beta: array![0.3],
6878 role: BlockRole::Scale,
6879 edf: 1.0,
6880 lambdas: Array1::zeros(0),
6881 },
6882 ]);
6883 let payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
6884
6885 let err = FittedModel::from_payload(payload)
6886 .validate_for_persistence()
6887 .expect_err("survival model without time-basis metadata should fail validation");
6888 assert!(err.to_string().contains("missing survival_time_basis"));
6889 }
6890
6891 #[test]
6892 fn saved_prediction_runtime_rejects_stale_payload_version() {
6893 let fit = saved_fit(vec![
6894 FittedBlock {
6895 beta: array![0.1],
6896 role: BlockRole::Mean,
6897 edf: 1.0,
6898 lambdas: Array1::zeros(0),
6899 },
6900 FittedBlock {
6901 beta: array![0.2],
6902 role: BlockRole::Scale,
6903 edf: 1.0,
6904 lambdas: Array1::zeros(0),
6905 },
6906 ]);
6907 let payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION - 1, fit);
6908
6909 let err = FittedModel::from_payload(payload)
6910 .saved_prediction_runtime()
6911 .expect_err("stale payload version should fail before runtime assembly");
6912 assert!(err.to_string().contains("payload schema mismatch"));
6913 }
6914
6915 #[test]
6916 fn saved_link_wiggle_warp_index_applies_exact_2141_mean_shift() {
6917 let runtime = SavedLinkWiggleRuntime {
6918 knots: vec![],
6919 degree: 0,
6920 penalty_metadata: None,
6921 beta: vec![],
6922 index_shift: Some(vec![0.25, -0.5]),
6923 };
6924 let design = DesignMatrix::from(array![[1.0, 2.0], [-3.0, 0.5]]);
6925 let base = array![0.75, -0.25];
6926 let index = runtime
6927 .warp_index(&base, &design)
6928 .expect("complete saved shift");
6929 assert_eq!(index, array![0.0, -1.25]);
6930 }
6931
6932 #[test]
6933 fn saved_link_wiggle_warp_index_rejects_partial_shift_coordinates() {
6934 let runtime = SavedLinkWiggleRuntime {
6935 knots: vec![],
6936 degree: 0,
6937 penalty_metadata: None,
6938 beta: vec![],
6939 index_shift: Some(vec![0.25]),
6940 };
6941 let design = DesignMatrix::from(array![[1.0, 2.0]]);
6942 let error = runtime
6943 .warp_index(&array![0.5], &design)
6944 .expect_err("partial #2141 shift metadata must fail loudly");
6945 assert!(error.to_string().contains("shift has 1 entries"));
6946 assert!(error.to_string().contains("mean design has 2 columns"));
6947 }
6948}